fix(live): 08-28复盘根治三连(003011守恒120股+600818废单+day_count5连日day=0)——①EOD补插失效修复:守恒缺口非空不再一天一跑,15:05-23:00每轮重查(QMT当日视图晚间才补全:003011实锤15:05快照缺第10笔/用户22点导出已齐全=当晚自愈窗口,23:00后记日放弃防无效轮询)+缺口标的QMT原始行直接打进告警取证(当时没dump事后只能猜)+同签名重试轮降INFO防60s刷屏;②当日废单告警入EOD汇总(600818形态:filled=0废单盘中只有ERROR日志躺文件里,复盘才发现当日缺仓~8万);③small_cap day_count持久化(runtime/state_{实例env}/small_cap_day.json,08-24~28连续5交易日重启归零day=0全量调仓根治,tc=5周期跨重启保持;隔日重启+1/同日不加/停摆跨多日只+1自洽;回测无实例env不落盘=旧内存语义;原版+_ex同接all_weather共享helper,TET副本diff契约不破);+测试:003011十笔孪生vs九行台账复现补插/缺口重试到视图补全即闭环/废单告警+部分成交与跨日不进清单/持久化4态;顺手修tests/test_live_api预存红9个(B3 f9b6d77只给两文件播种快照漏了本文件,照抄同款修法;空账号断言改create即400新契约);全量1333绿 [vps]
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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 ------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
+13
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user