df008b985c
- 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拍板)
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""模拟盘 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
|