From b436adef1b16bbc63d154c950fefd45bb735277b Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 20 Aug 2026 13:32:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(live):=20=E6=88=90=E4=BA=A4=E6=97=B6?= =?UTF-8?q?=E9=97=B41970=E5=AE=88=E5=8D=AB=E2=80=94=E2=80=9408-20=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E9=A6=96=E6=97=A5=E5=AE=9E=E9=94=A49=E7=AC=94traded?= =?UTF-8?q?=5Fat=3D1970-01-01=2000:00:01(QMT=E5=8E=9F=E5=A7=8B=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BB=8F=E5=BC=95=E6=93=8Epd.to=5Fdatetime=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=BD=A2=E6=80=81=E8=90=BDepoch),=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E3=80=8E=E4=BB=8A=E6=97=A5=E6=88=90=E4=BA=A4=E3=80=8F=E6=8C=89?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E8=BF=87=E6=BB=A4=E5=85=A8=E7=A9=BA+?= =?UTF-8?q?=E8=B4=A6=E6=9C=ACtrade=5Fdate=E5=A4=B1=E7=9C=9F(=E5=BD=93?= =?UTF-8?q?=E6=97=A5=E4=BB=93=E8=A2=AB=E5=BD=93=E5=8E=86=E5=8F=B2=E4=BB=93?= =?UTF-8?q?,T+1=E8=A7=86=E5=9B=BEfrozen=3D0);=E4=BF=AE=3D=5Feffective=5Ftr?= =?UTF-8?q?ade=5Ftime=E5=AE=88=E5=8D=AB:=E5=B9=B4=E4=BB=BD<2000=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E5=9B=9E=E9=80=80=E5=BD=93=E5=89=8D=E6=97=B6=E5=88=BB?= =?UTF-8?q?(=E5=BD=92=E5=9B=A0=E8=BD=AE=E8=AF=A2=E2=89=A460s,=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E8=AF=AF=E5=B7=AE=E4=BB=85=E8=B7=A8=E6=97=A560s?= =?UTF-8?q?=E7=AA=97=E5=8F=A3);+2=E6=B5=8B=E8=AF=95(1970=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=BD=93=E6=97=A5=E8=AE=B0=E8=B4=A6=E4=B8=94frozen=E7=94=9F?= =?UTF-8?q?=E6=95=88/=E6=9C=89=E6=95=88=E6=97=B6=E9=97=B4=E5=8E=9F?= =?UTF-8?q?=E6=A0=B7=E4=BF=9D=E7=95=99);=E9=99=84=E5=B8=A6=E5=B7=B2?= =?UTF-8?q?=E6=B2=BB=E7=96=97VPS=E5=AD=98=E9=87=8F9=E8=A1=8C(=E4=BB=8E?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E6=97=A5=E5=BF=97=E6=8C=89=E6=A0=87=E7=9A=84?= =?UTF-8?q?+=E6=95=B0=E9=87=8F=E5=9B=9E=E5=A1=AB13:03/13:05=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E5=88=BB,#18=E4=BB=8A=E6=97=A5=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E5=8D=B3=E6=97=B6=E5=8F=AF=E8=A7=81);portfolio=2029+3?= =?UTF-8?q?98=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/runner_live.py | 18 ++++++++-- tests/portfolio/test_live_instance_ledger.py | 38 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/sanguo_portfolio/runner_live.py b/sanguo_portfolio/runner_live.py index fda5711..07e499d 100644 --- a/sanguo_portfolio/runner_live.py +++ b/sanguo_portfolio/runner_live.py @@ -60,6 +60,20 @@ def live_env() -> Dict[str, str]: } +def _effective_trade_time(trade: Any) -> Any: + """成交时间守卫(2026-08-20 事故):QMT 原始成交时间经引擎 pd.to_datetime 的 + 失败形态会落成 1970-01-01 00:00:01 的 datetime——当日 9 笔 traded_at=1970 + 落库,前端"今日成交"按日期过滤全空 + 账本 trade_date 失真(T+1 视图错)。 + 年份<2000 一律视为无效,回退当前时刻:归因轮询间隔 ≤60s,日期误差只剩 + 跨日 60s 窗口,可忽略。""" + from datetime import datetime + + v = getattr(trade, "time", None) + if isinstance(v, datetime) and v.year >= 2000: + return v + return datetime.now() + + def _sync_instance_trades( engine: Any, ledger: Any, db: str, account_id: int, strategy_name: str, ) -> None: @@ -88,8 +102,8 @@ def _sync_instance_trades( if oid not in own_buy: continue # 别家实例/手动单,不归因给本实例 is_buy = own_buy[oid] - t_time = getattr(t, "time", None) - date_str = t_time.strftime("%Y-%m-%d") if hasattr(t_time, "strftime") else str(t_time or "") + t_time = _effective_trade_time(t) + date_str = t_time.strftime("%Y-%m-%d") applied = ledger.apply_trade( is_buy=is_buy, symbol=str(getattr(t, "security", "")), diff --git a/tests/portfolio/test_live_instance_ledger.py b/tests/portfolio/test_live_instance_ledger.py index 95a4e99..b2c13b9 100644 --- a/tests/portfolio/test_live_instance_ledger.py +++ b/tests/portfolio/test_live_instance_ledger.py @@ -129,6 +129,44 @@ def _fake_engine(): class TestAttributionAndSnapshot: + def test_sync_time_guard_rejects_epoch_1970(self, tmp_path): + """2026-08-20 事故回归:引擎成交 time 落 1970 → traded_at/账本 trade_date + 必须回退当前时刻(否则前端今日成交全空、T+1 视图把当日仓当历史仓)。""" + from datetime import date as _date + + from sanguo_live.persistence import init_db, list_trades + + db = str(tmp_path / "live.db") + init_db(db) + led = LiveInstanceLedger(initial_cash=1_000_000) + own = SimpleNamespace(order_id="o1", is_buy=True) + t_bad = SimpleNamespace( + order_id="o1", security="000001.XSHE", amount=100, price=10.0, + time=datetime(1970, 1, 1, 0, 0, 1), commission=0.0, tax=0.0) + engine = SimpleNamespace( + get_orders=lambda: {"o1": own}, + get_trades=lambda: {"t1": t_bad}, + context=SimpleNamespace(portfolio=SimpleNamespace(positions={})), + ) + _sync_instance_trades(engine, led, db, 44, "channel_test") + rows = list_trades(db, 44) + assert len(rows) == 1 + assert rows[0]["traded_at"].startswith(_date.today().isoformat()) + # 账本按今天记账 → 当日买入冻结(T+1),不再被当历史仓 + view = led.positions_view(_date.today().isoformat()) + assert view["000001.XSHE"]["closeable_amount"] == 0 + + def test_sync_time_guard_keeps_valid_time(self, tmp_path): + from sanguo_live.persistence import init_db, list_trades + + db = str(tmp_path / "live.db") + init_db(db) + led = LiveInstanceLedger(initial_cash=1_000_000) + engine = _fake_engine() + _sync_instance_trades(engine, led, db, 44, "channel_test") + rows = list_trades(db, 44) + assert rows[0]["traded_at"] == "2026-08-19 09:35:00" # 有效时间原样保留 + def test_sync_attributes_only_own_orders(self, tmp_path): from sanguo_live.persistence import init_db, list_trades