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]
This commit is contained in:
@@ -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/replay(paper_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 {})
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
"""模拟盘列表(启用聚宽级模拟交易列表页)。每行带最新净值 + 收益率。"""
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user