Skip to content

Commit d5bada3

Browse files
44-99claude
andcommitted
fix: complete v3 engine gaps — emergency detection, risk.py integration, signal stops, emergency actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6c9601b commit d5bada3

3 files changed

Lines changed: 211 additions & 19 deletions

File tree

‎tools/backtest_runner.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def main():
110110
)
111111

112112
try:
113-
engine.run()
113+
engine.run_backtest()
114114
except KeyboardInterrupt:
115115
print("\n[BacktestRunner] Interrupted.")
116116
engine.stop()

‎tools/paper_engine.py‎

Lines changed: 208 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def _default_v2_fields() -> dict:
277277
"position_cap_pct": 80.0,
278278
"preferred_sectors": [],
279279
"avoid_sectors": [],
280-
"emergency_triggers": {"market_drop_pct": 3.0, "single_stock_drop_pct": 5.0},
280+
"emergency_triggers": {"market_drop_pct": 3.0, "single_stock_drop_pct": 5.0, "account_drawdown_pct": 10.0},
281281
"buy_candidates": [],
282282
"holding_adjustments": [],
283283
"risk_report": {"rejected_candidates": [], "correlation_matrix": {}},
@@ -821,6 +821,23 @@ def __init__(self, start_date: str, end_date: str,
821821
df = df.sort_values("date").reset_index(drop=True)
822822
self._cache[code] = df[df["date"] <= self.end]
823823

824+
# Fetch Shanghai Composite index for emergency detection
825+
self._index_cache: pd.DataFrame | None = None
826+
try:
827+
import akshare as ak
828+
idx_df = ak.stock_zh_index_daily(symbol="sh000001")
829+
if not idx_df.empty:
830+
idx_df = idx_df.rename(columns={
831+
"日期": "date", "开盘": "open", "收盘": "close",
832+
"最高": "high", "最低": "low", "成交量": "volume",
833+
})
834+
idx_df["date"] = pd.to_datetime(idx_df["date"])
835+
idx_df = idx_df.sort_values("date").reset_index(drop=True)
836+
idx_df = idx_df[idx_df["date"] <= self.end]
837+
self._index_cache = idx_df
838+
except Exception:
839+
self._index_cache = None
840+
824841
def current_day_data(self, date: pd.Timestamp) -> dict[str, dict]:
825842
"""Get all universe stocks' data for a specific date as quote dicts."""
826843
quotes = {}
@@ -845,6 +862,29 @@ def current_day_data(self, date: pd.Timestamp) -> dict[str, dict]:
845862
(float(row["close"]) - prev_close) / prev_close * 100, 2
846863
) if prev_close else 0,
847864
}
865+
# Include market index data for emergency detection
866+
INDEX_CODE = "000001"
867+
if self._index_cache is not None:
868+
idx_row = self._index_cache[self._index_cache["date"] == date]
869+
if idx_row.empty:
870+
idx_row = self._index_cache[self._index_cache["date"] <= date].tail(1)
871+
if not idx_row.empty:
872+
idx_row = idx_row.iloc[-1]
873+
prev_idx = self._index_cache[self._index_cache["date"] < date]
874+
idx_prev_close = float(prev_idx.iloc[-1]["close"]) if not prev_idx.empty else float(idx_row["open"])
875+
quotes[INDEX_CODE] = {
876+
"code": INDEX_CODE,
877+
"price": float(idx_row["close"]),
878+
"open": float(idx_row["open"]),
879+
"high": float(idx_row["high"]),
880+
"low": float(idx_row["low"]),
881+
"prev_close": idx_prev_close,
882+
"volume": int(idx_row.get("volume", 0)),
883+
"change_pct": round(
884+
(float(idx_row["close"]) - idx_prev_close) / idx_prev_close * 100, 2
885+
) if idx_prev_close else 0,
886+
}
887+
848888
return quotes
849889

850890
def get_history_up_to(self, code: str, date: pd.Timestamp,
@@ -881,13 +921,14 @@ class OvernightPipeline:
881921

882922
def __init__(self, state: EngineState, plan: PlanManager,
883923
ledger: Ledger, clock: TradingClock, output_dir: str,
884-
mode: str = "paper"):
924+
mode: str = "paper", execution: "ExecutionEngine" = None):
885925
self.state = state
886926
self.plan = plan
887927
self.ledger = ledger
888928
self.clock = clock
889929
self.output_dir = output_dir
890930
self.mode = mode
931+
self.execution = execution
891932

892933
# ── Phase 0: Sub-Agent Research ───────────────────────────────
893934

@@ -1223,13 +1264,20 @@ def _parse_merged_response(self, response: str) -> dict:
12231264

12241265
def run_risk_validation(self) -> dict:
12251266
"""Python risk.py + signal.py hard validation on merged stage output."""
1267+
from risk import (
1268+
calc_volatility_metrics, calc_volatility_adjusted_limit,
1269+
calc_position_size, max_drawdown_check,
1270+
)
1271+
12261272
candidates = self.plan._data.get("buy_candidates", [])
12271273
rejected = []
12281274
passed = []
12291275
for c in candidates:
12301276
code = c.get("code", "")
12311277
if not code:
12321278
continue
1279+
1280+
# ── Hard checks (must pass) ──
12331281
entry = c.get("entry_max", 0)
12341282
stop = c.get("stop_loss", 0)
12351283
if stop >= entry:
@@ -1238,7 +1286,62 @@ def run_risk_validation(self) -> dict:
12381286
if entry > 0 and (entry - stop) / entry < 0.03:
12391287
rejected.append({"code": code, "reason": "risk/reward ratio too low", "rule": "signal_hard_check"})
12401288
continue
1241-
passed.append(c)
1289+
1290+
# ── risk.py quantitative checks (non-fatal on error) ──
1291+
try:
1292+
from _fallback import get_hist
1293+
df, _ = get_hist(code, days=120)
1294+
if df.empty:
1295+
rejected.append({"code": code, "reason": "no historical data", "rule": "risk_data"})
1296+
continue
1297+
1298+
closes = [float(x) for x in df["close"].tolist()]
1299+
price = closes[-1]
1300+
1301+
# Volatility check
1302+
vol = calc_volatility_metrics(closes)
1303+
annualized_vol = vol["annualized_volatility"]
1304+
limit_pct = calc_volatility_adjusted_limit(annualized_vol)
1305+
1306+
# Position sizing — ensure candidate position doesn't exceed vol-adjusted limit
1307+
sizing = calc_position_size(price, self.state.initial_capital, limit_pct)
1308+
sizing_limit_pct = sizing["position_limit_pct"]
1309+
candidate_pct = c.get("position_pct", 20)
1310+
if candidate_pct > sizing_limit_pct:
1311+
rejected.append({
1312+
"code": code,
1313+
"reason": (
1314+
f"仓位{candidate_pct}%超出波动率调整上限{sizing_limit_pct}% "
1315+
f"(年化波动率{annualized_vol:.1%})"
1316+
),
1317+
"rule": "risk_volatility",
1318+
})
1319+
continue
1320+
1321+
# Drawdown check
1322+
dd = max_drawdown_check(closes)
1323+
if dd.get("warn"):
1324+
rejected.append({
1325+
"code": code,
1326+
"reason": (
1327+
f"个票回撤警告: 当前回撤{dd['current_drawdown_pct']}%,"
1328+
f"历史最大回撤{dd['max_historical_drawdown_pct']}%"
1329+
),
1330+
"rule": "risk_drawdown",
1331+
})
1332+
continue
1333+
1334+
# Inject risk-adjusted sizing
1335+
c["position_limit_pct"] = sizing_limit_pct
1336+
c["max_shares"] = sizing["max_shares"]
1337+
c["volatility"] = vol
1338+
passed.append(c)
1339+
1340+
except Exception as exc:
1341+
# Non-fatal: let candidate pass through on error
1342+
print(f"[Risk] soft error for {code}: {exc}")
1343+
passed.append(c)
1344+
12421345
self.plan._data["buy_candidates"] = passed
12431346
self.plan._data["risk_report"] = {
12441347
"rejected_candidates": rejected,
@@ -1328,11 +1431,54 @@ def _apply_emergency_decisions(self, response: str) -> None:
13281431
except ValueError:
13291432
pass
13301433
elif action == "emergency_action":
1434+
action_type = parts[2] if len(parts) > 2 else ""
1435+
code_arg = parts[3] if len(parts) > 3 else ""
1436+
reasoning = ""
1437+
for kv in parts[4:]:
1438+
if kv.startswith("reasoning="):
1439+
reasoning = kv.split("=", 1)[1]
1440+
break
1441+
1442+
executed = False
1443+
if action_type == "close_all" and self.execution:
1444+
for h_code, h in list(self.state.holdings.items()):
1445+
shares = h.get("shares", 0)
1446+
price = h.get("current_price", 0)
1447+
if shares >= 100 and price > 0:
1448+
self.execution.execute_sell(
1449+
h_code, shares, price,
1450+
reason=f"emergency_close_all: {reasoning}",
1451+
)
1452+
executed = True
1453+
elif action_type == "reduce" and code_arg and self.execution:
1454+
h = self.state.holdings.get(code_arg, {})
1455+
shares = h.get("shares", 0)
1456+
price = h.get("current_price", 0)
1457+
if shares >= 100 and price > 0:
1458+
reduce_qty = (shares // 200) * 100
1459+
if reduce_qty >= 100:
1460+
self.execution.execute_sell(
1461+
code_arg, reduce_qty, price,
1462+
reason=f"emergency_reduce: {reasoning}",
1463+
)
1464+
executed = True
1465+
elif action_type == "close" and code_arg and self.execution:
1466+
h = self.state.holdings.get(code_arg, {})
1467+
shares = h.get("shares", 0)
1468+
price = h.get("current_price", 0)
1469+
if shares >= 100 and price > 0:
1470+
self.execution.execute_sell(
1471+
code_arg, shares, price,
1472+
reason=f"emergency_close: {reasoning}",
1473+
)
1474+
executed = True
1475+
13311476
self.ledger.append({
13321477
"decision": "emergency_action",
1333-
"action": parts[2] if len(parts) > 2 else "",
1334-
"code": parts[3] if len(parts) > 3 else "",
1335-
"reasoning": parts[5] if len(parts) > 5 else "",
1478+
"action": action_type,
1479+
"code": code_arg,
1480+
"reasoning": reasoning,
1481+
"executed": executed,
13361482
})
13371483

13381484
# ═══════════════════════════════════════════════════════════════
@@ -1424,10 +1570,34 @@ def _fetch_one(code):
14241570
if q:
14251571
quotes[code] = q
14261572

1573+
# Fetch market index for emergency detection (live/paper mode)
1574+
if not (self.data_feed and self.mode == "backtest"):
1575+
try:
1576+
from quote import get_market_overview
1577+
overview = get_market_overview()
1578+
if overview and not overview.get("error"):
1579+
for idx in overview.get("indices", []):
1580+
if "上证" in idx.get("name", ""):
1581+
quotes["000001"] = {
1582+
"price": idx.get("price", 0),
1583+
"change_pct": idx.get("change_pct", 0),
1584+
}
1585+
break
1586+
except Exception:
1587+
pass
1588+
14271589
# Update prices in state
14281590
for code, q in quotes.items():
1591+
if q.get("code") == "000001":
1592+
continue # skip index in state update
14291593
self.state.update_quote(code, q.get("price", 0))
14301594

1595+
# Track market price for emergency comparison
1596+
market_q = quotes.get("000001", {})
1597+
current_market_price = market_q.get("price", 0)
1598+
if current_market_price > 0 and self._prev_market_price <= 0:
1599+
self._prev_market_price = current_market_price
1600+
14311601
# 1. Stop-loss / take-profit triggers (always allowed, even during circuit breaker)
14321602
triggers = self.execution.check_stop_triggers(quotes)
14331603
for t in triggers:
@@ -1602,6 +1772,11 @@ def _scan_one(code):
16021772

16031773
self.state.snapshot_nav()
16041774
self.state.save()
1775+
1776+
# Update previous market price for next tick's emergency comparison
1777+
if current_market_price > 0:
1778+
self._prev_market_price = current_market_price
1779+
16051780
return {"events": events, "emergency": emergency, "trigger_reason": trigger_reason}
16061781

16071782
def execute_holding_adjustments(self) -> list[dict]:
@@ -1645,23 +1820,37 @@ def execute_holding_adjustments(self) -> list[dict]:
16451820
return results
16461821

16471822
def _check_emergency(self, quotes: dict) -> tuple:
1648-
"""Check for emergency conditions: market drop >3% or single stock drop >5%.
1823+
"""Check for emergency conditions: market -3%, account -10%, or single stock -5%.
16491824
16501825
Returns (is_emergency: bool, reason: str).
16511826
"""
16521827
triggers = self.plan.get_emergency_triggers()
16531828
stock_limit = triggers.get("single_stock_drop_pct", 5.0)
1829+
market_drop_pct = triggers.get("market_drop_pct", 3.0)
1830+
account_drawdown_pct = triggers.get("account_drawdown_pct", 10.0)
1831+
1832+
# Check market index drop vs previous close
1833+
market_q = quotes.get("000001", {})
1834+
current_market_price = market_q.get("price", 0)
1835+
if current_market_price > 0 and self._prev_market_price > 0:
1836+
drop_pct = (self._prev_market_price - current_market_price) / self._prev_market_price * 100
1837+
if drop_pct >= market_drop_pct:
1838+
return True, (
1839+
f"大盘下跌{drop_pct:.1f}% "
1840+
f"(从{self._prev_market_price:.2f}至{current_market_price:.2f},"
1841+
f"触发阈值{market_drop_pct}%)"
1842+
)
16541843

1655-
# Check market index (use Shanghai composite from market quote)
1656-
market_price = 0.0
1657-
for code, q in quotes.items():
1658-
if code in ("000001", "sh", "market"):
1659-
market_price = q.get("price", 0)
1660-
break
1661-
# If no explicit market quote, check first available
1662-
if market_price <= 0 and quotes:
1663-
# Use the first quote as a rough proxy
1664-
pass
1844+
# Check total account drawdown
1845+
if self.state.initial_capital > 0:
1846+
drawdown = (self.state.total_value - self.state.initial_capital) / self.state.initial_capital * 100
1847+
if drawdown <= -account_drawdown_pct:
1848+
return True, (
1849+
f"账户回撤{abs(drawdown):.1f}% "
1850+
f"(总资产{self.state.total_value:,.0f},"
1851+
f"初始资金{self.state.initial_capital:,.0f},"
1852+
f"触发阈值{account_drawdown_pct}%)"
1853+
)
16651854

16661855
# Check individual holdings for >stock_limit drop
16671856
for code, h in self.state.holdings.items():
@@ -1670,7 +1859,7 @@ def _check_emergency(self, quotes: dict) -> tuple:
16701859
if cost > 0 and current > 0:
16711860
drop_pct = (cost - current) / cost * 100
16721861
if drop_pct >= stock_limit:
1673-
return True, f"{code} drop {drop_pct:.1f}% from cost {cost:.2f} to {current:.2f}"
1862+
return True, f"{code} 个票下跌{drop_pct:.1f}% (成本{cost:.2f} 现价{current:.2f})"
16741863

16751864
return False, ""
16761865

@@ -1745,6 +1934,7 @@ def __init__(self, mode: str = "paper", capital: float = 100000,
17451934
self.pipeline = OvernightPipeline(
17461935
self.state, self.plan, self.ledger,
17471936
self.clock, self.output_dir, mode,
1937+
execution=self.execution,
17481938
)
17491939

17501940
def run_overnight(self) -> dict | None:

‎tools/signal_rules.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def check_ma_cross(df: pd.DataFrame) -> dict | None:
4040
"ma5": round(float(ma5[i]), 2),
4141
"ma10": round(float(ma10[i]), 2),
4242
"price": round(float(closes.iloc[i]), 2),
43+
"suggested_stop": round(float(closes.iloc[i]) * 0.97, 2),
4344
}
4445
if ma5[i] < ma10[i] and ma5[j] >= ma10[j]:
4546
return {
@@ -140,6 +141,7 @@ def check_ma_alignment(df: pd.DataFrame) -> dict | None:
140141
"ma5": round(float(ma5[-1]), 2),
141142
"ma10": round(float(ma10[-1]), 2),
142143
"ma20": round(float(ma20[-1]), 2),
144+
"suggested_stop": round(float(closes.iloc[-1]) * 0.96, 2),
143145
}
144146
if curr_bearish and not prev_bearish:
145147
return {

0 commit comments

Comments
 (0)