Files
sanguo_vnpy_v2/tests/api/test_paper_routes.py
T
claude_dev df008b985c
CI/CD / test (push) Failing after 23s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
feat(web): 对账中心上线——恒等式B5只读端点+前端恒等式区块,双轨对账页升级为对账中心(issue#42-A) [nas]
- API: GET /paper/reconcile/identity?date&refresh——缺省读 supervisor 15:10/23:27
  已存 identity_reconcile 行,无存行现算并落库(与 build/save 同套幂等);
  路由置于 /paper/{aid} 前,与既有 reconcile 端点同组
- 前端: Reconcile.vue 顶部新增恒等式卡片(账户级红绿/未归因±/占比vs容忍0.5%/
  可展开逐票缺口+实例市值拆分),页面与菜单更名为「对账中心」;
  api/paper.ts 补 IdentityReport 类型族
- 测试: 绿线(50/10050=0.497%pass)+存行复用+refresh强制重算(200=1.96%红)+
  snapshot_missing 如实标注,12/12 绿
- 附: docs/session_prompts/ 两份 session 移交提示词入库(fundamentals 18min→
  数据session / 统一ex定寸→策略session,用户08-26拍板)
2026-08-26 20:31:42 +08:00

324 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟盘 API 路由测试(spec §10)。"""
import os
from fastapi.testclient import TestClient
from sanguo_api.app import create_app
from sanguo_api.auth import create_token, set_jwt_config
from sanguo_api.routes_paper import set_db_path
def _client(tmp_path):
set_jwt_config(secret="t", expire_minutes=60)
db = os.path.join(str(tmp_path), "p.db")
app = create_app(db_path=db)
set_db_path(db)
return TestClient(app), create_token("admin")
def _auth(token):
return {"Authorization": f"Bearer {token}"}
def test_create_paper(tmp_path):
c, token = _client(tmp_path)
resp = c.post(
"/api/v1/paper/create",
json={
"symbols": ["600000"],
"strategies": [{"name": "DoubleMa", "symbol": "600000",
"match_session": "next_open"}],
"start": "2024-01-01", "end": "2024-06-30",
"initial_capital": 1_000_000,
},
headers=_auth(token),
)
assert resp.status_code == 200
assert "account_id" in resp.json()
def test_create_shadow_mode(tmp_path):
"""影子=第三种运行模式(CTA/组合都可):engine 由 mode 推导,不被日终 job 结算。"""
c, token = _client(tmp_path)
# CTA 影子
r1 = c.post("/api/v1/paper/create", json={
"mode": "shadow",
"symbols": ["600000"],
"strategies": [{"name": "DoubleMa", "symbol": "600000"}],
"start": "2024-01-01", "end": "2024-06-30",
}, headers=_auth(token))
assert r1.status_code == 200
# 组合影子:mode=shadow → engine=shadow
r2 = c.post("/api/v1/paper/create", json={
"mode": "shadow", "strategy_type": "portfolio",
"symbols": ["hs300_subset"],
"strategies": [{"name": "all_weather", "symbol": "hs300_subset"}],
"start": "2024-01-01", "end": "2024-12-31",
"pool": "hs300_subset",
}, headers=_auth(token))
assert r2.status_code == 200
lst = c.get("/api/v1/paper", headers=_auth(token)).json()
items = lst if isinstance(lst, list) else lst.get("accounts", lst.get("papers", []))
by_id = {a["id"]: a for a in items}
cta = by_id[r1.json()["account_id"]]
assert cta["mode"] == "shadow"
assert cta["engine"] == "shadow"
# 实走/影子是开放账户:用户填的区间被忽略,开始=创建当天,结束留空
from datetime import date
assert cta["start_date"] == date.today().isoformat()
assert not cta["end_date"]
pf = by_id[r2.json()["account_id"]]
assert pf["mode"] == "shadow" and pf["engine"] == "shadow"
assert not pf["end_date"]
def test_create_portfolio_rejects_replay(tmp_path):
c, token = _client(tmp_path)
resp = c.post("/api/v1/paper/create", json={
"mode": "replay", "strategy_type": "portfolio",
"symbols": ["hs300_subset"],
"strategies": [{"name": "all_weather", "symbol": "hs300_subset"}],
"start": "2024-01-01", "end": "2024-12-31",
}, headers=_auth(token))
assert resp.status_code == 400
assert "组合回测" in resp.json()["detail"]
def test_get_paper_and_empty_trades(tmp_path):
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/paper/create",
json={"symbols": ["600000"],
"strategies": [{"name": "S", "symbol": "600000"}],
"start": "2024-01-01", "end": "2024-06-30"},
headers=_auth(token),
).json()["account_id"]
assert c.get(f"/api/v1/paper/{aid}", headers=_auth(token)).status_code == 200
assert c.get(f"/api/v1/paper/{aid}/trades", headers=_auth(token)).json() == []
assert c.get(f"/api/v1/paper/{aid}/equity", headers=_auth(token)).json() == []
def test_get_paper_404(tmp_path):
c, token = _client(tmp_path)
assert c.get("/api/v1/paper/999", headers=_auth(token)).status_code == 404
def test_unauthorized_401(tmp_path):
c, _ = _client(tmp_path)
assert c.get("/api/v1/paper/1").status_code == 401
def test_strategy_summary_aggregation(tmp_path):
"""C-S2 归因:分策略成交/拒单/费用聚合(spec §7)。"""
from sanguo_trader.persistence import init_db, save_account, save_trade
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
aid = c.post(
"/api/v1/paper/create",
json={"symbols": ["600000"],
"strategies": [{"name": "S", "symbol": "600000"}],
"start": "2024-01-01", "end": "2024-06-30"},
headers=_auth(token),
).json()["account_id"]
# 造假 2 笔成交 + 1 拒单(s1),1 成交(s2)
save_trade(db, aid, {"strategy_id": "s1", "symbol": "600000", "price": 10,
"volume": 100, "commission": 5, "transfer_fee": 0.02})
save_trade(db, aid, {"strategy_id": "s1", "symbol": "600000", "price": 11,
"volume": 100, "commission": 5, "stamp_duty": 0.55,
"transfer_fee": 0.02})
save_trade(db, aid, {"strategy_id": "s1", "symbol": "300750"},
rejected=True, reject_reason="limit_up_locked")
save_trade(db, aid, {"strategy_id": "s2", "symbol": "000001", "price": 15,
"volume": 100, "commission": 5})
resp = c.get(f"/api/v1/paper/{aid}/strategies", headers=_auth(token))
assert resp.status_code == 200
summary = {s["strategy_id"]: s for s in resp.json()}
assert summary["s1"]["filled"] == 2
assert summary["s1"]["rejected"] == 1
assert summary["s2"]["filled"] == 1
def test_positions_endpoint(tmp_path):
"""Phase 3c:当前持仓快照({symbol:{volume,frozen,avg_price}} → list)。"""
from sanguo_trader.persistence import save_positions
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
aid = c.post(
"/api/v1/paper/create",
json={"symbols": ["600000"],
"strategies": [{"name": "S", "symbol": "600000"}],
"start": "2024-01-01", "end": "2024-06-30"},
headers=_auth(token),
).json()["account_id"]
save_positions(db, aid, "account",
{"600000": {"volume": 100, "frozen": 0, "avg_price": 10.5}},
"2024-01-15")
resp = c.get(f"/api/v1/paper/{aid}/positions", headers=_auth(token))
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["symbol"] == "600000"
assert data[0]["volume"] == 100
assert data[0]["frozen"] == 0
assert data[0]["avg_price"] == 10.5
def test_pending_endpoint(tmp_path):
"""Phase 3c:跨日 pending 订单(C-S3)。"""
from sanguo_trader.persistence import save_pending_orders
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
aid = c.post(
"/api/v1/paper/create",
json={"symbols": ["600000"],
"strategies": [{"name": "S", "symbol": "600000"}],
"start": "2024-01-01", "end": "2024-06-30"},
headers=_auth(token),
).json()["account_id"]
save_pending_orders(db, aid, [
{"strategy_id": "s1", "symbol": "600000", "side": "buy",
"price": 10.0, "volume": 100, "is_market": True,
"match_session": "next_open", "listing_days": 0},
])
resp = c.get(f"/api/v1/paper/{aid}/pending", headers=_auth(token))
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["symbol"] == "600000"
assert data[0]["side"] == "buy"
assert data[0]["is_market"] is True
# ===== 双轨对账报表(影子 P3 前半)=====
def test_reconcile_routes(tmp_path):
"""GET /paper/reconcile 自动配对+报告;/paper/reconcile/{l}/{s} 单配对。"""
import json as _json
import sqlite3
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class,"
"strategy_name,status) VALUES (5,'live','66639661','pool',"
"'channel_test','portfolio_channel_test','running')")
conn.execute(
"INSERT INTO paper_accounts (id,name,strategy_type,mode,status,"
"strategies) VALUES (39,'paper','portfolio','shadow','running',"
"'[{\"name\": \"channel_test\", \"params\": {}}]')")
conn.execute(
"INSERT INTO live_trades (account_id,symbol,direction,price,volume,"
"traded_at,vt_tradeid) VALUES (5,'510300.SH','buy',4.0,1000,"
"'2026-08-15 09:35:00','t1')")
conn.execute(
"INSERT INTO paper_trades (account_id,strategy_id,datetime,symbol,"
"direction,offset,price,volume,rejected,bar_date) VALUES (39,'ct',"
"'2026-08-15 09:35:00','510300.XSHG','long','open',4.0,1000,0,"
"'2026-08-15')")
conn.commit()
# 自动配对列表(指定 date 保证命中测试数据)
r = c.get("/api/v1/paper/reconcile?date=2026-08-15", headers=_auth(token))
assert r.status_code == 200
pairs = r.json()["pairs"]
assert len(pairs) == 1
assert pairs[0]["live_account_id"] == 5
assert pairs[0]["shadow_account_id"] == 39
assert pairs[0]["report"]["trades"]["count_match"] is True
# 单配对端点
r2 = c.get("/api/v1/paper/reconcile/5/39?date=2026-08-15", headers=_auth(token))
assert r2.status_code == 200
assert r2.json()["trades"]["live_count"] == 1
# 未配对的 aid 路由不被 reconcile 吞:GET /paper/39 仍走账户详情
r3 = c.get("/api/v1/paper/39", headers=_auth(token))
assert r3.status_code == 200
assert r3.json()["mode"] == "shadow"
def test_identity_reconcile_route(tmp_path):
"""GET /paper/reconcile/identity:现算→落库→二次读存行;超容忍标红。"""
import sqlite3
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
def _seed(snap_mv: float) -> None:
with sqlite3.connect(db) as conn:
conn.execute("DELETE FROM live_accounts")
conn.execute("DELETE FROM live_balance")
conn.execute("DELETE FROM live_positions")
conn.execute("DELETE FROM qmt_account_snapshot")
conn.execute(
"INSERT INTO live_accounts (id,name,account,vt_symbol,"
"strategy_class,strategy_name,status) VALUES "
"(5,'live','66639661','pool','channel_test',"
"'portfolio_channel_test','running')")
conn.execute(
"INSERT INTO live_balance (account_id,date,cash,market_value,"
"total) VALUES (5,'2026-08-15',1000.0,10000.0,11000.0)")
conn.execute(
"INSERT INTO live_positions (account_id,symbol,volume,avg_price) "
"VALUES (5,'510300.XSHG',1000,4.0)")
conn.execute(
"INSERT INTO qmt_account_snapshot (account,mini_path,cash,"
"market_value,total,positions,updated_at) VALUES "
"('66639661','',1000.0,?,10100.0,"
"'[{\"symbol\": \"510300.SH\", \"volume\": 1000}]',"
"'2026-08-15 15:10:00')", (snap_mv,))
conn.commit()
# 绿线:快照 10050 账本 10000 = 50(0.497% < 0.5%) → pass
_seed(10050.0)
r = c.get("/api/v1/paper/reconcile/identity?date=2026-08-15",
headers=_auth(token))
assert r.status_code == 200
data = r.json()
assert data["identity_passed"] is True
row = data["rows"][0]
assert row["account"] == "66639661"
assert row["status"] == "pass"
assert abs(row["unattributed_mv"] - 50.0) < 1e-6
assert row["unattributed_positions"] == [] # 逐票 1000=1000 无分歧
# 二次调用走已存行(identity_reconcile 同日已落库),结果一致
r2 = c.get("/api/v1/paper/reconcile/identity?date=2026-08-15",
headers=_auth(token))
assert r2.json()["rows"][0]["unattributed_mv"] == row["unattributed_mv"]
# 超红线(改库后 refresh=true 强制重算):快照 10200 账本 10000 = 200(1.96%)
_seed(10200.0)
r3 = c.get("/api/v1/paper/reconcile/identity?date=2026-08-15&refresh=true",
headers=_auth(token))
d3 = r3.json()
assert d3["identity_passed"] is False
assert d3["rows"][0]["status"] == "unattributed_over_tol"
assert abs(d3["rows"][0]["unattributed_mv"] - 200.0) < 1e-6
def test_identity_reconcile_snapshot_missing(tmp_path):
"""无快照账号如实标 snapshot_missing,不算通过也不崩。"""
import sqlite3
c, token = _client(tmp_path)
db = os.path.join(str(tmp_path), "p.db")
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class,"
"strategy_name,status) VALUES (6,'l2','88888888','pool',"
"'all_weather','portfolio_all_weather_ex','running')")
conn.commit()
r = c.get("/api/v1/paper/reconcile/identity?date=2026-08-15",
headers=_auth(token))
assert r.status_code == 200
row = r.json()["rows"][0]
assert row["status"] == "snapshot_missing"
assert r.json()["identity_passed"] is False