From 64cb460748b259a682f52aa332c902907aeb3734 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 29 Aug 2026 08:22:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(live):=2008-28=E5=A4=8D=E7=9B=98=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=E4=B8=89=E8=BF=9E(003011=E5=AE=88=E6=81=92120?= =?UTF-8?q?=E8=82=A1+600818=E5=BA=9F=E5=8D=95+day=5Fcount5=E8=BF=9E?= =?UTF-8?q?=E6=97=A5day=3D0)=E2=80=94=E2=80=94=E2=91=A0EOD=E8=A1=A5?= =?UTF-8?q?=E6=8F=92=E5=A4=B1=E6=95=88=E4=BF=AE=E5=A4=8D:=E5=AE=88?= =?UTF-8?q?=E6=81=92=E7=BC=BA=E5=8F=A3=E9=9D=9E=E7=A9=BA=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=80=E5=A4=A9=E4=B8=80=E8=B7=91,15:05-23:00=E6=AF=8F?= =?UTF-8?q?=E8=BD=AE=E9=87=8D=E6=9F=A5(QMT=E5=BD=93=E6=97=A5=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E6=99=9A=E9=97=B4=E6=89=8D=E8=A1=A5=E5=85=A8:003011?= =?UTF-8?q?=E5=AE=9E=E9=94=A415:05=E5=BF=AB=E7=85=A7=E7=BC=BA=E7=AC=AC10?= =?UTF-8?q?=E7=AC=94/=E7=94=A8=E6=88=B722=E7=82=B9=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=B7=B2=E9=BD=90=E5=85=A8=3D=E5=BD=93=E6=99=9A=E8=87=AA?= =?UTF-8?q?=E6=84=88=E7=AA=97=E5=8F=A3,23:00=E5=90=8E=E8=AE=B0=E6=97=A5?= =?UTF-8?q?=E6=94=BE=E5=BC=83=E9=98=B2=E6=97=A0=E6=95=88=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?)+=E7=BC=BA=E5=8F=A3=E6=A0=87=E7=9A=84QMT=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E8=A1=8C=E7=9B=B4=E6=8E=A5=E6=89=93=E8=BF=9B=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E5=8F=96=E8=AF=81(=E5=BD=93=E6=97=B6=E6=B2=A1dump=E4=BA=8B?= =?UTF-8?q?=E5=90=8E=E5=8F=AA=E8=83=BD=E7=8C=9C)+=E5=90=8C=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=87=8D=E8=AF=95=E8=BD=AE=E9=99=8DINFO=E9=98=B260s?= =?UTF-8?q?=E5=88=B7=E5=B1=8F;=E2=91=A1=E5=BD=93=E6=97=A5=E5=BA=9F?= =?UTF-8?q?=E5=8D=95=E5=91=8A=E8=AD=A6=E5=85=A5EOD=E6=B1=87=E6=80=BB(60081?= =?UTF-8?q?8=E5=BD=A2=E6=80=81:filled=3D0=E5=BA=9F=E5=8D=95=E7=9B=98?= =?UTF-8?q?=E4=B8=AD=E5=8F=AA=E6=9C=89ERROR=E6=97=A5=E5=BF=97=E8=BA=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=87=8C,=E5=A4=8D=E7=9B=98=E6=89=8D?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E5=BD=93=E6=97=A5=E7=BC=BA=E4=BB=93~8?= =?UTF-8?q?=E4=B8=87);=E2=91=A2small=5Fcap=20day=5Fcount=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96(runtime/state=5F{=E5=AE=9E=E4=BE=8Benv}/small=5Fcap?= =?UTF-8?q?=5Fday.json,08-24~28=E8=BF=9E=E7=BB=AD5=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=97=A5=E9=87=8D=E5=90=AF=E5=BD=92=E9=9B=B6day=3D0=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E8=B0=83=E4=BB=93=E6=A0=B9=E6=B2=BB,tc=3D5=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E8=B7=A8=E9=87=8D=E5=90=AF=E4=BF=9D=E6=8C=81;?= =?UTF-8?q?=E9=9A=94=E6=97=A5=E9=87=8D=E5=90=AF+1/=E5=90=8C=E6=97=A5?= =?UTF-8?q?=E4=B8=8D=E5=8A=A0/=E5=81=9C=E6=91=86=E8=B7=A8=E5=A4=9A?= =?UTF-8?q?=E6=97=A5=E5=8F=AA+1=E8=87=AA=E6=B4=BD;=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E6=97=A0=E5=AE=9E=E4=BE=8Benv=E4=B8=8D=E8=90=BD=E7=9B=98=3D?= =?UTF-8?q?=E6=97=A7=E5=86=85=E5=AD=98=E8=AF=AD=E4=B9=89;=E5=8E=9F?= =?UTF-8?q?=E7=89=88+=5Fex=E5=90=8C=E6=8E=A5all=5Fweather=E5=85=B1?= =?UTF-8?q?=E4=BA=ABhelper,TET=E5=89=AF=E6=9C=ACdiff=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=8D=E7=A0=B4);+=E6=B5=8B=E8=AF=95:003011=E5=8D=81?= =?UTF-8?q?=E7=AC=94=E5=AD=AA=E7=94=9Fvs=E4=B9=9D=E8=A1=8C=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E5=A4=8D=E7=8E=B0=E8=A1=A5=E6=8F=92/=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E9=87=8D=E8=AF=95=E5=88=B0=E8=A7=86=E5=9B=BE=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E5=8D=B3=E9=97=AD=E7=8E=AF/=E5=BA=9F=E5=8D=95?= =?UTF-8?q?=E5=91=8A=E8=AD=A6+=E9=83=A8=E5=88=86=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E4=B8=8E=E8=B7=A8=E6=97=A5=E4=B8=8D=E8=BF=9B=E6=B8=85=E5=8D=95?= =?UTF-8?q?/=E6=8C=81=E4=B9=85=E5=8C=964=E6=80=81;=E9=A1=BA=E6=89=8B?= =?UTF-8?q?=E4=BF=AEtests/test=5Flive=5Fapi=E9=A2=84=E5=AD=98=E7=BA=A29?= =?UTF-8?q?=E4=B8=AA(B3=20f9b6d77=E5=8F=AA=E7=BB=99=E4=B8=A4=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=92=AD=E7=A7=8D=E5=BF=AB=E7=85=A7=E6=BC=8F=E4=BA=86?= =?UTF-8?q?=E6=9C=AC=E6=96=87=E4=BB=B6,=E7=85=A7=E6=8A=84=E5=90=8C?= =?UTF-8?q?=E6=AC=BE=E4=BF=AE=E6=B3=95;=E7=A9=BA=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E6=94=B9create=E5=8D=B3400=E6=96=B0=E5=A5=91?= =?UTF-8?q?=E7=BA=A6);=E5=85=A8=E9=87=8F1333=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/live_reconcile.py | 77 ++++++++++-- sanguo_portfolio/strategies/all_weather.py | 52 ++++++++ sanguo_portfolio/strategies/small_cap.py | 16 ++- sanguo_portfolio/strategies/small_cap_ex.py | 16 ++- tests/portfolio/test_live_reconcile.py | 132 +++++++++++++++++++- tests/portfolio/test_small_cap.py | 62 +++++++++ tests/test_live_api.py | 17 ++- 7 files changed, 357 insertions(+), 15 deletions(-) diff --git a/sanguo_portfolio/live_reconcile.py b/sanguo_portfolio/live_reconcile.py index d076f3b..66dccdf 100644 --- a/sanguo_portfolio/live_reconcile.py +++ b/sanguo_portfolio/live_reconcile.py @@ -34,11 +34,18 @@ _TERMINAL = {"filled", "cancelled", "canceled", "partly_canceled", # EOD 对账窗口:15:05 起(60s 轮询首落在 15:05-15:06),赶在 15:10 恒等式前 EOD_AFTER = (15, 5) +# 重试窗口上界:QMT 当日成交 API 零点翻篇,23:00 后再查无意义 → 放弃留证。 +# 08-28 003011 实锤:15:05 快照缺第 10 笔成交,用户 22 点导出已齐全 +# (=QMT 当日视图会滞后补全)——守恒缺口非空时持续重试即可当晚自愈 +EOD_RETRY_UNTIL = (23, 0) # broker_oid -> {"engine_oid", "is_buy", "amount", "watched_date"} # 一个进程一个实例(runner_live 单引擎),模块级即实例级 _PENDING: Dict[str, Dict[str, Any]] = {} _LAST_EOD_DATE: str = "" +# 缺口/废单告警签名:重试轮里内容不变降级 INFO,防 60s×8h 刷屏 +_LAST_GAP_LOG_SIG: str = "" +_LAST_REJ_LOG_SIG: str = "" def _status_str(status: Any) -> str: @@ -299,33 +306,89 @@ def eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int, "order_volume": ov, "ledger_volume": lv, "gap": ov - lv, }) + # 当日废单告警(filled=0 的 rejected/failed):08-28 600818 实锤——盘中 + # 废单只有 ERROR 日志躺在文件里,复盘才发现当日目标缺仓 ~8 万;EOD 汇总 + # 让它当天可见。部分成交残量不进此清单(守恒/补插路径已覆盖)。 + rejected_orders = [] + for o in own_by_broker.values(): + if _status_str(getattr(o, "status", None)) not in ("rejected", "failed", "error"): + continue + o_dt = getattr(o, "datetime", None) + if o_dt is not None and not str(o_dt).startswith(today): + continue + if int(getattr(o, "filled", 0) or 0) > 0: + continue + rejected_orders.append({ + "symbol": str(getattr(o, "security", "") or "")[:6], + "side": "buy" if bool(getattr(o, "is_buy", True)) else "sell", + "amount": int(getattr(o, "amount", 0) or 0)}) + summary = {"qmt_trades": len(trades_all), "ours": ours, "backfilled": backfilled, "foreign": len(trades_all) - ours, - "conservation_gaps": conservation_gaps} + "conservation_gaps": conservation_gaps, + "rejected_orders": rejected_orders} + global _LAST_GAP_LOG_SIG, _LAST_REJ_LOG_SIG if backfilled or conservation_gaps: - logger.warning( + # 取证:缺口标的的 QMT 原始行直接打进告警(08-28 教训——15:05 快照 + # 内容事后不可回溯,当时没 dump 就只能靠猜哪条通道吞了行) + detail = "" + if conservation_gaps: + gap_syms = {str(g["symbol"]) for g in conservation_gaps} + rows_txt = [] + for r in trades_all: + if str(r.get("security") or "")[:6] in gap_syms: + rows_txt.append("%s x%s@%s#%s" % ( + _row_time(r).strftime("%H:%M:%S"), + r.get("amount"), r.get("price"), + str(r.get("trade_id") or "")[-6:])) + if rows_txt: + detail = " QMT行[%s]" % ";".join(rows_txt[:12]) + sig = "%s|%d|%s|%d" % (today, backfilled, conservation_gaps, len(trades_all)) + log_fn = logger.warning if sig != _LAST_GAP_LOG_SIG else logger.info + log_fn( "[live-reconcile] EOD对账补插 %d 笔 (account=%s QMT全量%d 本实例%d " - "别家%d) 守恒缺口=%s——存在引擎未报形态,查当日名单/日志", + "别家%d) 守恒缺口=%s%s——存在引擎未报形态,查当日名单/日志", backfilled, account_id, summary["qmt_trades"], ours, - summary["foreign"], conservation_gaps) + summary["foreign"], conservation_gaps, detail) + _LAST_GAP_LOG_SIG = sig else: logger.info("[live-reconcile] EOD对账干净 (account=%s QMT全量%d 本实例%d)", account_id, summary["qmt_trades"], ours) + if rejected_orders: + rsig = "%s|%s" % (today, rejected_orders) + if rsig != _LAST_REJ_LOG_SIG: + logger.warning( + "[live-reconcile] 当日废单 %d 笔 (account=%s): %s——当日目标缺仓," + "查柜台废单原因(600818 形态)", + len(rejected_orders), account_id, rejected_orders) + _LAST_REJ_LOG_SIG = rsig return summary def maybe_eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int, strategy_name: str, now: Optional[datetime] = None) -> Optional[Dict[str, int]]: - """归因轮询每轮调用:15:05 后当日首跑一次;失败不记日下轮重试。""" + """归因轮询每轮调用:15:05 后当日首跑;**守恒缺口非空不记日**,下轮重试。 + + 重试依据(08-28 003011 实锤):15:05 快照缺第 10 笔成交 → 自动补插 + 没生效,但用户 22 点导出已齐全 = QMT「当日成交」视图会滞后补全 + (零点才翻篇)——缺口敞开时每轮重查,期间补全即补插自愈,当晚闭环。 + 23:00(EOD_RETRY_UNTIL)后仍不平 → 记日放弃,缺口留给恒等式/人工。 + 查询抛错同样不记日(原有语义),下轮再试。 + """ global _LAST_EOD_DATE now = now or datetime.now() today = now.strftime("%Y-%m-%d") if _LAST_EOD_DATE == today: return None - if (now.hour, now.minute) < EOD_AFTER: + hm = (now.hour, now.minute) + if hm < EOD_AFTER: + return None + if hm >= EOD_RETRY_UNTIL: + _LAST_EOD_DATE = today return None summary = eod_reconcile(engine, ledger, db, account_id, strategy_name) - _LAST_EOD_DATE = today + if not summary.get("conservation_gaps"): + _LAST_EOD_DATE = today return summary diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 13a8e0f..060578d 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -692,4 +692,56 @@ def _shift_date(date_str: str, days: int) -> str: return (dt + datetime.timedelta(days=days)).strftime("%Y-%m-%d") +# ------------------ day_count 跨重启持久化(2026-08-28) ------------------ +# 08-24~08-28 实锤:small_cap 的 day_count 只在内存,引擎每晚部署/修账重启即 +# 归零 → 连续 5 个交易日 day=0 全量调仓,tc=5 从未生效(每天 ~1k 换仓摩擦)。 +# 状态文件按实例 env 隔离(live_20 与 shadow_61 互不串);回测/单测无实例 +# env → 不落盘不恢复,保持原内存语义。恢复口径:上次运行日≠今天 → +1(该 +# 交易日 handle_data 未跑),同日多次重启不重复加;停摆跨多交易日只 +1 +# (计数偏低=调仓延后,自洽不放大)。 +def _live_instance_key() -> str: + """live/影子实例标识(持久化按实例隔离);回测/单测无实例 env → 空。""" + import os + return (os.environ.get("SANGUO_SHADOW_ACCOUNT_ID") + or os.environ.get("SANGUO_LIVE_ACCOUNT_ID") or "") + + +def _day_state_path(strategy_name: str) -> Optional["pathlib.Path"]: + import pathlib + key = _live_instance_key() + if not key: + return None + return pathlib.Path("runtime") / f"state_{key}" / f"{strategy_name}_day.json" + + +def _load_day_state(strategy_name: str) -> tuple[int, str]: + """读持久化 (day_count, date);缺文件/损坏 → (0, "")=旧内存语义。""" + import json + p = _day_state_path(strategy_name) + if p is None or not p.exists(): + return 0, "" + try: + d = json.loads(p.read_text(encoding="utf-8")) + return int(d.get("day_count", 0)), str(d.get("date", "")) + except Exception as e: # noqa: BLE001 - 状态损坏不阻断交易,按无状态起 + logger.warning("[%s] day_count 状态读取失败(从 0 起): %s", strategy_name, e) + return 0, "" + + +def _save_day_state(strategy_name: str, day_count: int) -> None: + """handle_data 尾部落盘;写失败只告警(持久化失败不影响交易)。""" + import json + p = _day_state_path(strategy_name) + if p is None: + return + try: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps({ + "day_count": day_count, + "date": datetime.datetime.now().strftime("%Y-%m-%d"), + }), encoding="utf-8") + except Exception as e: # noqa: BLE001 + logger.warning("[%s] day_count 状态写入失败: %s", strategy_name, e) + + __all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"] diff --git a/sanguo_portfolio/strategies/small_cap.py b/sanguo_portfolio/strategies/small_cap.py index 830edec..cdfaa57 100644 --- a/sanguo_portfolio/strategies/small_cap.py +++ b/sanguo_portfolio/strategies/small_cap.py @@ -44,6 +44,8 @@ from .all_weather import ( _current_dt, _dedup, _get_positions, + _load_day_state, + _save_day_state, _total_value, _previous_date_str, ) @@ -112,6 +114,17 @@ class SmallCapStrategy: self.day_count: int = 0 # g.t:运行天数 self.in_position_stocks: List[str] = [] # g.in_position_stocks:当前持仓名单 + # day_count 跨重启持久化恢复(2026-08-28:08-24~08-28 连续 5 交易日 + # day=0 全量调仓根治——重启不再归零,tc=5 周期跨重启保持)。回测无 + # 实例 env → _load_day_state 返回 (0,"") 等价旧内存语义。 + saved_count, saved_date = _load_day_state("small_cap") + if saved_date: + today = datetime.datetime.now().strftime("%Y-%m-%d") + self.day_count = saved_count + (0 if saved_date == today else 1) + logger.info( + "[day-restore] day_count=%d (saved=%d @%s)", + self.day_count, saved_count, saved_date) + # =================== initialize =================== def initialize(self, context: Any) -> None: """聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。""" @@ -176,8 +189,9 @@ class SmallCapStrategy: # 3) 调仓(仅股票部分,去掉对冲) self._rebalance(context) - # 4) 天数加一(对齐原策略 g.t += 1) + # 4) 天数加一(对齐原策略 g.t += 1)+ 落盘(live/影子跨重启保持周期) self.day_count += 1 + _save_day_state("small_cap", self.day_count) # =================== pick_stocks (选股) =================== def _pick_stocks(self, context: Any) -> Optional[List[str]]: diff --git a/sanguo_portfolio/strategies/small_cap_ex.py b/sanguo_portfolio/strategies/small_cap_ex.py index 982c1d2..7695df0 100644 --- a/sanguo_portfolio/strategies/small_cap_ex.py +++ b/sanguo_portfolio/strategies/small_cap_ex.py @@ -46,7 +46,9 @@ from .all_weather import ( _current_dt, _dedup, _get_positions, + _load_day_state, _previous_date_str, + _save_day_state, _total_value, ) @@ -114,6 +116,17 @@ class SmallCapExStrategy: self.day_count: int = 0 # g.t:运行天数 self.in_position_stocks: List[str] = [] # g.in_position_stocks:当前持仓名单 + # day_count 跨重启持久化恢复(2026-08-28:08-24~08-28 连续 5 交易日 + # day=0 全量调仓根治——重启不再归零,tc=5 周期跨重启保持)。回测无 + # 实例 env → _load_day_state 返回 (0,"") 等价旧内存语义。 + saved_count, saved_date = _load_day_state("small_cap") + if saved_date: + today = datetime.datetime.now().strftime("%Y-%m-%d") + self.day_count = saved_count + (0 if saved_date == today else 1) + logger.info( + "[day-restore] day_count=%d (saved=%d @%s)", + self.day_count, saved_count, saved_date) + # =================== initialize =================== def initialize(self, context: Any) -> None: """聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。""" @@ -178,8 +191,9 @@ class SmallCapExStrategy: # 3) 调仓(仅股票部分,去掉对冲) self._rebalance(context) - # 4) 天数加一(对齐原策略 g.t += 1) + # 4) 天数加一(对齐原策略 g.t += 1)+ 落盘(live/影子跨重启保持周期) self.day_count += 1 + _save_day_state("small_cap", self.day_count) # =================== pick_stocks (选股) =================== def _pick_stocks(self, context: Any) -> Optional[List[str]]: diff --git a/tests/portfolio/test_live_reconcile.py b/tests/portfolio/test_live_reconcile.py index 2d052c5..5c97acd 100644 --- a/tests/portfolio/test_live_reconcile.py +++ b/tests/portfolio/test_live_reconcile.py @@ -26,10 +26,11 @@ from sanguo_portfolio.live_reconcile import ( # ------------------ 公共替身 ------------------ def _order(oid="o1", broker_oid="1001", security="000049.XSHE", is_buy=True, - amount=500, filled=0, status="open"): + amount=500, filled=0, status="open", datetime_=None): return SimpleNamespace( order_id=oid, _broker_order_id=broker_oid, security=security, is_buy=is_buy, amount=amount, filled=filled, status=status, + datetime=datetime_, ) @@ -61,9 +62,13 @@ def _engine(orders, broker_trades): def _clean_pending(): live_reconcile._PENDING.clear() live_reconcile._LAST_EOD_DATE = "" + live_reconcile._LAST_GAP_LOG_SIG = "" + live_reconcile._LAST_REJ_LOG_SIG = "" yield live_reconcile._PENDING.clear() live_reconcile._LAST_EOD_DATE = "" + live_reconcile._LAST_GAP_LOG_SIG = "" + live_reconcile._LAST_REJ_LOG_SIG = "" @pytest.fixture @@ -313,6 +318,82 @@ class TestEodReconcile: assert summary["backfilled"] == 1 # 先回填 assert summary["conservation_gaps"] == [] # 回填后守恒平,零缺口 + def test_twin_10_fills_vs_9_ledger_rows_backfill(self, db): + """08-28 003011 原样复现:订单 1200 全成,事件路径只落 9 笔(哈希 id, + 同分钟 120@20.45),QMT 快照 10 笔齐全 → 第 10 笔必须补插、守恒平。 + (该测试通过=匹配逻辑对孪生正确;当日生产「补插 0」只能出自 15:05 + 快照内容缺行——由缺口重试制兜底,见 TestMaybeEodReconcile。)""" + from sanguo_live.persistence import save_trade + led = LiveInstanceLedger(initial_cash=1_000_000) + for i in range(9): + led.apply_trade(True, "003011.XSHE", 20.45, 120, f"hash_{i}", _TODAY) + save_trade(db, 20, { + "strategy_name": "s", "symbol": "003011.XSHE", + "direction": "buy", "offset": "open", "price": 20.45, + "volume": 120, "traded_at": f"{_TODAY} 09:32:38", + "vt_tradeid": f"hash_{i}"}) + qmt = [_qmt_trade(order_id="326", security="003011.XSHE", amount=120, + price=20.45, trade_id=f"10100000325577{41 + i}", + time=f"{_TODAY} 09:32:{29 + i}") for i in range(10)] + eng = _engine( + [_order(status="filled", amount=1200, filled=1200, is_buy=True, + security="003011.XSHE", broker_oid="326")], qmt) + summary = eod_reconcile(eng, led, db, 20, "small_cap_ex") + assert summary["backfilled"] == 1 + assert summary["conservation_gaps"] == [] + assert led.positions["003011.XSHE"]["volume"] == 1200 + + def test_rejected_order_alarm(self, db, caplog): + """08-28 600818 形态:当日买入废单(filled=0)→ EOD 汇总告警,当天可见。""" + led = LiveInstanceLedger() + eng = _engine( + [_order(status="rejected", filled=0, amount=10900, is_buy=True, + security="600818.XSHG", broker_oid="661")], []) + with caplog.at_level("WARNING"): + summary = eod_reconcile(eng, led, db, 19, "momentum") + assert summary["rejected_orders"] == [ + {"symbol": "600818", "side": "buy", "amount": 10900}] + assert any("废单" in r.message for r in caplog.records) + + def test_rejected_partial_or_crossday_not_in_alarm(self, db, caplog): + """部分成交残量的废单(守恒/补插已覆盖)与昨日废单都不进清单。""" + led = LiveInstanceLedger() + eng = _engine( + [_order(status="rejected", filled=300, amount=500, broker_oid="a"), + _order(status="rejected", filled=0, amount=200, broker_oid="b", + datetime_="2020-01-01 10:00:00")], + []) + with caplog.at_level("WARNING"): + summary = eod_reconcile(eng, led, db, 19, "s") + assert summary["rejected_orders"] == [] + assert not any("废单" in r.message for r in caplog.records) + + def test_gap_warning_dedup_on_identical_retry(self, db, caplog): + """缺口重试轮:内容不变只首轮回 WARNING,重复轮降级 INFO 防刷屏。""" + from sanguo_live.persistence import save_trade + led = LiveInstanceLedger(initial_cash=1_000_000) + led.apply_trade(False, "001234.XSHE", 22.35, 1000, "h1", _TODAY) + save_trade(db, 20, { + "strategy_name": "s", "symbol": "001234.XSHE", + "direction": "sell", "offset": "close", "price": 22.35, + "volume": 1000, "traded_at": f"{_TODAY} 09:31:57", + "vt_tradeid": "h1"}) + eng = _engine( + [_order(status="filled", amount=1100, filled=1100, is_buy=False, + security="001234.XSHE", broker_oid="235")], + [_qmt_trade(order_id="235", security="001234.XSHE", amount=1000, + price=22.35, trade_id="known1", + time=f"{_TODAY} 09:31:57")]) + with caplog.at_level("INFO"): + eod_reconcile(eng, led, db, 20, "s") + eod_reconcile(eng, led, db, 20, "s") + warns = [r for r in caplog.records + if "守恒缺口" in r.message and r.levelname == "WARNING"] + infos = [r for r in caplog.records + if "守恒缺口" in r.message and r.levelname == "INFO"] + assert len(warns) == 1 + assert len(infos) == 1 + class TestMaybeEodReconcile: def test_before_window_is_noop(self): @@ -347,7 +428,54 @@ class TestMaybeEodReconcile: assert maybe_eod_reconcile( ok, led, db, 20, "s", now=datetime(2026, 8, 25, 15, 8)) == { "qmt_trades": 0, "ours": 0, "backfilled": 0, "foreign": 0, - "conservation_gaps": []} + "conservation_gaps": [], "rejected_orders": []} + + def test_gap_keeps_retrying_until_closed(self, db): + """守恒缺口敞开 → 不记日持续重查;视图补全后补插+守恒平+记日 + (08-28 003011:15:05 快照缺第 10 笔,晚间补全即当晚自愈)。""" + from sanguo_live.persistence import save_trade + led = LiveInstanceLedger(initial_cash=1_000_000) + led.apply_trade(False, "001234.XSHE", 22.35, 1000, "h1", _TODAY) + save_trade(db, 20, { + "strategy_name": "s", "symbol": "001234.XSHE", + "direction": "sell", "offset": "close", "price": 22.35, + "volume": 1000, "traded_at": f"{_TODAY} 09:31:57", + "vt_tradeid": "h1"}) + order = _order(status="filled", amount=1100, filled=1100, is_buy=False, + security="001234.XSHE", broker_oid="235") + # 15:06 快照:QMT 只有 1000(缺孪生 100)→ 有缺口,不记日 + eng_missing = _engine([order], [ + _qmt_trade(order_id="235", security="001234.XSHE", amount=1000, + price=22.35, trade_id="known1", + time=f"{_TODAY} 09:31:57")]) + s1 = maybe_eod_reconcile(eng_missing, led, db, 20, "s", + now=datetime(2026, 8, 25, 15, 6)) + assert s1 is not None and s1["conservation_gaps"] + assert live_reconcile._LAST_EOD_DATE == "" + # 22:30 快照:视图补全(孪生 100 出现)→ 补插+守恒平+记日 + eng_full = _engine([order], [ + _qmt_trade(order_id="235", security="001234.XSHE", amount=1000, + price=22.35, trade_id="known1", + time=f"{_TODAY} 09:31:57"), + _qmt_trade(order_id="235", security="001234.XSHE", amount=100, + price=22.35, trade_id="twin2", + time=f"{_TODAY} 09:31:57")]) + s2 = maybe_eod_reconcile(eng_full, led, db, 20, "s", + now=datetime(2026, 8, 25, 22, 30)) + assert s2["backfilled"] == 1 + assert s2["conservation_gaps"] == [] + assert live_reconcile._LAST_EOD_DATE != "" + # 记日后当日不再跑 + assert maybe_eod_reconcile(eng_full, led, db, 20, "s", + now=datetime(2026, 8, 25, 22, 40)) is None + + def test_after_retry_window_marks_done(self, db): + """23:00 后仍不平 → 记日放弃(当日 API 零点翻篇,再查无意义)。""" + led = LiveInstanceLedger() + eng = _engine([], []) + assert maybe_eod_reconcile(eng, led, db, 20, "s", + now=datetime(2026, 8, 25, 23, 1)) is None + assert live_reconcile._LAST_EOD_DATE == "2026-08-25" # ------------------ 事故重放 + seen_trade_ids ------------------ diff --git a/tests/portfolio/test_small_cap.py b/tests/portfolio/test_small_cap.py index 5eb7c1e..95c027e 100644 --- a/tests/portfolio/test_small_cap.py +++ b/tests/portfolio/test_small_cap.py @@ -464,6 +464,68 @@ class TestHandleDataPeriod: assert rebalance_days == [0, 5, 10] +# =================== day_count 跨重启持久化(2026-08-28) =================== +class TestDayCountPersistence: + """08-24~08-28 连续 5 交易日 day=0 全量调仓根治:重启不再归零。 + + 持久化按实例 env 隔离(SANGUO_LIVE_ACCOUNT_ID / SANGUO_SHADOW_ACCOUNT_ID, + live_20 与 shadow_61 互不串);回测/单测无 env → 不落盘不恢复(等价旧内存语义)。 + """ + + def _state_file(self, tmp_path, key="20"): + d = tmp_path / "runtime" / f"state_{key}" + d.mkdir(parents=True, exist_ok=True) + return d / "small_cap_day.json" + + def test_overnight_restore_increments(self, tmp_path, monkeypatch): + """隔日重启:恢复值 +1(该交易日 handle_data 尚未跑)——重启日不再是 day=0。""" + import json + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SANGUO_LIVE_ACCOUNT_ID", "20") + self._state_file(tmp_path).write_text( + json.dumps({"day_count": 4, "date": "2020-01-01"}), encoding="utf-8") + s = make_strategy(config=SmallCapConfig(tc=5)) + assert s.day_count == 5 + + def test_same_day_restart_no_double_count(self, tmp_path, monkeypatch): + """同日多次重启:恢复值不加(handle_data 当日已跑过/不再跑)。""" + import json + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SANGUO_SHADOW_ACCOUNT_ID", "61") + today = datetime.now().strftime("%Y-%m-%d") + self._state_file(tmp_path, "61").write_text( + json.dumps({"day_count": 3, "date": today}), encoding="utf-8") + s = make_strategy(config=SmallCapConfig(tc=5)) + assert s.day_count == 3 + + def test_backtest_mode_no_persistence(self, tmp_path, monkeypatch): + """回测/单测无实例 env:不恢复,handle_data 后也不落盘。""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SANGUO_LIVE_ACCOUNT_ID", raising=False) + monkeypatch.delenv("SANGUO_SHADOW_ACCOUNT_ID", raising=False) + s = make_strategy(config=SmallCapConfig(tc=5)) + assert s.day_count == 0 + s.day_count = 1 # 非调仓日路径,免选股数据 + ctx = FakeContext(current_dt=datetime(2024, 10, 9, 9, 30), cash=1_000_000) + s.handle_data(ctx) + assert not (tmp_path / "runtime").exists() + + def test_handle_data_persists_count(self, tmp_path, monkeypatch): + """live 实例 handle_data 尾部落盘,下个进程(重启)构造即恢复。""" + import json + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SANGUO_LIVE_ACCOUNT_ID", "20") + s = make_strategy(config=SmallCapConfig(tc=5)) + s.day_count = 1 # 非调仓日,免 mock 选股数据 + ctx = FakeContext(current_dt=datetime(2024, 10, 9, 9, 30), cash=1_000_000) + s.handle_data(ctx) + saved = json.loads(self._state_file(tmp_path).read_text(encoding="utf-8")) + assert saved["day_count"] == 2 + assert saved["date"] == datetime.now().strftime("%Y-%m-%d") + s2 = make_strategy(config=SmallCapConfig(tc=5)) # 模拟重启后的新进程 + assert s2.day_count == 2 + + # =================== handle_data (调仓行为) =================== class TestHandleDataRebalance: def test_sells_positions_not_in_target(self): diff --git a/tests/test_live_api.py b/tests/test_live_api.py index 2e69dc2..849ff2e 100644 --- a/tests/test_live_api.py +++ b/tests/test_live_api.py @@ -168,6 +168,12 @@ def _client(tmp_path): db = os.path.join(str(tmp_path), "live_api.db") app = create_app(db_path=db) set_db_path(db) + # B3(f9b6d77) 起 live/create 需新鲜账户快照(fail-closed),测试播种一份 + # ——同款修法见 test_portfolio_live/test_instance_binding,本文件当时漏补 + from sanguo_live import persistence as _lp + for _acc in ("12345678", "123", ""): + _lp.upsert_account_snapshot(db, _acc, cash=1e9, market_value=0, + total=1e9, positions=[]) return TestClient(app), create_token("admin") @@ -232,14 +238,17 @@ def test_start_and_stop(tmp_path): def test_start_empty_account_rejected(tmp_path): - """account 字段空 → start 返回 400。""" + """account 字段空 → 400(意图不变:空账号永不产生可运行实例)。 + + B3(f9b6d77)起空账号在 create 即被预算闸门 fail-closed 拒 + (空账号无快照可言),原为 create 通过、start 时拒。 + """ c, token = _client(tmp_path) - aid = c.post( + r = c.post( "/api/v1/live/create", json={"account": "", "strategy_name": "dm1"}, headers=_auth(token), - ).json()["account_id"] - r = c.post(f"/api/v1/live/{aid}/start", headers=_auth(token)) + ) assert r.status_code == 400