Files
sanguo_vnpy_v2/tests/portfolio/test_live_instance_ledger.py
T
claude_dev dae56e29aa
CI/CD / test (push) Successful in 11s
CI/CD / nas-deploy (push) Successful in 34s
CI/CD / nas-verify (push) Successful in 11s
fix(live): instance虚拟账本+成交归因+实盘日志黑洞——共享QMT账户三害根治第一步 [vps]
2026-08-19盘后QMT实锤:①互卖当日真实发生(518880两个实例各卖183700、
600036两实例各卖42900)②买入sizing被全账户污染(channel_test实盘买
183700股@9.12≈168万=全账户995万/6,影子才16.8万=100万/6)③QMT委托带
remark=bt:live_strateg:<hash>实例指纹(归因可交叉验证)。

①live_instance_ledger(新模块):每live实例一份虚拟子账本(现金=初始−Σ买
−Σ费+Σ卖,持仓+移动加权成本+T+1当日买入锁定,线程安全锁),由**本实例
真实成交**驱动——engine.get_trades()按order_id∈engine.get_orders()归因
(引擎_broker_order_index已映射回本实例id空间),别家实例/手动单不进账;
卖超账面/无账面卖出如实留痕不崩;restore_from_trades重启恢复。
②runner_live:_sync_trades旧轮询(写不进live_trades的坏件)替换为归因
落库(方向取自订单is_buy);快照落库从context.portfolio全账户改**实例视图**
(positions=账本持仓T+1冻结;balance=虚拟现金+市值,现价取全账户快照/退
加权成本)——治8实例同写一份全账户持仓+收益率=全账户/初始资金无意义;
balance节流:有成交立即写否则≥5分钟(治1440行/天/实例量偏大遗留)。
③通道注入:BrokerFacade.get_instance_positions字段+live_strategy._setup
读get_active()注入positions_view(策略session消费,getattr兜底回退
context.portfolio;回测/单测无账本=保持None)。
④实盘引擎日志黑洞根治:supervisor子进程stdout/stderr落logs/live_{aid}.log
(>5MB截断+spawn标记,对齐影子#88同款修法;8-17起实盘委托/成交零留存)。
+15测试(算术/归因过滤/幂等/快照实例视图/通道注入/日志重定向);
portfolio+live+shadow 400绿。
2026-08-19 18:39:13 +08:00

237 lines
10 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
# ------------------ 账本算术 ------------------
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_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)
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-19")
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)