@@ -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 :
0 commit comments