feat(trader): B5对账恒等式优先——15:10日终报表加『全账户=Σ实例账本+未归因』层(spec§B5)——①build_identity_report:每QMT账号一行,快照市值vs Σ实例账本市值(live_balance最新,dae56e2起=实例视图),未归因MV+占比,容差0.5%(价格时点差);逐票未归因=快照持仓−Σ实例持仓(6位码对齐)单列(重建后应≈0,大数=遗留/手动仓)②状态四态:pass/no_instances(重建期无实例=恒等式成立)/snapshot_missing(如实FAIL)/unattributed_over_tol(旧全账户行叠加期呈现大额负未归因)③save/load落库identity_reconcile(account+date主键)④15:10调度(_maybe_daily_reconcile)与CLI恒等式先行,再逐对live↔shadow(配对v2不变);+7测试(容差内过/未归因票单列/负未归因/无快照/无实例/多实例Σ不串账号/落库回读);trader 248绿 [vps]
CI/CD / test (push) Successful in 18s
CI/CD / nas-deploy (push) Successful in 58s
CI/CD / nas-verify (push) Successful in 11s

This commit is contained in:
2026-08-19 22:32:00 +08:00
parent 537fd2d571
commit ed15c24db8
3 changed files with 281 additions and 1 deletions
@@ -300,3 +300,131 @@ class TestFindPairsByInstance:
pairs = find_dual_track_pairs(db)
assert pairs == [{"live_account_id": 21, "shadow_account_id": 60,
"strategy": "all_weather"}]
# ===== B5 恒等式对账(spec §multi-strategy-instance-budget §B5) =====
from sanguo_trader.shadow.reconcile_report import ( # noqa: E402
IDENTITY_TOL_PCT, build_identity_report, load_identity_report,
save_identity_report,
)
def _seed_snapshot(db, account="66639661", cash=1e6, mv=1e6, positions=None):
from sanguo_live.persistence import upsert_account_snapshot
upsert_account_snapshot(db, account, cash=cash, market_value=mv,
total=cash + mv, positions=positions or [])
def _seed_inst_mv(db, aid, mv, positions=None):
"""实例账本:live_balance 最新市值 + live_positions 视图。"""
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO live_balance (account_id,date,cash,market_value,total) "
"VALUES (?,?,?,?,?)", (aid, "2026-08-19 15:00:00", 0, mv, mv))
for sym, vol in (positions or {}).items():
conn.execute(
"INSERT INTO live_positions (account_id,symbol,volume,frozen,"
"avg_price,updated_at) VALUES (?,?,?,?,?,?)",
(aid, sym, vol, 0, 10, "x"))
def test_identity_pass_within_tolerance(db):
"""Σ实例市值≈快照市值(容差内)→ pass;逐票对账无未归因。"""
_add_live_account(db, aid=5)
_seed_snapshot(db, mv=1_000_000, positions=[
{"symbol": "600036.SH", "volume": 1000, "can_use": 1000,
"avg_price": 38, "mv": 38000}])
_seed_inst_mv(db, 5, mv=997_000, positions={"600036.XSHG": 1000})
r = build_identity_report(db, "2026-08-19")
assert len(r["rows"]) == 1
row = r["rows"][0]
assert row["status"] == "pass" # 0.3% < 0.5% 容差
assert row["instance_mv_total"] == 997_000
assert row["unattributed_mv"] == 3_000
assert row["unattributed_positions"] == [] # 逐票对齐
assert r["identity_passed"] is True
def test_identity_unattributed_position_listed(db):
"""快照有实例没有的票(遗留/手动仓)→ 未归因票单列+超容差 FAIL。"""
_add_live_account(db, aid=5)
_seed_snapshot(db, mv=1_000_000, positions=[
{"symbol": "600036.SH", "volume": 1000, "can_use": 1000,
"avg_price": 38, "mv": 38000},
{"symbol": "518880.SH", "volume": 5000, "can_use": 5000,
"avg_price": 7, "mv": 35000}]) # 黄金ETF=手动仓
_seed_inst_mv(db, 5, mv=500_000, positions={"600036.XSHG": 1000})
r = build_identity_report(db)
row = r["rows"][0]
assert row["status"] == "unattributed_over_tol"
assert row["unattributed_mv"] == 500_000
unattr = row["unattributed_positions"]
assert [p["symbol"] for p in unattr] == ["518880"]
assert unattr[0]["diff"] == 5000
assert r["identity_passed"] is False
def test_identity_instance_over_snapshot_negative(db):
"""Σ实例>快照(旧全账户行叠加期)→ 未归因为负,如实呈现 FAIL。"""
_add_live_account(db, aid=5)
_seed_snapshot(db, mv=1_000_000)
_seed_inst_mv(db, 5, mv=8_000_000) # dae56e2 前的全账户行
r = build_identity_report(db)
assert r["rows"][0]["unattributed_mv"] == -7_000_000
assert r["rows"][0]["status"] == "unattributed_over_tol"
def test_identity_snapshot_missing_and_no_instances(db):
"""无快照→snapshot_missing;有快照无实例→no_instances(不算 FAIL)。"""
_add_live_account(db, aid=5) # 实例无快照
r = build_identity_report(db)
assert r["rows"][0]["status"] == "snapshot_missing"
assert r["identity_passed"] is False
# 反向:快照在、实例删光(重建期)
with sqlite3.connect(db) as conn:
conn.execute("DELETE FROM live_accounts")
_seed_snapshot(db, mv=1_000_000)
r2 = build_identity_report(db)
assert r2["rows"][0]["status"] == "no_instances"
assert r2["identity_passed"] is True # 无实例=无可归因,恒等式成立
def test_identity_multi_instance_sum(db):
"""多实例共享账户:Σ逐实例市值。"""
with sqlite3.connect(db) as conn:
conn.executemany(
"INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class,"
"strategy_name,status) VALUES (?,?,?,?,?,?,?)",
[(5, "a", "66639661", "x", "s", "s", "running"),
(6, "b", "66639661", "x", "s", "s", "running"),
(7, "c", "OTHER", "x", "s", "s", "running")])
_seed_snapshot(db, mv=1_000_000)
_seed_snapshot(db, account="OTHER", mv=500_000)
_seed_inst_mv(db, 5, 400_000)
_seed_inst_mv(db, 6, 595_000)
_seed_inst_mv(db, 7, 500_000)
r = build_identity_report(db)
by_acc = {row["account"]: row for row in r["rows"]}
assert by_acc["66639661"]["instance_mv_total"] == 995_000
assert by_acc["66639661"]["status"] == "pass"
assert by_acc["OTHER"]["status"] == "pass"
assert len(by_acc["66639661"]["instances"]) == 2 # 不串账号
def test_identity_save_load_roundtrip(db):
_add_live_account(db, aid=5)
_seed_snapshot(db, mv=1_000_000)
_seed_inst_mv(db, 5, 1_000_000)
r = build_identity_report(db, "2026-08-19")
save_identity_report(db, r)
loaded = load_identity_report(db, "2026-08-19")
assert len(loaded) == 1
assert loaded[0]["status"] == "pass"
assert loaded[0]["account"] == "66639661"
assert load_identity_report(db, "1999-01-01") == []
def test_identity_tolerance_constant():
"""容差 0.5%(spec §B5:价格时点差)。"""
assert IDENTITY_TOL_PCT == 0.5