337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""模拟盘 API 路由(spec §10)。
|
||
|
||
create 建 paper_account(持久化配置);GET 查询净值/成交/状态。
|
||
回放执行(engine.run)由 orchestrator 异步触发或容器内同步跑,端到端冒烟在容器
|
||
(本机无 NAS parquet + vnpy 完整依赖),本模块只做 account 管理 + 查询。
|
||
"""
|
||
import json
|
||
import sqlite3
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from .auth import verify_token as verify_token_impl
|
||
|
||
router = APIRouter()
|
||
_db_path = {"path": None}
|
||
|
||
|
||
def set_db_path(p):
|
||
_db_path["path"] = p
|
||
if p:
|
||
from sanguo_trader.persistence import init_db
|
||
init_db(p) # app 启动建表(幂等),保证 GET 查询不报 no such table
|
||
|
||
|
||
async def verify_token(authorization: str | None = Header(None)):
|
||
if authorization is None or not authorization.startswith("Bearer "):
|
||
raise HTTPException(401, "Missing/invalid authorization")
|
||
return verify_token_impl(authorization.split(" ", 1)[1])
|
||
|
||
|
||
class StrategyCfg(BaseModel):
|
||
name: str
|
||
params: dict = {}
|
||
match_session: str = "next_open"
|
||
symbol: str
|
||
listing_days: int = 0
|
||
max_allocation: float | None = None # 软限额(spec §195),None=用 initial_capital
|
||
|
||
|
||
class PaperCreateRequest(BaseModel):
|
||
name: str = "paper"
|
||
mode: str = "replay"
|
||
interval: str = "d"
|
||
symbols: list[str]
|
||
strategies: list[StrategyCfg]
|
||
initial_capital: float = 1_000_000
|
||
rate: float = 0.0003
|
||
slippage: float = 0.0
|
||
pricetick: float = 0.01
|
||
stamp_duty_rate: float = 0.0005
|
||
transfer_fee_rate: float = 0.00001
|
||
min_commission: float = 5.0
|
||
start: str
|
||
end: str
|
||
# 组合策略实走(E1):strategy_type=portfolio 时 mode 必须 live,
|
||
# strategies[0].name=组合策略名,pool/max_pool/benchmark 进 params
|
||
strategy_type: str = "cta"
|
||
pool: str = "hs300_subset"
|
||
max_pool: int = 30
|
||
benchmark: str = "000300.XSHG"
|
||
|
||
|
||
@router.post("/paper/create", dependencies=[Depends(verify_token)])
|
||
def create_paper(req: PaperCreateRequest):
|
||
from sanguo_trader.persistence import init_db, save_account
|
||
import threading
|
||
|
||
db = _db_path["path"] or ":memory:"
|
||
init_db(db)
|
||
if req.strategy_type == "portfolio":
|
||
if req.mode != "live":
|
||
raise HTTPException(400, "组合策略模拟盘仅支持实走(live)模式;历史回放请用「组合回测」")
|
||
payload = req.model_dump()
|
||
payload["symbols"] = [req.pool]
|
||
payload["strategies"] = [{
|
||
"name": (req.strategies[0].name if req.strategies else "all_weather"),
|
||
"params": {"max_pool": req.max_pool, "benchmark": req.benchmark},
|
||
}]
|
||
aid = save_account(db, payload)
|
||
from sanguo_trader.persistence import update_account_status
|
||
update_account_status(db, aid, "running")
|
||
return {"account_id": aid, "status": "running"}
|
||
|
||
aid = save_account(db, req.model_dump())
|
||
status = "created"
|
||
if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502)
|
||
def _bg():
|
||
from sanguo_trader.persistence import update_account_status
|
||
try:
|
||
_run_replay(db, aid, req)
|
||
update_account_status(db, aid, "done")
|
||
except Exception as e: # noqa: BLE001
|
||
update_account_status(db, aid, "failed", str(e))
|
||
threading.Thread(target=_bg, daemon=True).start()
|
||
status = "running"
|
||
elif req.mode == "live": # 实走:全局 job 每日 20:30 遍历 step(不跑回放)
|
||
from sanguo_trader.persistence import update_account_status
|
||
update_account_status(db, aid, "running")
|
||
status = "running"
|
||
return {"account_id": aid, "status": status}
|
||
|
||
|
||
@router.get("/paper", dependencies=[Depends(verify_token)])
|
||
def list_papers():
|
||
"""模拟盘列表(启用聚宽级模拟交易列表页)。每行带最新净值 + 收益率。"""
|
||
from sanguo_trader.persistence import load_last_balance
|
||
|
||
db = _db_path["path"]
|
||
with sqlite3.connect(db) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute(
|
||
"SELECT * FROM paper_accounts ORDER BY id DESC"
|
||
).fetchall()
|
||
out = []
|
||
for r in rows:
|
||
item = dict(r)
|
||
cap = item.get("initial_capital") or 0
|
||
last = load_last_balance(db, item["id"])
|
||
if last and cap:
|
||
item["latest_equity"] = last.get("total_equity")
|
||
item["latest_date"] = last.get("date")
|
||
item["total_return"] = (last.get("total_equity", 0) - cap) / cap
|
||
else:
|
||
item["latest_equity"] = None
|
||
item["latest_date"] = None
|
||
item["total_return"] = None
|
||
out.append(item)
|
||
return {"accounts": out}
|
||
|
||
|
||
@router.get("/paper/{aid}", dependencies=[Depends(verify_token)])
|
||
def get_paper(aid: int):
|
||
db = _db_path["path"]
|
||
with sqlite3.connect(db) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
row = conn.execute(
|
||
"SELECT * FROM paper_accounts WHERE id=?", (aid,)
|
||
).fetchone()
|
||
if not row:
|
||
raise HTTPException(404, "account not found")
|
||
return dict(row)
|
||
|
||
|
||
@router.get("/paper/{aid}/equity", dependencies=[Depends(verify_token)])
|
||
def get_equity(aid: int):
|
||
from sanguo_trader.persistence import list_daily_balance
|
||
|
||
return list_daily_balance(_db_path["path"], aid)
|
||
|
||
|
||
@router.get("/paper/{aid}/trades", dependencies=[Depends(verify_token)])
|
||
def get_trades(aid: int):
|
||
from sanguo_trader.persistence import list_trades
|
||
|
||
return list_trades(_db_path["path"], aid)
|
||
|
||
|
||
@router.get("/paper/{aid}/strategies", dependencies=[Depends(verify_token)])
|
||
def get_strategies(aid: int):
|
||
"""分策略归因:成交/拒单/费用聚合(spec §7)。"""
|
||
from sanguo_trader.persistence import list_strategy_summary
|
||
|
||
return list_strategy_summary(_db_path["path"], aid)
|
||
|
||
|
||
@router.get("/paper/{aid}/positions", dependencies=[Depends(verify_token)])
|
||
def get_positions(aid: int):
|
||
"""当前持仓快照(实走监控,Phase 3c)。{symbol:{volume,frozen,avg_price}} → list。"""
|
||
from sanguo_trader.persistence import load_positions
|
||
|
||
pos = load_positions(_db_path["path"], aid, "account")
|
||
return [
|
||
{"symbol": sym, "volume": p["volume"], "frozen": p.get("frozen", 0),
|
||
"avg_price": p["avg_price"]}
|
||
for sym, p in pos.items()
|
||
]
|
||
|
||
|
||
@router.get("/paper/{aid}/pending", dependencies=[Depends(verify_token)])
|
||
def get_pending(aid: int):
|
||
"""跨日 pending 订单(实走监控,Phase 3c)。"""
|
||
from sanguo_trader.persistence import load_pending_orders
|
||
|
||
return load_pending_orders(_db_path["path"], aid)
|
||
|
||
|
||
# ===== 生命周期管理(spec §10 补全:停止/恢复/删除/编辑)=====
|
||
|
||
def _get_account_row(db, aid: int) -> dict:
|
||
with sqlite3.connect(db) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (aid,)).fetchone()
|
||
if not row:
|
||
raise HTTPException(404, "account not found")
|
||
return dict(row)
|
||
|
||
|
||
@router.post("/paper/{aid}/stop", dependencies=[Depends(verify_token)])
|
||
def stop_paper(aid: int):
|
||
"""停止实走:置 status=stopped,每日 20:30 全局 step 只选 running → 自动跳过。"""
|
||
from sanguo_trader.persistence import update_account_status
|
||
|
||
db = _db_path["path"]
|
||
acc = _get_account_row(db, aid)
|
||
if acc.get("mode") != "live":
|
||
raise HTTPException(400, "仅实走(live)账户支持停止;回放账户为一次性任务")
|
||
update_account_status(db, aid, "stopped")
|
||
return {"account_id": aid, "status": "stopped"}
|
||
|
||
|
||
@router.post("/paper/{aid}/resume", dependencies=[Depends(verify_token)])
|
||
def resume_paper(aid: int):
|
||
"""恢复实走:次日 20:30 起继续 step。"""
|
||
from sanguo_trader.persistence import update_account_status
|
||
|
||
db = _db_path["path"]
|
||
acc = _get_account_row(db, aid)
|
||
if acc.get("mode") != "live":
|
||
raise HTTPException(400, "仅实走(live)账户支持恢复")
|
||
update_account_status(db, aid, "running")
|
||
return {"account_id": aid, "status": "running"}
|
||
|
||
|
||
@router.delete("/paper/{aid}", dependencies=[Depends(verify_token)])
|
||
def delete_paper(aid: int):
|
||
"""删除模拟盘账户及其全部数据(净值/成交/持仓/挂单,不可恢复)。"""
|
||
db = _db_path["path"]
|
||
_get_account_row(db, aid)
|
||
tables = ("paper_accounts", "paper_daily_balance", "paper_trades",
|
||
"paper_positions", "paper_pending_orders", "paper_shadow_orders")
|
||
with sqlite3.connect(db) as conn:
|
||
for t in tables:
|
||
if t == "paper_accounts":
|
||
conn.execute("DELETE FROM paper_accounts WHERE id=?", (aid,))
|
||
else:
|
||
conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,))
|
||
conn.commit()
|
||
return {"account_id": aid, "deleted": True}
|
||
|
||
|
||
class PaperUpdateRequest(BaseModel):
|
||
"""可编辑字段(其余字段沿用原值;策略参数/标的改动自下次 step 生效)。"""
|
||
name: str | None = None
|
||
symbols: list[str] | None = None
|
||
strategies: list[StrategyCfg] | None = None
|
||
initial_capital: float | None = None
|
||
|
||
|
||
@router.put("/paper/{aid}", dependencies=[Depends(verify_token)])
|
||
def update_paper(aid: int, req: PaperUpdateRequest):
|
||
db = _db_path["path"]
|
||
_get_account_row(db, aid)
|
||
sets, args = [], []
|
||
if req.name is not None:
|
||
sets.append("name=?"); args.append(req.name)
|
||
if req.symbols is not None:
|
||
sets.append("symbols=?"); args.append(json.dumps(req.symbols))
|
||
if req.strategies is not None:
|
||
sets.append("strategies=?")
|
||
args.append(json.dumps([s.model_dump() for s in req.strategies]))
|
||
if req.initial_capital is not None:
|
||
sets.append("initial_capital=?"); args.append(req.initial_capital)
|
||
if not sets:
|
||
return {"account_id": aid, "updated": False}
|
||
with sqlite3.connect(db) as conn:
|
||
conn.execute(f"UPDATE paper_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid))
|
||
conn.commit()
|
||
return {"account_id": aid, "updated": True}
|
||
|
||
|
||
class _DataSourceWrapper:
|
||
"""包装 iter_bars/fetch_day 给 PaperEngine/live_orchestrator。"""
|
||
|
||
def __init__(self, cfg):
|
||
self.cfg = cfg
|
||
|
||
def iter_bars(self, symbols, start, end, interval, adjust="qfq", cfg=None):
|
||
from sanguo_trader.data_source import iter_bars
|
||
|
||
return iter_bars(symbols, start, end, interval, adjust, cfg or self.cfg)
|
||
|
||
def fetch_day(self, symbol, date, interval, adjust="qfq", cfg=None):
|
||
from sanguo_trader.data_source import fetch_day
|
||
|
||
return fetch_day(symbol, date, interval, adjust, cfg or self.cfg)
|
||
|
||
|
||
def _run_replay(db, aid, req: PaperCreateRequest):
|
||
"""构造引擎 + 跑回放(容器内有 vnpy_ctastrategy + NAS parquet,本机仅空转)。"""
|
||
from sanguo_trader.account import Account
|
||
from sanguo_trader.cta_adapter import PaperCtaEngine
|
||
from sanguo_trader.engine import PaperEngine
|
||
from sanguo_trader.models import AccountConfig
|
||
from sanguo_trader.strategy_runner import StrategyRunner
|
||
from sanguo_data.config import find_config_path, load_config
|
||
from sanguo_data.datareader import guess_exchange
|
||
from .strategy_registry import get_strategy_class
|
||
|
||
data_cfg = load_config(find_config_path())
|
||
acc_cfg = AccountConfig(
|
||
initial_capital=req.initial_capital, rate=req.rate, slippage=req.slippage,
|
||
pricetick=req.pricetick, stamp_duty_rate=req.stamp_duty_rate,
|
||
transfer_fee_rate=req.transfer_fee_rate, min_commission=req.min_commission,
|
||
)
|
||
account = Account(req.initial_capital)
|
||
runners: list = []
|
||
for s in req.strategies:
|
||
cls = get_strategy_class(s.name)
|
||
if cls is None:
|
||
continue # 策略不可用(本机无 vnpy_ctastrategy)→ 跳过
|
||
from sanguo_trader.limit import lot_size_for
|
||
cta = PaperCtaEngine(s.name, match_session=s.match_session,
|
||
listing_days=s.listing_days,
|
||
size=lot_size_for(s.symbol)) # 主板 100 / 科创 200 股一手
|
||
vt_symbol = f"{s.symbol}.{guess_exchange(s.symbol).value}"
|
||
strat = cls(cta, s.name, vt_symbol, s.params) # CtaTemplate(cta_engine, name, vt_symbol, setting)
|
||
strat.trading = True # 允许 send_order(等价 on_start)
|
||
try:
|
||
from vnpy.trader.utility import ArrayManager
|
||
if not hasattr(strat, "am"):
|
||
strat.am = ArrayManager(20) # 默认 100 根才 inited,短区间不够;用 20 兼容
|
||
except Exception:
|
||
pass
|
||
cta.set_strategy(strat)
|
||
runners.append(StrategyRunner(
|
||
s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol,
|
||
max_allocation=(s.max_allocation if s.max_allocation is not None
|
||
else req.initial_capital)))
|
||
from sanguo_data.dividend_source import build_dividend_calendar
|
||
div_calendar = build_dividend_calendar(req.symbols, req.start, req.end)
|
||
pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg,
|
||
db, aid, req.symbols, req.start, req.end, req.interval,
|
||
risk_free_rate=getattr(data_cfg, "risk_free_rate", 0.0),
|
||
dividends_by_date=div_calendar)
|
||
pe.run()
|