258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""双轨对账报表(影子柜台 vs 实盘模拟,设计 §8.2)单元测试。
|
|
|
|
纯 DB fixture:同一 db 文件里 live_*/paper_* 两套表(与 VPS backtest_results.db
|
|
同构),验证四项对账指标 + 自动配对 + 报告落库。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from sanguo_trader.persistence import init_db as init_paper_db
|
|
from sanguo_trader.shadow.reconcile_report import (
|
|
PRICE_DIFF_BPS_MAX,
|
|
build_reconcile_report,
|
|
find_dual_track_pairs,
|
|
load_reconcile_report,
|
|
save_reconcile_report,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
db_path = str(tmp_path / "t.db")
|
|
init_paper_db(db_path) # paper_* 表
|
|
from sanguo_live.persistence import init_db as init_live_db
|
|
init_live_db(db_path) # live_* 表(同文件共存,与 VPS 一致)
|
|
return db_path
|
|
|
|
|
|
def _add_live_account(db, aid=5, strategy_class="channel_test"):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class,"
|
|
"strategy_name,status) VALUES (?,?,?,?,?,?,?)",
|
|
(aid, "live-600000", "66639661", "hs300_subset",
|
|
strategy_class, "portfolio_channel_test", "running"),
|
|
)
|
|
|
|
|
|
def _add_shadow_account(db, aid=39, strategy="channel_test"):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO paper_accounts (id,name,strategy_type,mode,status,symbols,"
|
|
"strategies) VALUES (?,?,?,?,?,?,?)",
|
|
(aid, "paper", "portfolio", "shadow", "running", '["hs300_subset"]',
|
|
json.dumps([{"name": strategy, "params": {}}])),
|
|
)
|
|
|
|
|
|
def _add_live_trade(db, aid, symbol, direction, price, volume, traded_at):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO live_trades (account_id,strategy_name,symbol,direction,"
|
|
"offset,price,volume,traded_at,vt_tradeid) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
(aid, "portfolio_channel_test", symbol, direction, "", price, volume,
|
|
traded_at, f"t{price}{volume}{symbol}"),
|
|
)
|
|
|
|
|
|
def _add_paper_trade(db, aid, symbol, direction, price, volume, dt, bar_date):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO paper_trades (account_id,strategy_id,datetime,symbol,"
|
|
"direction,offset,match_session,price,volume,commission,stamp_duty,"
|
|
"transfer_fee,rejected,bar_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?)",
|
|
(aid, "channel_test", dt, symbol, direction, "open",
|
|
"shadow_realtime", price, volume, 5.0, 0.0, bar_date),
|
|
)
|
|
|
|
|
|
def _add_live_balance(db, aid, date, cash, mv, total):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO live_balance (account_id,date,cash,market_value,total) "
|
|
"VALUES (?,?,?,?,?)", (aid, date, cash, mv, total),
|
|
)
|
|
|
|
|
|
def _add_paper_balance(db, aid, date, cash, mv, total):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO paper_daily_balance (account_id,date,cash,market_value,"
|
|
"total_equity) VALUES (?,?,?,?,?)", (aid, date, cash, mv, total),
|
|
)
|
|
|
|
|
|
def _add_live_position(db, aid, symbol, volume):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO live_positions (account_id,symbol,volume,frozen,avg_price,"
|
|
"updated_at) VALUES (?,?,?,?,?,?)",
|
|
(aid, symbol, volume, 0.0, 10.0, "2026-08-15 15:00:00"),
|
|
)
|
|
|
|
|
|
def _add_paper_position(db, aid, symbol, volume):
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"INSERT INTO paper_positions (account_id,scope,symbol,date,volume,"
|
|
"frozen,avg_price,market_value,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
(aid, "account", symbol, "2026-08-15", volume, 0, 10.0, volume * 10.0,
|
|
"2026-08-15 15:00:00"),
|
|
)
|
|
|
|
|
|
D = "2026-08-15"
|
|
|
|
|
|
class TestFindDualTrackPairs:
|
|
def test_pairs_by_strategy_name(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
pairs = find_dual_track_pairs(db)
|
|
assert pairs == [{"live_account_id": 5, "shadow_account_id": 39,
|
|
"strategy": "channel_test"}]
|
|
|
|
def test_no_shadow_no_pairs(self, db):
|
|
_add_live_account(db)
|
|
assert find_dual_track_pairs(db) == []
|
|
|
|
|
|
class TestBuildReconcileReport:
|
|
def test_all_pass_when_both_sides_identical(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
# 同笔成交(符号口径不同:live 用 600000.SH,shadow 用 600000.XSHG)
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.00, 1000, f"{D} 09:35:00")
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.00, 1000,
|
|
f"{D} 09:35:00", D)
|
|
# 持仓一致
|
|
_add_live_position(db, 5, "510300.SH", 1000)
|
|
_add_paper_position(db, 39, "510300.XSHG", 1000)
|
|
# 净值:月初基线同 100 万,当日同 101 万 → 月偏差 0
|
|
_add_live_balance(db, 5, "2026-08-01", 1_000_000, 0, 1_000_000)
|
|
_add_live_balance(db, 5, D, 10_000, 1_000_000, 1_010_000)
|
|
_add_paper_balance(db, 39, "2026-08-01", 1_000_000, 0, 1_000_000)
|
|
_add_paper_balance(db, 39, D, 10_000, 1_000_000, 1_010_000)
|
|
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["trades"]["count_match"] is True
|
|
assert r["trades"]["live_count"] == 1 and r["trades"]["shadow_count"] == 1
|
|
assert r["trades"]["rows"][0]["symbol"] == "510300"
|
|
assert r["trades"]["rows"][0]["price_diff_bps"] == pytest.approx(0, abs=1)
|
|
assert r["trades"]["pass_price"] is True
|
|
assert r["positions"]["match"] is True
|
|
assert r["nav"]["mtd_deviation_pct"] == pytest.approx(0, abs=1e-9)
|
|
assert r["passed"] is True
|
|
|
|
def test_count_mismatch_fails(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.00, 1000, f"{D} 09:35:00")
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.01, 500, f"{D} 10:00:00")
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.00, 1500,
|
|
f"{D} 09:35:00", D)
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["trades"]["count_match"] is False # 2 vs 1
|
|
|
|
def test_price_diff_over_10bps_fails(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
# 4.004 vs 4.000 = 10bps 边界;4.01 vs 4.00 = 25bps 超限
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.01, 1000, f"{D} 09:35:00")
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.00, 1000,
|
|
f"{D} 09:35:00", D)
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["trades"]["rows"][0]["price_diff_bps"] == pytest.approx(25.0, abs=0.5)
|
|
assert r["trades"]["pass_price"] is False
|
|
assert PRICE_DIFF_BPS_MAX == 10
|
|
|
|
def test_position_volume_mismatch_detected(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
_add_live_position(db, 5, "510300.SH", 1000)
|
|
_add_paper_position(db, 39, "510300.XSHG", 800)
|
|
_add_paper_position(db, 39, "159915.XSHE", 500) # 影子多出一只
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["positions"]["match"] is False
|
|
vols = {row["symbol"]: row for row in r["positions"]["rows"]}
|
|
assert vols["510300"]["live_volume"] == 1000
|
|
assert vols["510300"]["shadow_volume"] == 800
|
|
assert vols["159915"]["live_volume"] == 0
|
|
|
|
def test_nav_mtd_deviation_over_threshold_fails(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
# live 月内 +1.0%,shadow 月内 -0.6% → 偏差 1.6% > 0.5%
|
|
_add_live_balance(db, 5, "2026-08-01", 1_000_000, 0, 1_000_000)
|
|
_add_live_balance(db, 5, D, 0, 1_010_000, 1_010_000)
|
|
_add_paper_balance(db, 39, "2026-08-01", 1_000_000, 0, 1_000_000)
|
|
_add_paper_balance(db, 39, D, 0, 994_000, 994_000)
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["nav"]["mtd_deviation_pct"] == pytest.approx(1.6, abs=0.01)
|
|
assert r["nav"]["pass_nav"] is False
|
|
|
|
def test_rejected_shadow_trades_excluded(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.00, 1000, f"{D} 09:35:00")
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.00, 1000,
|
|
f"{D} 09:35:00", D)
|
|
with sqlite3.connect(db) as conn: # 影子拒单不应计入笔数
|
|
conn.execute(
|
|
"INSERT INTO paper_trades (account_id,strategy_id,datetime,symbol,"
|
|
"direction,price,volume,rejected,reject_reason,bar_date) "
|
|
"VALUES (39,'channel_test',?,'159915.XSHE','long',2.0,100,1,"
|
|
"'涨停拒买',?)", (f"{D} 13:45:00", D))
|
|
r = build_reconcile_report(db, 5, 39, D)
|
|
assert r["trades"]["shadow_count"] == 1
|
|
|
|
|
|
class TestPersistReconcileReport:
|
|
def test_save_load_roundtrip_and_upsert(self, db):
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.0, 100, f"{D} 09:35", D)
|
|
r1 = build_reconcile_report(db, 5, 39, D)
|
|
save_reconcile_report(db, r1)
|
|
r1["passed"] = True # 改一处再存 → upsert 覆盖
|
|
save_reconcile_report(db, r1)
|
|
loaded = load_reconcile_report(db, 5, 39, D)
|
|
assert loaded is not None
|
|
assert loaded["passed"] is True
|
|
rows = load_reconcile_report(db, 5, 39, D, as_row=True)
|
|
assert rows and rows[0]["live_account_id"] == 5
|
|
|
|
|
|
class TestDailyReconcileHook:
|
|
def test_runs_once_after_close_and_skips_before(self, db):
|
|
"""15:10 前不跑;之后跑一次落库,同日第二次跳过。"""
|
|
from datetime import datetime
|
|
|
|
from sanguo_trader.shadow.reconcile_report import load_reconcile_report
|
|
from sanguo_trader.shadow.supervisor import _maybe_daily_reconcile
|
|
|
|
_add_live_account(db)
|
|
_add_shadow_account(db)
|
|
_add_live_trade(db, 5, "510300.SH", "buy", 4.0, 1000, f"{D} 09:35:00")
|
|
_add_paper_trade(db, 39, "510300.XSHG", "long", 4.0, 1000,
|
|
f"{D} 09:35:00", D)
|
|
done: set = set()
|
|
|
|
# 盘中 14:00 → 不跑
|
|
_maybe_daily_reconcile(db, done, now=datetime(2026, 8, 15, 14, 0))
|
|
assert done == set()
|
|
assert load_reconcile_report(db, 5, 39, D) is None
|
|
|
|
# 收盘后 15:30 → 跑并落库
|
|
_maybe_daily_reconcile(db, done, now=datetime(2026, 8, 15, 15, 30))
|
|
assert D in done
|
|
assert load_reconcile_report(db, 5, 39, D) is not None
|
|
|
|
# 同日再触发 → 跳过
|
|
_maybe_daily_reconcile(db, done, now=datetime(2026, 8, 15, 16, 0))
|
|
assert done == {D}
|