"""策略实例 JSON 存储(配置数据,几行;无需 DB 迁移)。 实例 = 代码文件的参数变体(spec §12 三层模型中层)= 策略档案(§12.6 做实设计)。 status/last_return:事件型运行(回测/回放)完成时落盘回写;持续型运行 (实走/影子/实盘)由 API 读时聚合覆盖(见 routes_strategy.enrich_instances)。 """ from __future__ import annotations import json import os import time _DEFAULT_STATUS = {"backtest": "-", "replay": "-", "paper_live": "-", "live": "-"} # 路径:env 覆盖 > 仓库根 data/strategy_instances.json。测试用 monkeypatch 改 _STORE_PATH。 _STORE_PATH: str = os.environ.get( "SANGUO_INSTANCE_STORE", os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "strategy_instances.json", ), ) def _load() -> list[dict]: if not os.path.exists(_STORE_PATH): return [] with open(_STORE_PATH, encoding="utf-8") as f: try: return json.load(f) except json.JSONDecodeError: return [] def _save(rows: list[dict]) -> None: os.makedirs(os.path.dirname(_STORE_PATH), exist_ok=True) with open(_STORE_PATH, "w", encoding="utf-8") as f: json.dump(rows, f, ensure_ascii=False, indent=2) def list_instances() -> dict: return {"instances": _load()} def get_instance(inst_id: int) -> dict: for r in _load(): if r.get("id") == inst_id: return {"instance": r} return {"instance": None} def create_instance(payload: dict) -> int: rows = _load() new_id = (max((r.get("id", 0) for r in rows), default=0) + 1) if rows else 1 row = { "id": new_id, "code_file": payload.get("code_file", ""), "name": payload.get("name", ""), "type": payload.get("type", "cta"), "params": payload.get("params", {}), "symbol_or_pool": payload.get("symbol_or_pool", ""), "interval": payload.get("interval", "d"), "match_session": payload.get("match_session", "next_open"), "status": dict(_DEFAULT_STATUS), "last_return": None, "updated_at": time.strftime("%Y-%m-%d"), } rows.append(row) _save(rows) return new_id def update_instance(inst_id: int, payload: dict) -> None: rows = _load() for r in rows: if r.get("id") == inst_id: for k in ("code_file", "name", "type", "params", "symbol_or_pool", "interval", "match_session"): if k in payload: r[k] = payload[k] r["updated_at"] = time.strftime("%Y-%m-%d") _save(rows) return def delete_instance(inst_id: int) -> bool: rows = _load() new_rows = [r for r in rows if r.get("id") != inst_id] if len(new_rows) == len(rows): 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, meta: dict | None = None) -> bool: """事件型运行(回测/回放)完成/失败时回写档案。 kind: backtest/replay(paper_live/live 是持续运行,读时聚合不落盘)。 ret: 小数收益(0.1548=+15.48%),None 则保留旧值。 meta: 附加信息(如 {"code_hash": ...} 发起时代码版本)。 返回 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 if meta: r.setdefault("run_meta", {}) r["run_meta"].setdefault(kind, {}) r["run_meta"][kind].update(meta) 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):账户参数 vs 档案当前参数。 account 兼容两种行: - paper_accounts 行(strategies=[{params}],JSON 字符串或已解析) - live_accounts 行(setting={params},JSON 字符串或已解析 dict) 只比对参数键值。 """ import json as _json raw = account.get("strategies") if raw is None and "setting" in account: s = account["setting"] if isinstance(s, str): try: s = _json.loads(s) except (ValueError, TypeError): return False strats = [{"params": s}] if isinstance(s, dict) else [] elif 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 {})