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]
CI/CD / test (push) Successful in 9s
CI/CD / nas-deploy (push) Successful in 17s
CI/CD / nas-verify (push) Successful in 15s

This commit is contained in:
2026-08-29 08:22:14 +08:00
parent a62bdbabc1
commit 64cb460748
7 changed files with 357 additions and 15 deletions
+70 -7
View File
@@ -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"]
+15 -1
View File
@@ -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]]:
+15 -1
View File
@@ -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]]:
+130 -2
View File
@@ -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 ------------------
+62
View File
@@ -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
View File
@@ -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