"""实盘模拟 API + 持久化单测(task #4)。 两层: (1) persistence CRUD —— sqlite tmp,纯 Python,Mac 跑通; (2) routes_live API —— FastAPI TestClient,不实例化 LiveTradingEngine (supervisor 才起 engine,本模块只测 DB CRUD 路由)。 Mac 跑:``pytest tests/test_live_api.py -v`` """ from __future__ import annotations 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_live import set_db_path # ============================================================================= # 层 1:persistence CRUD # ============================================================================= def _db(tmp_path) -> str: from sanguo_live.persistence import init_db db = os.path.join(str(tmp_path), "live.db") init_db(db) return db def test_save_and_get_account(tmp_path): from sanguo_live.persistence import save_account, get_account db = _db(tmp_path) aid = save_account(db, { "name": "live1", "account": "12345678", "vt_symbol": "600000.SSE", "strategy_name": "dm1", "setting": {"fast_window": 5}, "initial_capital": 5e5, }) assert aid > 0 acc = get_account(db, aid) assert acc["account"] == "12345678" assert acc["status"] == "stopped" # 默认 stopped assert acc["vt_symbol"] == "600000.SSE" assert "\"fast_window\": 5" in acc["setting"] # JSON 字符串 def test_list_accounts_and_default_status(tmp_path): from sanguo_live.persistence import save_account, list_accounts db = _db(tmp_path) save_account(db, {"account": "1", "strategy_name": "s1"}) save_account(db, {"account": "2", "strategy_name": "s2"}) rows = list_accounts(db) assert len(rows) == 2 # DESC 排序:最新建的在前 assert rows[0]["account"] == "2" assert all(r["status"] == "stopped" for r in rows) def test_update_account_status(tmp_path): from sanguo_live.persistence import ( save_account, update_account_status, get_account, list_running_accounts, ) db = _db(tmp_path) aid = save_account(db, {"account": "999", "strategy_name": "s"}) update_account_status(db, aid, "running") assert get_account(db, aid)["status"] == "running" assert len(list_running_accounts(db)) == 1 update_account_status(db, aid, "stopped", "test error") assert get_account(db, aid)["status"] == "stopped" assert get_account(db, aid)["error_msg"] == "test error" assert list_running_accounts(db) == [] def test_save_and_list_trades(tmp_path): from sanguo_live.persistence import save_account, save_trade, list_trades db = _db(tmp_path) aid = save_account(db, {"account": "1", "strategy_name": "s"}) tid1 = save_trade(db, aid, {"symbol": "600000.SSE", "direction": "long", "offset": "open", "price": 10.5, "volume": 100, "traded_at": "2026-07-17T10:00:00", "vt_tradeid": "T1"}) tid2 = save_trade(db, aid, {"symbol": "600000.SSE", "direction": "short", "offset": "close", "price": 11.0, "volume": 100, "traded_at": "2026-07-17T11:00:00", "vt_tradeid": "T2"}) assert tid1 > 0 and tid2 > tid1 trades = list_trades(db, aid) assert len(trades) == 2 assert trades[0]["vt_tradeid"] == "T1" assert trades[1]["price"] == 11.0 def test_save_positions_overwrites_snapshot(tmp_path): """positions 覆盖式快照:第二次 save 完全替换第一次。""" from sanguo_live.persistence import ( save_account, save_positions, load_positions, ) db = _db(tmp_path) aid = save_account(db, {"account": "1", "strategy_name": "s"}) save_positions(db, aid, { "600000.SSE": {"volume": 100, "frozen": 0, "avg_price": 10.0}, "000001.SZSE": {"volume": 200, "frozen": 50, "avg_price": 15.0}, }) pos = load_positions(db, aid) assert len(pos) == 2 # 覆盖(600000 减仓,000001 清仓) save_positions(db, aid, { "600000.SSE": {"volume": 50, "frozen": 0, "avg_price": 10.0}, }) pos2 = load_positions(db, aid) assert len(pos2) == 1 assert pos2[0]["symbol"] == "600000.SSE" assert pos2[0]["volume"] == 50 def test_save_positions_skips_zero_volume(tmp_path): from sanguo_live.persistence import save_account, save_positions, load_positions db = _db(tmp_path) aid = save_account(db, {"account": "1", "strategy_name": "s"}) save_positions(db, aid, { "600000.SSE": {"volume": 0, "frozen": 0, "avg_price": 0}, "000001.SZSE": {"volume": 100, "frozen": 0, "avg_price": 15.0}, }) pos = load_positions(db, aid) assert len(pos) == 1 assert pos[0]["symbol"] == "000001.SZSE" def test_save_and_get_last_balance(tmp_path): from sanguo_live.persistence import ( save_account, save_balance, list_balance, get_last_balance, ) db = _db(tmp_path) aid = save_account(db, {"account": "1", "strategy_name": "s"}) save_balance(db, aid, "2026-07-17 10:00:00", 5e5, 1e5, 6e5) save_balance(db, aid, "2026-07-17 11:00:00", 4e5, 2e5, 6e5) all_bal = list_balance(db, aid) assert len(all_bal) == 2 last = get_last_balance(db, aid) assert last["cash"] == 4e5 assert last["total"] == 6e5 assert last["date"] == "2026-07-17 11:00:00" def test_get_last_balance_empty(tmp_path): from sanguo_live.persistence import save_account, get_last_balance db = _db(tmp_path) aid = save_account(db, {"account": "1", "strategy_name": "s"}) assert get_last_balance(db, aid) is None # ============================================================================= # 层 2:routes_live API(TestClient,不依赖 vnpy) # ============================================================================= def _client(tmp_path): set_jwt_config(secret="t", expire_minutes=60) db = os.path.join(str(tmp_path), "live_api.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_live(tmp_path): c, token = _client(tmp_path) resp = c.post( "/api/v1/live/create", json={ "name": "live1", "account": "12345678", "vt_symbol": "600000.SSE", "strategy_name": "dm1", "setting": {"fast_window": 5}, "initial_capital": 5e5, }, headers=_auth(token), ) assert resp.status_code == 200 body = resp.json() assert body["account_id"] > 0 assert body["status"] == "stopped" # create 后默认 stopped def test_list_and_get_live(tmp_path): c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1"}, headers=_auth(token), ).json()["account_id"] # 列表 lst = c.get("/api/v1/live", headers=_auth(token)).json() assert len(lst["accounts"]) == 1 assert lst["accounts"][0]["id"] == aid assert lst["accounts"][0]["total_return"] is None # 无 balance assert lst["accounts"][0]["position_count"] == 0 # 详情 detail = c.get(f"/api/v1/live/{aid}", headers=_auth(token)).json() assert detail["account"] == "123" assert detail["status"] == "stopped" def test_start_and_stop(tmp_path): c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1"}, headers=_auth(token), ).json()["account_id"] # start r = c.post(f"/api/v1/live/{aid}/start", headers=_auth(token)) assert r.status_code == 200 assert r.json()["status"] == "running" assert c.get(f"/api/v1/live/{aid}/status", headers=_auth(token)).json()["status"] == "running" # stop r = c.post(f"/api/v1/live/{aid}/stop", headers=_auth(token)) assert r.json()["status"] == "stopped" assert c.get(f"/api/v1/live/{aid}/status", headers=_auth(token)).json()["status"] == "stopped" def test_start_empty_account_rejected(tmp_path): """account 字段空 → start 返回 400。""" c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "", "strategy_name": "dm1"}, headers=_auth(token), ).json()["account_id"] r = c.post(f"/api/v1/live/{aid}/start", headers=_auth(token)) assert r.status_code == 400 def test_empty_trades_positions_account(tmp_path): """新建实例:trades / positions / account 应返回空结构。""" c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1"}, headers=_auth(token), ).json()["account_id"] assert c.get(f"/api/v1/live/{aid}/trades", headers=_auth(token)).json() == [] assert c.get(f"/api/v1/live/{aid}/positions", headers=_auth(token)).json() == [] assert c.get(f"/api/v1/live/{aid}/account", headers=_auth(token)).json() == {} def test_get_live_404(tmp_path): c, token = _client(tmp_path) assert c.get("/api/v1/live/999", headers=_auth(token)).status_code == 404 assert c.get("/api/v1/live/999/status", headers=_auth(token)).status_code == 404 assert c.post("/api/v1/live/999/start", headers=_auth(token)).status_code == 404 def test_unauthorized_401(tmp_path): c, _ = _client(tmp_path) assert c.get("/api/v1/live").status_code == 401 assert c.post("/api/v1/live/create", json={"account": "1", "strategy_name": "s"}).status_code == 401 def test_create_live_mini_path_default_when_empty(monkeypatch, tmp_path): """create 不传 mini_path → 后端 env/内置默认兜底,落库 mini_path 非空。 避免空 mini_path 导致 connect=-1(task #6a 冒烟发现)。 """ monkeypatch.delenv("SANGUO_QMT_PATH", raising=False) c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1"}, # 不传 mini_path headers=_auth(token), ).json()["account_id"] from sanguo_live.persistence import get_account db = os.path.join(str(tmp_path), "live_api.db") acc = get_account(db, aid) assert acc["mini_path"] # 非空 assert "userdata_mini" in acc["mini_path"] # 内置默认 def test_create_live_mini_path_env_fallback(monkeypatch, tmp_path): """req.mini_path 空 → env SANGUO_QMT_PATH 兜底(优先于内置默认)。""" monkeypatch.setenv("SANGUO_QMT_PATH", "/from/env/mini") c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1", "mini_path": ""}, headers=_auth(token), ).json()["account_id"] from sanguo_live.persistence import get_account db = os.path.join(str(tmp_path), "live_api.db") assert get_account(db, aid)["mini_path"] == "/from/env/mini" def test_list_lives_total_return_uses_first_snapshot_baseline(tmp_path): """list 收益率按首快照 total 为 baseline,不是 initial_capital。 场景:initial_capital=6e5,但首快照 total=5e5(模拟入金后立刻记录)。 两条 balance:5e5 → 5.5e5,收益率应为 (5.5e5 - 5e5) / 5e5 = 0.1, 而非按 initial_capital 6e5 算的 -0.0833。 """ from sanguo_live.persistence import save_balance c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1", "initial_capital": 6e5}, headers=_auth(token), ).json()["account_id"] db = os.path.join(str(tmp_path), "live_api.db") save_balance(db, aid, "2026-07-17 09:30:00", 5e5, 0, 5e5) # baseline save_balance(db, aid, "2026-07-17 15:00:00", 5e5, 0.5e5, 5.5e5) item = c.get("/api/v1/live", headers=_auth(token)).json()["accounts"][0] assert item["latest_equity"] == 5.5e5 # (5.5e5 - 5e5) / 5e5 = 0.1 assert abs(item["total_return"] - 0.1) < 1e-9 def test_routes_reflect_db_writes(tmp_path): """直接写 DB(模拟 supervisor 落库)→ API 路由读到。""" from sanguo_live.persistence import ( save_trade, save_positions, save_balance, ) c, token = _client(tmp_path) aid = c.post( "/api/v1/live/create", json={"account": "123", "strategy_name": "dm1", "initial_capital": 6e5}, headers=_auth(token), ).json()["account_id"] db = os.path.join(str(tmp_path), "live_api.db") save_trade(db, aid, {"symbol": "600000.SSE", "direction": "long", "offset": "open", "price": 10.0, "volume": 100, "traded_at": "2026-07-17T10:00:00"}) save_positions(db, aid, {"600000.SSE": {"volume": 100, "frozen": 0, "avg_price": 10.0}}) save_balance(db, aid, "2026-07-17 10:00:00", 5e5, 1e5, 6e5) trades = c.get(f"/api/v1/live/{aid}/trades", headers=_auth(token)).json() assert len(trades) == 1 assert trades[0]["price"] == 10.0 pos = c.get(f"/api/v1/live/{aid}/positions", headers=_auth(token)).json() assert len(pos) == 1 and pos[0]["symbol"] == "600000.SSE" acc = c.get(f"/api/v1/live/{aid}/account", headers=_auth(token)).json() assert acc["total"] == 6e5 # 列表汇总:有 balance 后 total_return 应非 None lst = c.get("/api/v1/live", headers=_auth(token)).json() item = lst["accounts"][0] assert item["latest_equity"] == 6e5 assert item["total_return"] == 0.0 # 6e5 == 初始 6e5 assert item["position_count"] == 1