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]
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user