feat(strategy): 策略实例做实P0+P1+策略库A+B混合布局(spec§12.6定稿)——实例=策略档案:①绑定:paper/live账户+回测任务加instance_id(paper_accounts/live_accounts ALTER迁移),发起即建档(无档案自动建),绑已有档案时D1发起快照(账户用档案参数复印件)②回写:事件型(回测_on_done/回放线程)落盘update_instance_run;持续型(实走/影子/实盘)读时聚合_instance_runtime(四格覆盖+在跑账户+漂移检测)③D2/D3同步:POST /paper/sync/{id}批量刷运行中模拟账户(实走+影子锁死一致),实盘不在线改参④D5删除保护409⑤P1全景:GET instances/{id}/overview(全部运行账户+净值尾部+合并持仓归因)收编挂起项「按实例归因持仓」⑥前端:策略库重做A+B混合(统计条+在跑巡检模式+左栏代码树中文主显/文件副行+档案区漂移角标/同步/全景;STRATEGY_LABELS抽共享常量),InstanceOverview抽屉(echarts净值对比+归因持仓表),模拟盘/实盘表单加实例档案下拉(选中预填+绑定,路由?instance=直进);mock层enriched/sync/overview(路由序enriched先于{id});+7绑定测试,921绿,build绿,dev浏览器验收过 [vps]
CI/CD / test (push) Successful in 14s
CI/CD / nas-deploy (push) Successful in 52s
CI/CD / nas-verify (push) Successful in 17s

This commit is contained in:
2026-08-15 22:39:05 +08:00
parent 5615b11640
commit 256820d36f
24 changed files with 2048 additions and 204 deletions
+70 -2
View File
@@ -1,7 +1,8 @@
"""策略实例 JSON 存储(配置数据,几行;无需 DB 迁移)。
实例 = 代码文件的参数变体(spec §12 三层模型中层)。
运行态字段 status/last_return 给默认占位,由后续运行关联回填。
实例 = 代码文件的参数变体(spec §12 三层模型中层)= 策略档案(§12.6 做实设计)
status/last_return:事件型运行(回测/回放)完成时落盘回写;持续型运行
(实走/影子/实盘)由 API 读时聚合覆盖(见 routes_strategy.enrich_instances)。
"""
from __future__ import annotations
import json
@@ -87,3 +88,70 @@ def delete_instance(inst_id: int) -> bool:
return False
_save(new_rows)
return True
# ===== §12.6 做实:事件型运行回写 + 参数漂移(§12.6 P0=====
_KINDS = ("backtest", "replay")
def update_instance_run(inst_id: int, kind: str, status: str,
ret: float | None = None) -> bool:
"""事件型运行(回测/回放)完成/失败时回写档案。
kind: backtest/replaypaper_live/live 是持续运行,读时聚合不落盘)。
ret: 小数收益(0.1548=+15.48%),None 则保留旧值。
返回 False = 实例不存在(如已删,静默丢弃)。
"""
if kind not in _KINDS:
return False
rows = _load()
for r in rows:
if r.get("id") == inst_id:
r.setdefault("status", dict(_DEFAULT_STATUS))
r["status"][kind] = status
if ret is not None:
r["last_return"] = ret
r.setdefault("run_returns", {})
r["run_returns"][kind] = ret
r["updated_at"] = time.strftime("%Y-%m-%d")
_save(rows)
return True
return False
def get_instance_params_snapshot(inst_id: int) -> dict | None:
"""档案当前参数(发起时快照用,D1)。不存在返回 None。"""
for r in _load():
if r.get("id") == inst_id:
return {
"params": dict(r.get("params") or {}),
"symbol_or_pool": r.get("symbol_or_pool", ""),
"interval": r.get("interval", "d"),
"match_session": r.get("match_session", "next_open"),
"code_file": r.get("code_file", ""),
"type": r.get("type", "cta"),
"name": r.get("name", ""),
}
return None
def account_params_drifted(snapshot_params: dict, account: dict) -> bool:
"""参数漂移检测(D2):账户 strategies 参数 vs 档案当前参数。
account 为 paper_accounts 行(strategies 是 JSON 字符串或已解析 list)。
只比对参数键值(标的/周期改了也算漂移——都影响行为)。
"""
import json as _json
raw = account.get("strategies")
if isinstance(raw, str):
try:
strats = _json.loads(raw)
except (ValueError, TypeError):
return False
else:
strats = raw or []
if not strats:
return False
live_params = dict(strats[0].get("params") or {})
return live_params != dict(snapshot_params.get("params") or {})
+1
View File
@@ -111,6 +111,7 @@ async def submit_cta(req: CtaBacktestRequest):
capital=req.capital,
position_pct=req.position_pct,
interval=req.interval,
instance_id=req.instance_id,
)
return {"task_id": tid}
+30
View File
@@ -9,6 +9,7 @@ GET 查询持仓/成交/账户/状态。runner(supervisor) 是独立常驻进程
from __future__ import annotations
import os
import time
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
@@ -53,6 +54,21 @@ class LiveCreateRequest(BaseModel):
pool: str = ""
max_pool: int = 0
benchmark: str = ""
# §12.6 实例做实:账户绑档案;空=发起即建档
instance_id: int | None = None
def _resolve_file_by_class(class_name: str) -> str:
"""类名/策略名 → 策略文件名(发起即建档反查 code_file;查不到返回空)。"""
try:
from .strategy_registry import list_strategy_files
for f in list_strategy_files()["files"]:
if f.get("class_name") == class_name or f.get("name", "").removesuffix(".py") == class_name:
return f.get("name", "")
except Exception:
pass
return ""
def _normalize_vt_symbol(code: str) -> str:
@@ -74,6 +90,20 @@ def create_live(req: LiveCreateRequest):
db = _db_path["path"] or ":memory:"
init_db(db)
# §12.6 发起绑档案(合法 instance_id 用之;否则发起即建档)
from . import instance_store
if not (req.instance_id and instance_store.get_instance_params_snapshot(req.instance_id)):
sym = req.pool if req.strategy_type == "portfolio" else req.vt_symbol
req.instance_id = instance_store.create_instance({
"code_file": _resolve_file_by_class(req.strategy_class),
"name": f"{req.strategy_class}·live·{time.strftime('%m%d')}",
"type": req.strategy_type,
"params": dict(req.setting or {}),
"symbol_or_pool": sym,
"interval": req.interval or "d",
"match_session": "next_open",
})
payload = req.model_dump()
if payload.get("strategy_type") == "portfolio":
# 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env)
+93
View File
@@ -6,11 +6,13 @@ create 建 paper_account(持久化配置);GET 查询净值/成交/状态
"""
import json
import sqlite3
import time
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import verify_token as verify_token_impl
from . import instance_store
from .validation import validate_backtest_range
router = APIRouter()
@@ -62,6 +64,41 @@ class PaperCreateRequest(BaseModel):
pool: str = "hs300_subset"
max_pool: int = 30
benchmark: str = "000300.XSHG"
# §12.6 实例做实:账户绑档案;空=发起即建档(自动创建实例再发起)
instance_id: int | None = None
def _resolve_file_by_class(class_name: str) -> str:
"""类名 → 策略文件名(发起即建档时反查 code_file;查不到返回空)。"""
try:
from .strategy_registry import list_strategy_files
for f in list_strategy_files()["files"]:
if f.get("class_name") == class_name:
return f.get("name", "")
except Exception:
pass
return ""
def _ensure_instance_for_paper(req: PaperCreateRequest) -> int:
"""§12.6 D1/D5:发起时绑档案。带合法 instance_id 用之;否则发起即建档。"""
from . import instance_store
if req.instance_id and instance_store.get_instance_params_snapshot(req.instance_id):
return req.instance_id
strat = req.strategies[0] if req.strategies else None
cls = strat.name if strat else ""
sym = req.pool if req.strategy_type == "portfolio" else ",".join(req.symbols)
return instance_store.create_instance({
"code_file": _resolve_file_by_class(cls),
"name": f"{cls}·{req.mode}·{time.strftime('%m%d')}",
"type": req.strategy_type,
"params": dict(strat.params) if strat else {},
"symbol_or_pool": sym,
"interval": req.interval,
"match_session": strat.match_session if strat else "next_open",
})
@router.post("/paper/create", dependencies=[Depends(verify_token)])
@@ -71,6 +108,13 @@ def create_paper(req: PaperCreateRequest):
db = _db_path["path"] or ":memory:"
init_db(db)
# 实例绑定先于日期归一(model_dump 要带上 instance_id 落库)
req.instance_id = _ensure_instance_for_paper(req)
# §12.6 D1 发起时快照:绑已有档案 → 账户参数用档案当时的参数(复印件),
# 后续改档案不影响本账户(漂移可见,手动同步)
snap = instance_store.get_instance_params_snapshot(req.instance_id) if req.instance_id else None
if snap and req.strategies:
req.strategies[0].params = dict(snap.get("params") or {})
# 实走/影子是开放账户:起止日期无意义,开始=创建当天(组合日终重放依赖 start_date,
# 空值会崩),结束留空;仅回放保留用户填的历史区间
if req.mode in ("live", "shadow"):
@@ -103,11 +147,22 @@ def create_paper(req: PaperCreateRequest):
if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502)
def _bg():
from sanguo_trader.persistence import update_account_status
from .instance_store import update_instance_run
try:
_run_replay(db, aid, req)
update_account_status(db, aid, "done")
# §12.6 回放完成回写档案(收益=末次净值/初始-1)
if req.instance_id:
from sanguo_trader.persistence import load_last_balance
last = load_last_balance(db, aid)
ret = None
if last and req.initial_capital:
ret = (last.get("total_equity", 0) - req.initial_capital) / req.initial_capital
update_instance_run(req.instance_id, "replay", "done", ret)
except Exception as e: # noqa: BLE001
update_account_status(db, aid, "failed", str(e))
if req.instance_id:
update_instance_run(req.instance_id, "replay", "failed")
threading.Thread(target=_bg, daemon=True).start()
status = "running"
elif req.mode in ("live", "shadow"): # 实走:每日 20:30 step;影子:等 VPS 影子柜台进程接管
@@ -117,6 +172,44 @@ def create_paper(req: PaperCreateRequest):
return {"account_id": aid, "status": status}
@router.post("/paper/sync/{instance_id}", dependencies=[Depends(verify_token)])
def sync_instance_params(instance_id: int):
"""§12.6 D2/D3 参数同步:档案当前参数 → 该档案全部运行中模拟账户。
实走+影子一起换影子与实盘/对照账户参数必须锁死否则双轨对账失效
实盘不在此列D2实盘不提供在线改参停了重发
注意仅改 params在跑进程的内存参数于当日结算/重启后生效快照在结算时重读
"""
from . import instance_store
snap = instance_store.get_instance_params_snapshot(instance_id)
if snap is None:
raise HTTPException(404, "实例不存在")
db = _db_path["path"] or ":memory:"
synced = 0
with sqlite3.connect(db) as conn:
rows = conn.execute(
"SELECT id, strategies FROM paper_accounts "
"WHERE instance_id=? AND status='running'",
(instance_id,),
).fetchall()
for aid, raw in rows:
try:
strats = json.loads(raw) if isinstance(raw, str) else (raw or [])
except (ValueError, TypeError):
continue
if not strats:
continue
strats[0]["params"] = dict(snap["params"])
conn.execute(
"UPDATE paper_accounts SET strategies=?, updated_at=? WHERE id=?",
(json.dumps(strats, ensure_ascii=False), time.strftime("%Y-%m-%d %H:%M:%S"), aid),
)
synced += 1
conn.commit()
return {"synced": synced}
@router.get("/paper", dependencies=[Depends(verify_token)])
def list_papers():
"""模拟盘列表(启用聚宽级模拟交易列表页)。每行带最新净值 + 收益率。"""
+2
View File
@@ -45,6 +45,7 @@ class PortfolioBacktestRequest(BaseModel):
min_commission: float = Field(default=5.0, description="单笔最低佣金(元)")
slippage: float = Field(default=0.0, description="滑点比率(万10=0.001,0=不加)")
interval: str = Field(default="d", description="K线周期:组合回放暂仅日线(d)")
instance_id: int | None = Field(default=None, description="§12.6 可选关联策略档案(带了则结果回写)")
@router.post("/portfolio/backtest", dependencies=[Depends(verify_token)])
@@ -66,6 +67,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest):
min_commission=req.min_commission,
slippage=req.slippage,
interval=req.interval,
instance_id=req.instance_id,
)
return {"task_id": tid}
+189
View File
@@ -81,6 +81,32 @@ def post_instance(req: InstancePayload):
return {"id": new_id}
@router.get("/strategy/instances/enriched")
def get_instances_enriched():
"""实例列表 + 读时聚合:持续运行覆盖四格的实走/实盘格 + 在跑账户 + 漂移标记。
注意必须注册在 /strategy/instances/{inst_id} 之前否则 enriched 被当 inst_id 解析
"""
rt = _instance_runtime()
out = []
for inst in instance_store.list_instances()["instances"]:
ent = rt.get(inst["id"], {"running_accounts": [], "drift": False})
paper_runs = [a for a in ent["running_accounts"] if a["kind"] in ("paper", "shadow")]
live_runs = [a for a in ent["running_accounts"] if a["kind"] == "live"]
status = dict(inst.get("status") or {})
if paper_runs:
status["paper_live"] = "running"
if live_runs:
status["live"] = "running"
out.append({
**inst,
"status": status,
"running_accounts": ent["running_accounts"],
"drift": ent["drift"],
})
return {"instances": out}
@router.get("/strategy/instances/{inst_id}")
def get_instance(inst_id: int):
return instance_store.get_instance(inst_id)
@@ -94,7 +120,170 @@ def put_instance(inst_id: int, req: InstancePayload):
@router.delete("/strategy/instances/{inst_id}")
def del_instance(inst_id: int):
# §12.6 D5 删除保护:有运行中账户(模拟/实盘)的档案禁止删
running = _instance_runtime().get(inst_id, {}).get("running_accounts", [])
if running:
names = "".join(a["label"] for a in running[:3])
raise HTTPException(
status_code=409,
detail=f"该档案下有运行中的账户({names}…),请先停止再删除",
)
ok = instance_store.delete_instance(inst_id)
if not ok:
raise HTTPException(status_code=404, detail="实例不存在")
return {"ok": True}
# ===== §12.6 做实:读时聚合(持续型运行)+ P1 实例全景 =====
def _instance_runtime() -> dict[int, dict]:
"""读时聚合每个档案的模拟/实盘账户(持续型运行的"回写")。
返回 {instance_id: {running_accounts: [...], drift: bool}}DB 不可达返回 {}
策略库退化为纯 JSON 状态展示不阻塞
"""
import json as _json
import sqlite3
out: dict[int, dict] = {}
try:
from .routes_paper import _db_path as paper_db
pdb = paper_db["path"]
if pdb:
with sqlite3.connect(pdb) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT id, name, mode, engine, status, strategies, instance_id, "
"initial_capital FROM paper_accounts WHERE instance_id IS NOT NULL"
).fetchall()
from sanguo_trader.persistence import load_last_balance
for r in rows:
ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False})
ret = None
last = load_last_balance(pdb, r["id"])
if last and r["initial_capital"]:
ret = (last.get("total_equity", 0) - r["initial_capital"]) / r["initial_capital"]
snapshot = instance_store.get_instance_params_snapshot(r["instance_id"])
drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(r)))
if drifted:
ent["drift"] = True
if r["status"] == "running":
ent["running_accounts"].append({
"kind": "shadow" if r["engine"] == "shadow" else "paper",
"aid": r["id"],
"label": r["name"] or f"paper#{r['id']}",
"ret": ret,
"drifted": drifted,
})
except Exception:
pass
try:
from .routes_live import _db_path as live_db
from sanguo_live.persistence import get_first_balance, get_last_balance, list_accounts
ldb = live_db["path"]
if ldb:
for a in list_accounts(ldb):
iid = a.get("instance_id")
if not iid:
continue
ent = out.setdefault(iid, {"running_accounts": [], "drift": False})
ret = None
first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"])
if first and last and first.get("total"):
ret = (last["total"] - first["total"]) / first["total"]
if a.get("status") == "running":
ent["running_accounts"].append({
"kind": "live",
"aid": a["id"],
"label": a.get("name") or f"live#{a['id']}",
"ret": ret,
"drifted": False,
})
except Exception:
pass
return out
@router.get("/strategy/instances/{inst_id}/overview")
def get_instance_overview(inst_id: int):
"""P1 实例全景:档案 + 全部运行账户(含净值尾部)+ 合并持仓归因。"""
import sqlite3
snap = instance_store.get_instance_params_snapshot(inst_id)
if snap is None:
raise HTTPException(status_code=404, detail="实例不存在")
runs: list[dict] = []
positions: dict[str, dict] = {}
def _absorb(symbol: str, volume: float, avg: float, label: str) -> None:
p = positions.setdefault(symbol, {"symbol": symbol, "volume": 0.0,
"avg_price": avg, "accounts": []})
p["volume"] += volume
p["accounts"].append({"label": label, "volume": volume})
try:
from .routes_paper import _db_path as paper_db
pdb = paper_db["path"]
if pdb:
from sanguo_trader.persistence import load_last_balance, load_positions
with sqlite3.connect(pdb) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM paper_accounts WHERE instance_id=? ORDER BY id", (inst_id,)
).fetchall()
for r in rows:
last = load_last_balance(pdb, r["id"])
ret = None
if last and r["initial_capital"]:
ret = (last["total_equity"] - r["initial_capital"]) / r["initial_capital"]
with sqlite3.connect(pdb) as conn:
curve = conn.execute(
"SELECT date, total_equity FROM paper_daily_balance "
"WHERE account_id=? ORDER BY date DESC LIMIT 120",
(r["id"],),
).fetchall()
runs.append({
"kind": "shadow" if r["engine"] == "shadow" else "paper",
"aid": r["id"], "label": r["name"], "mode": r["mode"],
"status": r["status"], "ret": ret,
"equity": [{"date": c[0], "equity": c[1]} for c in reversed(curve)],
})
if r["status"] == "running":
scope = "shadow" if r["engine"] == "shadow" else r["mode"]
for sym, p in load_positions(pdb, r["id"], scope).items():
_absorb(sym, p.get("volume", 0), p.get("avg_price", 0),
r["name"] or f"paper#{r['id']}")
except Exception:
pass
try:
from .routes_live import _db_path as live_db
ldb = live_db["path"]
if ldb:
from sanguo_live.persistence import (
get_first_balance, get_last_balance, list_accounts, load_positions,
)
for a in list_accounts(ldb):
if a.get("instance_id") != inst_id:
continue
first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"])
ret = None
if first and last and first.get("total"):
ret = (last["total"] - first["total"]) / first["total"]
runs.append({
"kind": "live", "aid": a["id"], "label": a.get("name"),
"status": a.get("status"), "ret": ret, "equity": [],
})
if a.get("status") == "running":
for p in load_positions(ldb, a["id"]):
_absorb(p["symbol"], p.get("volume", 0), p.get("avg_price", 0),
a.get("name") or f"live#{a['id']}")
except Exception:
pass
return {"instance": snap, "runs": runs, "positions": list(positions.values())}
+2
View File
@@ -22,6 +22,8 @@ class CtaBacktestRequest(BaseModel):
stamp_duty_rate: float = 0.0005 # 卖方 0.05%
transfer_fee_rate: float = 0.00001 # 沪市 0.001%
slippage: float = 0.0 # 滑点(比率,万10=0.001);0=不加滑点
# §12.6 实例做实:可选关联档案(带了则结果回写档案四格)
instance_id: int | None = None
class OptimizeRequest(BaseModel):