Files

337 lines
13 KiB
Python
Raw Permalink 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 路由(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()