Files
sanguo_vnpy_v2/sanguo_trader/shadow/reconcile_report.py
T

321 lines
13 KiB
Python

"""双轨日终对账报表(影子柜台 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 <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
# 笔数=原始成交行数(非聚合桶):实盘部分成交会多行,差异如实呈现待归因
live_count = len(live_rows)
shadow_count = len(shadow_rows)
return {
"live_count": live_count,
"shadow_count": shadow_count,
"count_match": live_count == shadow_count,
"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,
}
def find_dual_track_pairs(db: str) -> List[Dict[str, Any]]:
"""自动配对:运行中影子账户(mode=shadow)按策略名匹配 live_accounts.strategy_class。"""
pairs: List[Dict[str, Any]] = []
with sqlite3.connect(db) as conn:
lives = {r[0]: r[1] for r in conn.execute(
"SELECT strategy_class, id FROM live_accounts WHERE status='running'")}
for aid, strategies_json in conn.execute(
"SELECT id, strategies FROM paper_accounts "
"WHERE mode='shadow' AND status='running'"):
try:
name = (json.loads(strategies_json or "[]") or [{}])[0].get("name", "")
except (json.JSONDecodeError, IndexError):
continue
live_id = lives.get(name)
if live_id:
pairs.append({"live_account_id": live_id,
"shadow_account_id": aid, "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 <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 同策略名)")
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()