Files
sanguo_vnpy_v2/tests/test_live_api.py
T
claude_dev 96b1924fd5 feat: 实盘模拟(live) + 组合回测MVP(portfolio)
[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端):
- sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL)
- sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status)
- frontend live: New/List/Monitor + api/live.ts; config/live.yaml

[portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证):
- runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import)
- sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑
- frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线
- 路由/菜单注册(/backtest/portfolio 组合回测)
- 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
2026-07-18 20:04:16 +08:00

371 lines
14 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 + 持久化单测(task #4)。
两层:
(1) persistence CRUD —— sqlite tmp,纯 PythonMac 跑通;
(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
# =============================================================================
# 层 1persistence 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
# =============================================================================
# 层 2routes_live APITestClient,不依赖 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