Files
sanguo_vnpy_v2/tests/portfolio/test_live_instance_ledger.py
T
claude_dev 191270c884
CI/CD / test (push) Failing after 6s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
fix(trader): 卖后买现金窗口B修法——下单返回即时归因入账,台账cash不再等60s轮询
2026-08-25 事故:small_cap同轮「全卖19只→马上全买20只」在两轮归因轮询间隙
读台账现金,卖出回款不可见→20笔买入全部目标0、全天空仓;momentum同型撞运
只入账首笔79k/6=13.2k缩水44%仓;value无卖后买序列满额(反证)。QMT无责
(0.5s filled/券商现金即时/下单线程同步见filled),gap=runner_live.py归因
poller 60s一轮才调ledger.apply_trade(唯一cash更新入口,DB落库时间戳恰差60s
铁证)。

修法(B,治本现金新鲜度):
- LiveInstanceLedger.on_order_done钩子+notify_order_done(未注入/抛错静默,
  绝不阻断下单;漏单由轮询兜底);apply_trade幂等判定整体移入锁内——钩子
  (策略线程)与轮询(poller线程)并发同步同一笔成交时恰好一笔入账,防双计
- live_strategy._instance_order_wrappers:所有真实委托(bt_order/透传)返回后
  _done()触发即时归因;决策层不下单的路径不触发
- runner_live:engine装配后注入on_order_done=_sync_instance_trades闭包;
  60s轮询保留兜底(部分成交后续/异步路径)

测试+8:钩子三态(nop/触发/吞异常)+8线程同trade_id并发恰入账一次(竞态回归)
+wrapper卖出/买入/透传触发+不下单不触发;portfolio 459绿+api 170绿

[vps]
2026-08-25 19:47:13 +08:00

328 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""实例虚拟账本(live_instance_ledger)+ runner_live 归因链路回归。
背景(2026-08-19 三日体检):8 路组合实盘共享一个 miniQMT 账号,
context.portfolio=全账户视图 → channel_test 轮换互卖别家持仓、对账 8 对
全 FAIL、收益率=全账户/初始资金无意义。修复=每实例一份由**自身真实成交**
驱动的虚拟子账本(engine.get_trades() 按 order_id ∈ engine.get_orders() 归因)。
"""
from __future__ import annotations
import sqlite3
from datetime import datetime
from types import SimpleNamespace
import pytest
from sanguo_portfolio.live_instance_ledger import (
LiveInstanceLedger, estimate_fee, set_active,
)
from sanguo_portfolio.runner_live import _snapshot_once, _sync_instance_trades
# ------------------ 即时归因钩子(B修法,2026-08-25 卖后买现金窗口) ------------------
class TestOrderDoneHook:
"""2026-08-25 事故:台账 cash 只被 60s 归因轮询更新,同轮「全卖→马上全买」
在轮询间隙读现金 → small_cap 20 笔买入全目标0 全天空仓、momentum 缩水44%仓。
B 修法 = 下单返回后立刻归因(on_order_done 钩子),轮询降级为兜底。"""
def test_notify_without_hook_is_noop(self):
led = LiveInstanceLedger()
led.notify_order_done() # 未注入钩子(回测/影子/单测)不抛不做事
def test_notify_invokes_registered_hook(self):
led = LiveInstanceLedger()
fired = []
led.on_order_done = lambda: fired.append(1)
led.notify_order_done()
assert fired == [1]
def test_notify_swallows_hook_exception(self):
"""钩子失败绝不阻断下单主流程(漏单由 60s 轮询兜底)。"""
led = LiveInstanceLedger()
def boom():
raise RuntimeError("sync 失败")
led.on_order_done = boom
led.notify_order_done() # 不抛
def test_concurrent_same_trade_id_credited_once(self):
"""双线程竞态回归:即时归因钩子(策略线程)与轮询(poller线程)并发
_sync 同一笔成交——幂等判定必须整体在锁内,否则现金双计。"""
import threading
led = LiveInstanceLedger(initial_cash=100_000)
workers = []
barrier = threading.Barrier(8)
def worker():
barrier.wait()
led.apply_trade(False, "000001.XSHE", 10.0, 1000, "same-tid",
"2026-08-25")
for _ in range(8):
t = threading.Thread(target=worker)
t.start()
workers.append(t)
for t in workers:
t.join()
assert led.cash == pytest.approx(
100_000 + 10_000 - estimate_fee(False, 10.0, 1000))
# ------------------ 账本算术 ------------------
class TestLedgerArithmetic:
def test_buy_sell_with_estimated_fees(self):
"""手算:100万 + 买100@10(费5) + 买200@13(费5) + 卖300@12(费5+税3.6)。"""
led = LiveInstanceLedger(initial_cash=1_000_000)
assert led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-19")
assert led.apply_trade(True, "000001.XSHE", 13.0, 200, "t2", "2026-08-19")
# 998,995 → 996,390;持仓 300 股,加权成本 (1000+2600)/300 = 12
assert led.cash == pytest.approx(996_390.0)
assert led.positions["000001.XSHE"]["volume"] == 300
assert led.positions["000001.XSHE"]["avg_cost"] == pytest.approx(12.0)
assert led.apply_trade(False, "000001.XSHE", 12.0, 300, "t3", "2026-08-19")
# 卖出费 = max(3600×0.0003,5)=5 + 印花税 3600×0.001=3.6
assert led.cash == pytest.approx(996_390.0 + 3600 - 8.6)
assert "000001.XSHE" not in led.positions
def test_actual_fee_overrides_estimate(self):
led = LiveInstanceLedger(initial_cash=100_000)
led.apply_trade(True, "600000.XSHG", 10.0, 100, "t1", "2026-08-19", fee=25.0)
assert led.cash == pytest.approx(100_000 - 1000 - 25)
def test_dup_trade_id_ignored(self):
led = LiveInstanceLedger()
assert led.apply_trade(True, "600000.XSHG", 10.0, 100, "t1", "2026-08-19")
assert not led.apply_trade(True, "600000.XSHG", 10.0, 100, "t1", "2026-08-19")
assert led.positions["600000.XSHG"]["volume"] == 100
def test_restore_from_trades_rebuilds(self):
"""重启恢复:DB 行重放出现金/持仓/幂等(与 live_trades 行格式一致)。"""
led = LiveInstanceLedger(initial_cash=1_000_000)
rows = [
{"direction": "buy", "symbol": "000001.XSHE", "price": 10.0,
"volume": 100, "traded_at": "2026-08-18 09:35:00", "vt_tradeid": "a1"},
{"direction": "buy", "symbol": "600000.XSHG", "price": 20.0,
"volume": 200, "traded_at": "2026-08-18 09:35:01", "vt_tradeid": "a2"},
{"direction": "sell", "symbol": "600000.XSHG", "price": 21.0,
"volume": 200, "traded_at": "2026-08-19 13:45:00", "vt_tradeid": "a3"},
]
assert led.restore_from_trades(rows) == 3
# 100万 (1000+5) (4000+5) +(4200max(1.26,5)=54200×0.001=4.2→9.2)
assert led.cash == pytest.approx(1_000_000 - 1005 - 4005 + 4190.8)
assert led.positions["000001.XSHE"]["volume"] == 100
assert "600000.XSHG" not in led.positions
# 重放幂等:同批行再来一遍零增量
assert led.restore_from_trades(rows) == 0
def test_t1_closeable_today_then_next_day(self):
led = LiveInstanceLedger()
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-19")
view_today = led.positions_view(now_date="2026-08-19")
assert view_today["000001.XSHE"]["closeable_amount"] == 0 # T+1 锁定
view_next = led.positions_view(now_date="2026-08-20")
assert view_next["000001.XSHE"]["closeable_amount"] == 100
def test_equity_price_fallback_to_avg_cost(self):
led = LiveInstanceLedger(initial_cash=100_000)
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-19")
cash, mv, total = led.equity({}) # 无现价 → 退加权成本 10
assert mv == pytest.approx(1000)
assert total == pytest.approx(cash + 1000)
_, mv2, _ = led.equity({"000001.XSHE": 12.5})
assert mv2 == pytest.approx(1250)
def test_estimate_fee_matches_order_cost(self):
assert estimate_fee(True, 10.0, 100) == pytest.approx(5.0) # 佣金触底
assert estimate_fee(False, 10.0, 100_000) == pytest.approx(
max(1_000_000 * 0.0003, 5) + 1_000_000 * 0.001)
def test_sell_without_book_position_keeps_cash_no_crash(self):
"""bootstrap 缺口前的旧仓卖出:账上无此标的——现金照收,持仓无账可扣不崩。"""
led = LiveInstanceLedger(initial_cash=100_000)
assert led.apply_trade(
False, "600519.XSHG", 1000.0, 100, "t1", "2026-08-19")
assert led.cash == pytest.approx(100_000 + 100_000 - 130.0) # 佣金30+税100
assert led.positions == {}
def test_dirty_flag_drives_snapshot_throttle(self):
"""新成交→dirty=True(下个快照周期必写);初始/重放后同样置位。"""
led = LiveInstanceLedger()
led.dirty = False
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-19")
assert led.dirty is True
led2 = LiveInstanceLedger()
led2.dirty = False
led2.restore_from_trades([{
"direction": "buy", "symbol": "000001.XSHE", "price": 10.0,
"volume": 100, "traded_at": "2026-08-19 09:35:00",
"vt_tradeid": "r1"}])
assert led2.dirty is True
# ------------------ 归因与落库链路 ------------------
def _fake_engine():
"""两个成交:o1(本实例买单)/ FOREIGN(别家实例单)。"""
own = SimpleNamespace(order_id="o1", is_buy=True)
t_own = SimpleNamespace(
order_id="o1", security="000001.XSHE", amount=100, price=10.0,
time=datetime(2026, 8, 19, 9, 35, 0), commission=0.0, tax=0.0)
t_foreign = SimpleNamespace(
order_id="8800099", security="600519.XSHG", amount=500, price=1500.0,
time=datetime(2026, 8, 19, 9, 36, 0), commission=0.0, tax=0.0)
return SimpleNamespace(
get_orders=lambda: {"o1": own},
get_trades=lambda: {"t1": t_own, "t99": t_foreign},
context=SimpleNamespace(portfolio=SimpleNamespace(
positions={"000001.XSHE": SimpleNamespace(price=11.0)})),
)
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
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")
# 账本只有本实例成交;别家 500 股×1500 不进账
assert led.positions["000001.XSHE"]["volume"] == 100
assert led.cash == pytest.approx(1_000_000 - 1005)
rows = list_trades(db, 44)
assert len(rows) == 1
assert rows[0]["vt_tradeid"] == "t1"
assert rows[0]["direction"] == "buy"
assert rows[0]["offset"] == "open"
assert rows[0]["strategy_name"] == "channel_test"
def test_sync_idempotent_no_duplicate_rows(self, tmp_path):
from sanguo_live.persistence import init_db, list_trades
db = str(tmp_path / "live.db")
init_db(db)
led = LiveInstanceLedger()
engine = _fake_engine()
_sync_instance_trades(engine, led, db, 44, "channel_test")
_sync_instance_trades(engine, led, db, 44, "channel_test")
assert len(list_trades(db, 44)) == 1
def test_snapshot_writes_instance_view_not_full_account(self, tmp_path):
"""快照落库=实例视图(旧版落全账户持仓是互卖/对账错的根源)。"""
from sanguo_live.persistence import init_db, list_balance, load_positions
db = str(tmp_path / "live.db")
init_db(db)
led = LiveInstanceLedger(initial_cash=1_000_000)
# trade_date=今天(动态):快照按 now 判 T+1,写死日期跨日必翻红(frozen 归 0)
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1",
datetime.now().strftime("%Y-%m-%d"))
engine = _fake_engine()
_snapshot_once(engine, db, 44, led)
pos = load_positions(db, 44)
assert len(pos) == 1
assert pos[0]["symbol"] == "000001.XSHE"
assert pos[0]["volume"] == 100
assert pos[0]["frozen"] == 100 # T+1 当日买入
assert pos[0]["avg_price"] == pytest.approx(10.0)
bal = list_balance(db, 44)[-1]
# 虚拟账本:cash=998,995;市值按现价 11 → 1100
assert bal["cash"] == pytest.approx(998_995.0)
assert bal["market_value"] == pytest.approx(1100.0)
assert bal["total"] == pytest.approx(1_000_095.0)
# ------------------ 适配层通道注入 ------------------
@pytest.fixture(autouse=True)
def _clear_active():
set_active(None)
yield
set_active(None)
def _patch_wiring(monkeypatch):
"""打桩 bullet_trade 装配依赖(对齐 tests/api/test_portfolio_live 模式)。"""
import bullet_trade.core as bt_core
import bullet_trade.data.api as bt_data_api
monkeypatch.setattr(bt_core, "run_daily", lambda f, t, **kw: None)
monkeypatch.setattr(bt_core, "run_monthly", lambda f, d, t, **kw: None)
monkeypatch.setattr(bt_data_api, "get_data_provider", lambda: SimpleNamespace())
class TestFacadeChannel:
def test_setup_injects_when_ledger_active(self, monkeypatch):
from sanguo_portfolio import live_strategy
_patch_wiring(monkeypatch)
live_strategy._STATE.update(strategy=None, wired=False)
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test")
led = LiveInstanceLedger(initial_cash=500_000)
set_active(led)
live_strategy._setup(SimpleNamespace())
strategy = live_strategy._STATE["strategy"]
assert callable(strategy.broker.get_instance_positions)
view = strategy.broker.get_instance_positions()
assert view == {}
live_strategy._STATE.update(strategy=None, wired=False)
def test_setup_leaves_none_without_ledger(self, monkeypatch):
"""回测/无账本:通道保持 None,策略侧回退 context.portfolio。"""
from sanguo_portfolio import live_strategy
_patch_wiring(monkeypatch)
live_strategy._STATE.update(strategy=None, wired=False)
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test")
live_strategy._setup(SimpleNamespace())
strategy = live_strategy._STATE["strategy"]
assert strategy.broker.get_instance_positions is None
live_strategy._STATE.update(strategy=None, wired=False)