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 起服务联调
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"""实盘模拟 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
|
||||
Reference in New Issue
Block a user