"""双轨日终对账报表(影子柜台 vs 实盘模拟,设计 §8.2,影子 P3 前半)。 同一 db 文件(VPS ``backtest_results.db``)里 live_* 实盘侧与 paper_* 影子侧 同库共存,本模块按日对账四项指标: | 对比项 | 一致标准 | |--------|---------| | 成交笔数 | 完全相同(rejected 影子单不计) | | 每笔成交价差 | 平均 <10bps(按 symbol+side 聚合 vwap 对比) | | 收盘持仓 | 逐只股票+数量相同 | | 净值偏差 | 月累计 <0.5%(月内首基线 → 当日,双侧收益率差) | 差异即策略从纸面到真实的真实滑点成本——影子柜台的核心产出之一。 用法: python -m sanguo_trader.shadow.reconcile_report --db [--date YYYY-MM-DD] API: GET /paper/reconcile(见 sanguo_api/routes_paper.py) """ from __future__ import annotations import json import logging import sqlite3 from datetime import datetime from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # §8.2 一致标准 PRICE_DIFF_BPS_MAX = 10.0 # 每笔成交价差平均上限(bps) NAV_MTD_PCT_MAX = 0.5 # 净值月累计偏差上限(%) def _norm_symbol(symbol: str) -> str: """两侧符号口径不同(live=QMT '510300.SH',shadow=jq '510300.XSHG')→ 6 位码。""" return str(symbol or "").split(".", 1)[0].strip() def _norm_side(direction: str) -> str: """live 'buy'/'sell' vs shadow 'long'/'short' → B/S。""" d = str(direction or "").lower() if d in ("buy", "long", "多", "b"): return "B" return "S" def _month_start(date: str) -> str: return f"{date[:7]}-01" def _trade_rows(conn: sqlite3.Connection, table: str, account_id: int, date: str) -> List[Dict[str, Any]]: """按日取成交行。live.traded_at 是 QMT 原样字符串(可能 '2026-08-15 09:35:00' 或紧凑格式),用两种 LIKE 兜;shadow 用 bar_date 精确匹配。""" if table == "live_trades": q = ("SELECT symbol, direction, price, volume, traded_at FROM live_trades " "WHERE account_id=? AND (substr(traded_at,1,10)=? OR traded_at LIKE ?)") rows = conn.execute(q, (account_id, date, f"{date.replace('-', '')}%")).fetchall() return [{"symbol": r[0], "direction": r[1], "price": r[2], "volume": r[3]} for r in rows] q = ("SELECT symbol, direction, price, volume FROM paper_trades " "WHERE account_id=? AND bar_date=? AND (rejected IS NULL OR rejected=0)") rows = conn.execute(q, (account_id, date)).fetchall() return [{"symbol": r[0], "direction": r[1], "price": r[2], "volume": r[3]} for r in rows] def _aggregate(trades: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]: """(norm_symbol, side) → {volume, notional} 聚合(vwap = notional/volume)。""" agg: Dict[str, Dict[str, float]] = {} for t in trades: key = f"{_norm_symbol(t['symbol'])}:{_norm_side(t['direction'])}" a = agg.setdefault(key, {"volume": 0.0, "notional": 0.0}) vol = float(t["volume"] or 0) a["volume"] += vol a["notional"] += vol * float(t["price"] or 0) return agg def _reconcile_trades(conn: sqlite3.Connection, live_id: int, shadow_id: int, date: str) -> Dict[str, Any]: live_rows = _trade_rows(conn, "live_trades", live_id, date) shadow_rows = _trade_rows(conn, "paper_trades", shadow_id, date) live = _aggregate(live_rows) shadow = _aggregate(shadow_rows) rows: List[Dict[str, Any]] = [] diffs: List[float] = [] for key in sorted(set(live) | set(shadow)): symbol, side = key.rsplit(":", 1) lv, sv = live.get(key), shadow.get(key) lv_vwap = lv["notional"] / lv["volume"] if lv and lv["volume"] else None sv_vwap = sv["notional"] / sv["volume"] if sv and sv["volume"] else None bps = None if lv_vwap and sv_vwap: bps = (lv_vwap - sv_vwap) / sv_vwap * 1e4 # 带符号:稳定偏一侧→重标滑点 diffs.append(abs(bps)) rows.append({ "symbol": symbol, "side": side, "live_volume": lv["volume"] if lv else 0, "shadow_volume": sv["volume"] if sv else 0, "live_vwap": round(lv_vwap, 4) if lv_vwap else None, "shadow_vwap": round(sv_vwap, 4) if sv_vwap else None, "price_diff_bps": round(bps, 2) if bps is not None else None, }) avg_bps = sum(diffs) / len(diffs) if diffs else 0.0 # 笔数口径(2026-08-24 修订):count_match 按 票+方向 聚合桶总量比较——实盘 # QMT 部分成交会把一笔 3800 股拆 19 行(08-24 momentum 27 vs 5 恒 False, # 纯计数噪音);原始行数仍如实呈现(live_count/shadow_count)供归因。 volume_match = all(r["live_volume"] == r["shadow_volume"] for r in rows) live_count = len(live_rows) shadow_count = len(shadow_rows) return { "live_count": live_count, "shadow_count": shadow_count, "count_match": volume_match, "rows": rows, "avg_price_diff_bps": round(avg_bps, 2), "pass_price": avg_bps <= PRICE_DIFF_BPS_MAX, } def _reconcile_positions(conn: sqlite3.Connection, live_id: int, shadow_id: int) -> Dict[str, Any]: lv = {r[0]: r[1] for r in conn.execute( "SELECT symbol, volume FROM live_positions WHERE account_id=?", (live_id,))} sv = {r[0]: r[1] for r in conn.execute( "SELECT symbol, volume FROM paper_positions " "WHERE account_id=? AND scope='account' AND date=(" " SELECT MAX(date) FROM paper_positions WHERE account_id=? AND scope='account')", (shadow_id, shadow_id))} lv_n = {_norm_symbol(s): v for s, v in lv.items()} sv_n = {_norm_symbol(s): v for s, v in sv.items()} rows = [] match = True for sym in sorted(set(lv_n) | set(sv_n)): lvol = int(lv_n.get(sym) or 0) svol = int(sv_n.get(sym) or 0) if lvol != svol: match = False rows.append({"symbol": sym, "live_volume": lvol, "shadow_volume": svol, "volume_diff": lvol - svol}) return {"rows": rows, "match": match} def _reconcile_nav(conn: sqlite3.Connection, live_id: int, shadow_id: int, date: str) -> Dict[str, Any]: """月累计净值偏差:月内(含月前最后一条)首基线 → 当日,双侧收益率差(%)。""" def _series(table: str, col: str, aid: int) -> Dict[str, float]: q = (f"SELECT date, {col} FROM {table} WHERE account_id=? " f"AND date<=? ORDER BY date") return {r[0]: float(r[1]) for r in conn.execute(q, (aid, date))} live_s = _series("live_balance", "total", live_id) shadow_s = _series("paper_daily_balance", "total_equity", shadow_id) if not live_s or not shadow_s or date not in live_s or date not in shadow_s: return {"live_total": live_s.get(date), "shadow_total": shadow_s.get(date), "mtd_deviation_pct": None, "pass_nav": None, "note": "净值序列不全"} ms = _month_start(date) def _baseline(series: Dict[str, float]) -> Optional[float]: prior = [v for d, v in series.items() if d < ms] return prior[-1] if prior else next( (v for d, v in sorted(series.items()) if d >= ms), None) lb, sb = _baseline(live_s), _baseline(shadow_s) if not lb or not sb: return {"live_total": live_s[date], "shadow_total": shadow_s[date], "mtd_deviation_pct": None, "pass_nav": None, "note": "无月内基线"} live_ret = live_s[date] / lb - 1 shadow_ret = shadow_s[date] / sb - 1 dev = abs(live_ret - shadow_ret) * 100 return { "live_total": live_s[date], "shadow_total": shadow_s[date], "live_mtd_return_pct": round(live_ret * 100, 4), "shadow_mtd_return_pct": round(shadow_ret * 100, 4), "mtd_deviation_pct": round(dev, 4), "pass_nav": dev <= NAV_MTD_PCT_MAX, } def build_reconcile_report(db: str, live_account_id: int, shadow_account_id: int, date: Optional[str] = None) -> Dict[str, Any]: """构建并返回某日双轨对账报告(纯读,不落库)。""" date = date or datetime.now().strftime("%Y-%m-%d") with sqlite3.connect(db) as conn: trades = _reconcile_trades(conn, live_account_id, shadow_account_id, date) positions = _reconcile_positions(conn, live_account_id, shadow_account_id) nav = _reconcile_nav(conn, live_account_id, shadow_account_id, date) passed = bool( trades["count_match"] and trades["pass_price"] and positions["match"] and nav["pass_nav"] is not False ) return { "date": date, "live_account_id": live_account_id, "shadow_account_id": shadow_account_id, "trades": trades, "positions": positions, "nav": nav, "passed": passed, } # ===== B5 恒等式对账(spec §multi-strategy-instance-budget §B5) ===== # 全账户 = Σ实例账本 + 未归因遗留仓;先过恒等式,再做逐对 live↔shadow 行为对比。 IDENTITY_TOL_PCT = 0.5 # 未归因占账户市值容忍上限(%;价格时点差) def build_identity_report(db: str, date: Optional[str] = None) -> Dict[str, Any]: """账户恒等式报告:每 QMT 账号一行,snapshot 市值 vs Σ实例账本市值。 - Σ实例市值 = 同账号各实盘实例 live_balance 最新一条 market_value 之和 (dae56e2 起为实例账本视图;旧全账户行会如实呈现为大额负未归因)。 - 未归因仓 = 按 6 位码逐票对账:snapshot 持仓 − Σ实例持仓(live_positions)。 - 无快照/无实例的账号如实标注(snapshot_missing / no_instances)。 """ date = date or datetime.now().strftime("%Y-%m-%d") rows: List[Dict[str, Any]] = [] with sqlite3.connect(db) as conn: conn.row_factory = sqlite3.Row snapshots = {r["account"]: dict(r) for r in conn.execute( "SELECT * FROM qmt_account_snapshot")} # 同 QMT 账号分组:实例最新账本 + 实例持仓视图 inst_rows = [dict(r) for r in conn.execute( "SELECT id, name, account FROM live_accounts")] by_acc: Dict[str, List[Dict[str, Any]]] = {} for r in inst_rows: by_acc.setdefault((r.get("account") or "").strip(), []).append(r) accounts = sorted(set(snapshots) | {a for a in by_acc if a}) for acc in accounts: snap = snapshots.get(acc) insts = by_acc.get(acc, []) mv_total = 0.0 for inst in insts: last = conn.execute( "SELECT market_value FROM live_balance " "WHERE account_id=? ORDER BY id DESC LIMIT 1", (inst["id"],)).fetchone() inst["mv"] = float(last[0] or 0) if last else 0.0 mv_total += inst["mv"] snap_mv = float(snap["market_value"]) if snap else None unattr_mv = (snap_mv - mv_total) if snap_mv is not None else None unattr_pct = (abs(unattr_mv) / snap_mv * 100 if snap_mv not in (None, 0) and unattr_mv is not None else None) # 逐票未归因:快照持仓 − Σ实例持仓(6 位码对齐) snap_pos: Dict[str, float] = {} if snap: try: for p in json.loads(snap.get("positions") or "[]"): snap_pos[_norm_symbol(p.get("symbol"))] = float( p.get("volume") or 0) except (ValueError, TypeError): snap_pos = {} inst_pos: Dict[str, float] = {} for inst in insts: for r in conn.execute( "SELECT symbol, volume FROM live_positions " "WHERE account_id=?", (inst["id"],)): inst_pos[_norm_symbol(r[0])] = inst_pos.get( _norm_symbol(r[0]), 0.0) + float(r[1] or 0) # 双端键并集:快照独有=正向孤儿(遗留/手动仓),实例独有=负向幻影 # (09-04 002836 实证:只遍历快照会漏幻影,导致修账只补一半) unattr_positions = [ {"symbol": s, "snapshot_volume": snap_pos.get(s, 0.0), "instance_volume": inst_pos.get(s, 0.0), "diff": snap_pos.get(s, 0.0) - inst_pos.get(s, 0.0)} for s in sorted(set(snap_pos) | set(inst_pos)) if abs(snap_pos.get(s, 0.0) - inst_pos.get(s, 0.0)) > 0.5 ] if snap is None: status = "snapshot_missing" elif not insts: status = "no_instances" elif unattr_pct is not None and unattr_pct <= IDENTITY_TOL_PCT: status = "pass" else: status = "unattributed_over_tol" rows.append({ "account": acc, "date": date, "status": status, "snapshot_mv": snap_mv, "instance_mv_total": mv_total, "unattributed_mv": unattr_mv, "unattributed_pct": round(unattr_pct, 4) if unattr_pct is not None else None, "tolerance_pct": IDENTITY_TOL_PCT, "instances": [{"id": i["id"], "name": i["name"], "mv": i["mv"]} for i in insts], "unattributed_positions": unattr_positions, }) passed = all(r["status"] in ("pass", "no_instances") for r in rows) \ and bool(rows) return {"date": date, "rows": rows, "identity_passed": passed} _IDENTITY_SCHEMA = """ CREATE TABLE IF NOT EXISTS identity_reconcile ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT, date TEXT, passed INTEGER, report TEXT, created_at TEXT, UNIQUE(account, date) ) """ def save_identity_report(db: str, report: Dict[str, Any]) -> None: """恒等式报告落库(同账号同日覆盖)。""" with sqlite3.connect(db) as conn: conn.execute(_IDENTITY_SCHEMA) for row in report["rows"]: conn.execute( "INSERT INTO identity_reconcile (account, date, passed, report, created_at) " "VALUES (?,?,?,?,?) ON CONFLICT(account, date) DO UPDATE SET " "passed=excluded.passed, report=excluded.report, " "created_at=excluded.created_at", (row["account"], report["date"], 1 if row["status"] in ("pass", "no_instances") else 0, json.dumps(row, ensure_ascii=False), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit() def load_identity_report(db: str, date: str) -> List[Dict[str, Any]]: """读已存恒等式行(无 → 空表)。""" with sqlite3.connect(db) as conn: try: rows = conn.execute( "SELECT report FROM identity_reconcile WHERE date=? ORDER BY account", (date,)).fetchall() except sqlite3.OperationalError: return [] return [json.loads(r[0]) for r in rows] def find_dual_track_pairs(db: str) -> List[Dict[str, Any]]: """自动配对(2026-08-16 v2):优先 instance_id+周期精确配对,无 instance 回退策略名。 v1 按策略名建 dict 收敛——同策略多 live 账户(live#10/#11 都是 channel_test) 后者覆盖前者 → live#10 漏配、live#11 被配两次,15:10 日终对账配错对 (VPS 8对舰队实测)。同实例的 live↔shadow 才是真双轨。 """ pairs: List[Dict[str, Any]] = [] with sqlite3.connect(db) as conn: conn.row_factory = sqlite3.Row lives = [dict(r) for r in conn.execute( "SELECT id, strategy_class, instance_id, interval " "FROM live_accounts WHERE status='running'")] for r in conn.execute( "SELECT id, strategies, instance_id, interval FROM paper_accounts " "WHERE mode='shadow' AND status='running'"): sh = dict(r) try: name = (json.loads(sh.get("strategies") or "[]") or [{}])[0].get("name", "") except (json.JSONDecodeError, IndexError): continue cand = None if sh.get("instance_id"): # 同实例同周期 → 精确双轨;退而求其次同实例 cand = next((l for l in lives if l.get("instance_id") == sh["instance_id"] and l.get("interval") == sh.get("interval")), None) if cand is None: cand = next((l for l in lives if l.get("instance_id") == sh["instance_id"]), None) if cand is None: same = [l for l in lives if l["strategy_class"] == name] if len(same) == 1: cand = same[0] elif len(same) > 1: # 同策略多 live 无 instance 可辨 → 周期一致才配,宁缺毋错 cand = next((l for l in same if l.get("interval") == sh.get("interval")), None) if cand: pairs.append({"live_account_id": cand["id"], "shadow_account_id": sh["id"], "strategy": name}) return pairs _SCHEMA = """ CREATE TABLE IF NOT EXISTS dual_track_reconcile ( id INTEGER PRIMARY KEY AUTOINCREMENT, live_account_id INTEGER, shadow_account_id INTEGER, date TEXT, passed INTEGER, report TEXT, created_at TEXT, UNIQUE(live_account_id, shadow_account_id, date) ) """ def save_reconcile_report(db: str, report: Dict[str, Any]) -> None: """落库(upsert 同配对同日覆盖)。""" with sqlite3.connect(db) as conn: conn.execute(_SCHEMA) conn.execute( "INSERT INTO dual_track_reconcile " "(live_account_id, shadow_account_id, date, passed, report, created_at) " "VALUES (?,?,?,?,?,?) " "ON CONFLICT(live_account_id, shadow_account_id, date) " "DO UPDATE SET passed=excluded.passed, report=excluded.report, " "created_at=excluded.created_at", (report["live_account_id"], report["shadow_account_id"], report["date"], 1 if report.get("passed") else 0, json.dumps(report, ensure_ascii=False), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit() def load_reconcile_report(db: str, live_account_id: int, shadow_account_id: int, date: str, *, as_row: bool = False) -> Any: """读已存报告;无 → None。as_row=True 返回表行(含 created_at)而非解析 JSON。""" with sqlite3.connect(db) as conn: conn.execute(_SCHEMA) row = conn.execute( "SELECT live_account_id, shadow_account_id, date, passed, report, " "created_at FROM dual_track_reconcile WHERE live_account_id=? " "AND shadow_account_id=? AND date=?", (live_account_id, shadow_account_id, date), ).fetchone() if row is None: return None if as_row: keys = ("live_account_id", "shadow_account_id", "date", "passed", "report", "created_at") return [dict(zip(keys, row))] return json.loads(row[4]) def main() -> None: """CLI:python -m sanguo_trader.shadow.reconcile_report --db [--date ...] 不传配对 → find_dual_track_pairs 自动配对全部跑一遍并落库。 """ import argparse logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") p = argparse.ArgumentParser(description="双轨日终对账报表") p.add_argument("--db", required=True) p.add_argument("--date", default=None) p.add_argument("--live", type=int, default=None, help="显式配对:live 账户 id") p.add_argument("--shadow", type=int, default=None, help="显式配对:影子账户 id") args = p.parse_args() if args.live and args.shadow: pairs = [{"live_account_id": args.live, "shadow_account_id": args.shadow, "strategy": "(explicit)"}] else: pairs = find_dual_track_pairs(args.db) if not pairs: logger.warning("未找到运行中的双轨配对(影子 mode=shadow ↔ live 同策略名)") # B5 恒等式先行:全账户 = Σ实例账本 + 未归因,再做逐对行为对比 identity = build_identity_report(args.db, args.date) save_identity_report(args.db, identity) for row in identity["rows"]: logger.info( "[恒等式] %s %s: 快照市值=%.0f Σ实例=%.0f 未归因=%.0f(%s%%) " "未归因票%d只 → %s", identity["date"], row["account"], row["snapshot_mv"] or 0, row["instance_mv_total"], row["unattributed_mv"] or 0, row["unattributed_pct"], len(row["unattributed_positions"]), row["status"], ) for pair in pairs: r = build_reconcile_report(args.db, pair["live_account_id"], pair["shadow_account_id"], args.date) save_reconcile_report(args.db, r) t = r["trades"] logger.info( "[对账] %s live#%s vs shadow#%s(%s): 笔数 %s/%s=%s 价差%.1fbps=%s " "持仓=%s 净值月偏差=%s%% → %s", r["date"], pair["live_account_id"], pair["shadow_account_id"], pair["strategy"], t["live_count"], t["shadow_count"], "同" if t["count_match"] else "异", t["avg_price_diff_bps"], "过" if t["pass_price"] else "超限", "同" if r["positions"]["match"] else "异", r["nav"]["mtd_deviation_pct"], "PASS" if r["passed"] else "FAIL", ) if __name__ == "__main__": main()