"""模拟盘 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"