Complete broker read model sync groundwork

This commit is contained in:
Codex
2026-05-08 08:09:00 +08:00
parent 9b85e3c9a7
commit e9b15e1528
31 changed files with 1629 additions and 69 deletions
+11
View File
@@ -132,6 +132,7 @@ def run_autonomous_cycle(env=None, runner=subprocess.run, db_probe=None, schema_
"schema": None,
"next_actions": [],
"action_plan": [],
"continuation_decision": "continue",
}
offline_env = dict(env)
@@ -371,6 +372,7 @@ def _parse_json(value: str):
def _finish(summary: dict, exit_code: int, output_json: bool) -> int:
summary["next_actions"] = build_cycle_next_actions(summary)
summary["action_plan"] = build_cycle_action_plan(summary["next_actions"])
summary["continuation_decision"] = classify_cycle_continuation(summary)
if output_json:
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
return exit_code
@@ -437,6 +439,15 @@ def build_cycle_next_actions(summary: dict) -> list[str]:
return ["continue_offline_automation"]
def classify_cycle_continuation(summary: dict) -> str:
mode = summary.get("mode")
if mode in {"waiting_for_database", "offline_checks_failed", "migration_failed", "backfill_failed", "pipeline_failed"}:
return "blocked"
if mode == "pipeline_completed":
return "advance"
return "continue"
def build_cycle_action_plan(next_actions: list[str]) -> list[dict]:
plan = []
for action in next_actions:
+14
View File
@@ -58,6 +58,8 @@ def build_backtest_mode_from_env(env=None) -> str:
def build_backtest_subject_type_from_env(env=None) -> str:
env = os.environ if env is None else env
if build_backtest_session_codes_from_env(env=env):
return "signal"
value = str(env.get("BACKTEST_SUBJECT_TYPE") or "").strip()
return value or BACKTEST_SUBJECT_PLAN_REPLAY
@@ -85,6 +87,14 @@ def build_replay_config_from_env(env=None) -> BacktestReplayConfig:
)
def build_backtest_session_codes_from_env(env=None) -> list[str]:
env = os.environ if env is None else env
raw = str(env.get("BACKTEST_SESSION_CODES") or "").strip()
if not raw:
return []
return [item.strip() for item in raw.split(",") if item.strip()]
def build_backtest_timeframe_set_from_env(env=None) -> dict:
env = os.environ if env is None else env
bias_tf = str(env.get("AI_ICT_BIAS_TIMEFRAME") or env.get("BACKTEST_BIAS_TIMEFRAME") or "1m")
@@ -111,6 +121,7 @@ def run_backtest_once(
candidate_model_code: Optional[str] = None,
replay_config: Optional[BacktestReplayConfig] = None,
timeframe_set: Optional[dict] = None,
session_codes: Optional[list[str]] = None,
) -> int:
run_id, outcomes = service.run_backtest(
instrument_id=instrument_id,
@@ -123,6 +134,7 @@ def run_backtest_once(
mode=backtest_mode,
replay_config=replay_config,
timeframe_set=timeframe_set,
session_codes=session_codes,
)
summary = summarize_backtest_outcomes(outcomes, subject_type=subject_type)
quality_gate_result = evaluate_backtest_quality(
@@ -138,6 +150,7 @@ def run_backtest_once(
"trading_plan_id": trading_plan_id,
"candidate_model_code": candidate_model_code,
"timeframe_set": timeframe_set,
"session_codes": session_codes,
"summary": summary,
"quality_gate": quality_gate_result,
"quality_gate_required": require_quality_gate,
@@ -172,6 +185,7 @@ def main(env=None) -> int:
candidate_model_code=build_backtest_candidate_model_code_from_env(env=env),
replay_config=build_replay_config_from_env(env=env),
timeframe_set=build_backtest_timeframe_set_from_env(env=env),
session_codes=build_backtest_session_codes_from_env(env=env),
)
except SQLAlchemyError as exc:
summary = {
+26
View File
@@ -79,6 +79,7 @@ def run_trading_delivery_rehearsal(env=None, runner=subprocess.run, cwd: Path =
"blockers": [],
"manual_decisions": [],
"promotion_decision": "hold",
"continuation_decision": "continue",
"policies": {
"reference_migration": (
"auto_apply"
@@ -501,10 +502,35 @@ def _finalize_trading_rehearsal(summary: dict) -> dict:
summary["passed"] = summary["passed"] and len(summary["blockers"]) == 0
if not summary["passed"]:
summary["promotion_decision"] = "hold"
summary["continuation_decision"] = classify_rehearsal_continuation(summary)
summary["finished_ts"] = datetime.now(timezone.utc).isoformat()
return summary
def classify_rehearsal_continuation(summary: dict) -> str:
mode = summary.get("mode")
if mode == "rehearsal_completed":
return "advance"
if mode in {
"reference_migration_deferred",
"reference_migration_conflicts_blocked",
"reference_migration_manual_apply_required",
"checklist_drift_failed",
"historical_repair_scan_failed",
"historical_repair_apply_failed",
"historical_repair_rescan_failed",
"historical_repair_safe_candidates_remain",
"historical_repair_manual_apply_required",
"autonomous_cycle_failed",
"deploy_or_health_failed",
"post_deploy_readiness_failed",
"post_deploy_reports_failed",
"reference_migration_apply_failed",
}:
return "blocked"
return "continue"
def main(env=None) -> int:
env = build_trading_rehearsal_env(env=env)
summary = run_trading_delivery_rehearsal(env=env)
+491
View File
@@ -0,0 +1,491 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import json
import os
import re
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.run_trading_delivery_rehearsal import run_trading_delivery_rehearsal
SESSION_PATH = ROOT / "SESSION.md"
TASKS_PATH = ROOT / "TASKS.md"
RUNNER_STATUS_PATH = ROOT / ".claude" / "automation_status.json"
RUNNER_RESUME_WATCH_PATHS = [
ROOT / "src",
ROOT / "scripts",
ROOT / ".env",
ROOT / "SESSION.md",
ROOT / "TASKS.md",
]
STAGE_SEQUENCE = ["P0-2", "P0-3", "P0-4", "P0-5", "P1-1", "P1-2", "P1-3"]
STAGE_TITLES = {
"P0-1": "实时市场真值层",
"P0-2": "真实账户与订单只读同步",
"P0-3": "盘中持续调度与运行监督",
"P0-4": "决策包与主动提醒",
"P0-5": "真实交易映射与盘后闭环",
"P1-1": "辅助可信度与模型白名单",
"P1-2": "操作体验与运营工具",
"P1-3": "live adapter 前准备",
}
STAGE_GOALS = {
"P0-2": [
"建立 `BrokerReadModelService`",
"读取真实账户余额 / 持仓 / 挂单 / 成交",
"扩展 `RiskContext`",
"建立 reconciliation 读模型",
"前端展示真实 `open position / pending order`",
"focused smoke: `/trading/broker-state` 与 `/trading/market-state` 返回 broker state",
],
"P0-3": [
"建立 `TradingRuntimeScheduler`",
"建立 `TradingRuntimeSupervisor`",
"提供 runtime health API",
"前端展示 runtime status",
"focused smoke: runtime 状态 API / workbench runtime status 可见",
],
"P0-4": [
"建立 `DecisionPacketService`",
"输出统一决策包字段",
"接入主动提醒通道",
"workbench 展示决策卡",
],
"P0-5": [
"建立 execution 与真实 order/fill 映射",
"真实持仓变化触发 journal entry",
"增强真实交易归因与周报闭环",
],
"P1-1": [
"建立 symbol / session / setup 表现统计",
"增加辅助可信度评分与白名单",
],
"P1-2": [
"补齐 workbench 快捷筛选、session 视图、alert history、operator notes",
"支持一键生成盘后 review packet",
],
"P1-3": [
"校准真实 order schema",
"补全 bracket / OCO reconciliation 语义",
"建立 live readiness checklist 与 operator confirmation protocol",
],
}
DEFAULT_VALIDATION_COMMANDS = ["npm run lint", "npm run build", "npm run visual:snapshots"]
BLOCKED_MARKERS = [
"缺凭据",
"权限不足",
"不可逆",
"网络/供应商限制",
"外部阻塞",
]
def run_until_blocked(env=None, runner=None, sleeper=None) -> dict:
env = dict(os.environ if env is None else env)
runner = runner or subprocess.run
output_json = _get_bool(env, "RUN_UNTIL_BLOCKED_OUTPUT_JSON", False)
max_rounds = max(_get_int(env, "RUN_UNTIL_BLOCKED_MAX_ROUNDS", 8), 1)
max_idle_rounds = max(_get_int(env, "RUN_UNTIL_BLOCKED_MAX_IDLE_ROUNDS", 1), 0)
completed_stages = parse_done_stages(TASKS_PATH.read_text(encoding="utf-8"))
current_stage = detect_current_stage(SESSION_PATH.read_text(encoding="utf-8"), completed_stages)
idle_rounds = 0
rounds = []
blocked_reason = None
started_ts = datetime.now(timezone.utc).isoformat()
write_runner_status(
{
"state": "running",
"started_ts": started_ts,
"heartbeat_ts": started_ts,
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": None,
"rounds_completed": 0,
}
)
try:
while current_stage is not None and len(rounds) < max_rounds:
write_runner_status(
{
"state": "running",
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason,
"rounds_completed": len(rounds),
}
)
round_env = dict(env)
round_env["RUN_UNTIL_BLOCKED_STAGE"] = current_stage
round_env["RUN_UNTIL_BLOCKED_STAGE_TITLE"] = STAGE_TITLES[current_stage]
summary = run_trading_delivery_rehearsal(env=round_env, runner=runner, sleeper=sleeper)
round_result = {
"round": len(rounds) + 1,
"stage": current_stage,
"stage_title": STAGE_TITLES[current_stage],
"continuation_decision": summary.get("continuation_decision", "continue"),
"mode": summary.get("mode"),
"passed": summary.get("passed", False),
"blockers": list(summary.get("blockers") or []),
"promotion_decision": summary.get("promotion_decision"),
}
rounds.append(round_result)
if summary.get("continuation_decision") == "advance":
completed_stages.append(current_stage)
next_stage = get_next_stage(current_stage, completed_stages)
write_anchor_files(
completed_stages=completed_stages,
current_stage=next_stage,
last_result=summary,
)
current_stage = next_stage
idle_rounds = 0
continue
blocked_reason = first_blocked_reason(summary)
if blocked_reason is not None or summary.get("continuation_decision") == "blocked":
write_anchor_files(
completed_stages=completed_stages,
current_stage=current_stage,
last_result=summary,
blocked_reason=blocked_reason or ", ".join(summary.get("blockers") or []) or summary.get("mode") or "blocked",
)
break
idle_rounds += 1
write_anchor_files(
completed_stages=completed_stages,
current_stage=current_stage,
last_result=summary,
blocked_reason=None,
)
if idle_rounds > max_idle_rounds:
blocked_reason = f"idle_round_limit_exceeded:{summary.get('mode') or 'unknown'}"
break
final_stage = current_stage
summary = {
"started_ts": started_ts,
"finished_ts": datetime.now(timezone.utc).isoformat(),
"rounds": rounds,
"completed_stages": completed_stages,
"current_stage": final_stage,
"current_stage_title": STAGE_TITLES.get(final_stage),
"blocked_reason": blocked_reason,
"status": "completed" if final_stage is None and blocked_reason is None else "blocked" if blocked_reason else "stopped",
}
write_runner_status(
{
"state": summary["status"],
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": final_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason,
"rounds_completed": len(rounds),
}
)
except Exception:
write_runner_status(
{
"state": "failed",
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason or "runner_exception",
"rounds_completed": len(rounds),
}
)
raise
if output_json:
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
else:
print(f"Run-until-blocked status: {summary['status']}")
print(f"Completed stages: {', '.join(completed_stages) if completed_stages else 'none'}")
if final_stage:
print(f"Current stage: {final_stage} {STAGE_TITLES[final_stage]}")
if blocked_reason:
print(f"Blocked reason: {blocked_reason}")
return summary
def run_supervised_until_stopped(env=None, runner=None, sleeper=None) -> dict:
env = dict(os.environ if env is None else env)
sleeper = sleeper or time.sleep
loop_sleep_seconds = max(_get_int(env, "RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS", 30), 1)
max_supervisor_cycles = max(_get_int(env, "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES", 0), 0)
cycles = 0
last_summary = None
resume_fingerprint = compute_resume_fingerprint()
while True:
last_summary = run_until_blocked(env=env, runner=runner, sleeper=sleeper)
cycles += 1
if last_summary["status"] == "completed":
return last_summary
supervisor_state = classify_supervisor_state(last_summary)
write_runner_status(
{
"state": supervisor_state,
"started_ts": last_summary["started_ts"],
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": last_summary.get("current_stage"),
"completed_stages": last_summary.get("completed_stages") or [],
"blocked_reason": last_summary.get("blocked_reason"),
"rounds_completed": len(last_summary.get("rounds") or []),
"sleep_seconds": loop_sleep_seconds,
"supervisor_cycles": cycles,
"resume_fingerprint": resume_fingerprint,
}
)
if max_supervisor_cycles and cycles >= max_supervisor_cycles:
return last_summary
if supervisor_state in {"paused", "needs_fix"}:
if wait_for_resume_condition(
previous_fingerprint=resume_fingerprint,
sleeper=sleeper,
loop_sleep_seconds=loop_sleep_seconds,
):
resume_fingerprint = compute_resume_fingerprint()
continue
return last_summary
sleeper(loop_sleep_seconds)
def classify_supervisor_state(summary: dict) -> str:
blocked_reason = str(summary.get("blocked_reason") or "")
if summary.get("status") == "completed":
return "completed"
if blocked_reason.startswith("autonomous_mode:pipeline_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:offline_checks_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:migration_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:backfill_failed"):
return "paused"
if blocked_reason.startswith("autonomous_mode:waiting_for_database"):
return "sleeping"
if blocked_reason:
return "paused"
return "sleeping"
def wait_for_resume_condition(*, previous_fingerprint: str, sleeper, loop_sleep_seconds: int) -> bool:
current_fingerprint = compute_resume_fingerprint()
if current_fingerprint != previous_fingerprint:
return True
sleeper(loop_sleep_seconds)
return compute_resume_fingerprint() != previous_fingerprint
def compute_resume_fingerprint() -> str:
entries = []
for path in RUNNER_RESUME_WATCH_PATHS:
if not path.exists():
entries.append(f"{path}:missing")
continue
if path.is_file():
stat = path.stat()
entries.append(f"{path}:{stat.st_mtime_ns}:{stat.st_size}")
continue
for child in sorted(path.rglob("*")):
if not child.is_file():
continue
stat = child.stat()
entries.append(f"{child}:{stat.st_mtime_ns}:{stat.st_size}")
return "|".join(entries)
def write_runner_status(payload: dict) -> None:
RUNNER_STATUS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def detect_current_stage(session_text: str, completed_stages: list[str]) -> Optional[str]:
exact_next_step = re.search(r"###\s+`(P\d-\d)`", session_text)
if exact_next_step and exact_next_step.group(1) in STAGE_SEQUENCE:
return exact_next_step.group(1)
for stage in STAGE_SEQUENCE:
if stage not in completed_stages:
return stage
return None
def get_next_stage(current_stage: str, completed_stages: list[str]) -> Optional[str]:
for stage in STAGE_SEQUENCE:
if stage not in completed_stages:
return stage
return None
def parse_done_stages(tasks_text: str) -> list[str]:
return re.findall(r"- \[x\] `(P\d-\d)`", tasks_text)
def first_blocked_reason(summary: dict) -> Optional[str]:
blockers = [str(item) for item in summary.get("blockers") or []]
for blocker in blockers:
if any(marker in blocker for marker in BLOCKED_MARKERS):
return blocker
if summary.get("continuation_decision") == "blocked":
return blockers[0] if blockers else summary.get("mode")
return None
def write_anchor_files(*, completed_stages: list[str], current_stage: Optional[str], last_result: dict, blocked_reason: Optional[str] = None) -> None:
SESSION_PATH.write_text(render_session(completed_stages, current_stage, last_result, blocked_reason), encoding="utf-8")
TASKS_PATH.write_text(render_tasks(completed_stages, current_stage, blocked_reason), encoding="utf-8")
def render_session(completed_stages: list[str], current_stage: Optional[str], last_result: dict, blocked_reason: Optional[str]) -> str:
completed_lines = [f"- 已完成 `{stage}`:{STAGE_TITLES[stage]}" for stage in completed_stages] or ["- 本轮尚无新增完成阶段"]
next_step_block = render_next_step_block(current_stage)
risk_lines = [
"- 当前市场真值链路依赖本机 `192.168.3.73:10808` 持续可达;如本机关机或代理退出,远端将退化",
"- 真实账户只读同步下一阶段可能需要额外凭据或账号权限;若现有环境缺失,将构成唯一允许的用户介入点",
]
if blocked_reason:
risk_lines.append(f"- 当前自动化已阻塞:{blocked_reason}")
return "\n".join(
[
"# ai-exchange Session",
"",
"## Current Goal",
"",
"从现在开始按全自动模式持续推进交易模块主线,不再等待阶段确认,直到完成 `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` 中剩余开发目标。",
"",
"## Current Branch",
"",
"`main`",
"",
"## Last Updated",
"",
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d UTC')}",
"",
"## Completed In Last Session",
"",
*completed_lines,
"",
"## Current Working State",
"",
"- 当前主线执行协议已固化到:",
" - `docs/AI_ICT_全自动开发执行流程_v1.md`",
"- 当前阶段队列来源于:",
" - `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md`",
"- 默认不再等待用户中途确认",
"- 每阶段必须完成:",
" - 实现",
" - focused tests",
" - `npm run lint`",
" - `npm run build`",
" - `npm run visual:snapshots`",
" - Docker 部署",
" - 远端 smoke",
" - 状态回写",
"",
"## Exact Next Step",
"",
next_step_block,
"",
"## Validation Commands",
"",
"```bash",
*DEFAULT_VALIDATION_COMMANDS,
"```",
"",
"## Risks Or Open Questions",
"",
*risk_lines,
"",
]
)
def render_next_step_block(current_stage: Optional[str]) -> str:
if current_stage is None:
return "所有阶段已完成,无下一阶段。"
goal_lines = STAGE_GOALS.get(current_stage, [])
rendered_goals = "\n".join(f"- {line}" for line in goal_lines)
return f"直接进入:\n\n### `{current_stage}` {STAGE_TITLES[current_stage]}\n\n目标:\n\n{rendered_goals}"
def render_tasks(completed_stages: list[str], current_stage: Optional[str], blocked_reason: Optional[str]) -> str:
in_progress_lines = [f"- [ ] `{current_stage}` {STAGE_TITLES[current_stage]}"] if current_stage else ["- [x] 所有阶段已完成"]
next_up_lines = [
f"- [ ] `{stage}` {STAGE_TITLES[stage]}"
for stage in STAGE_SEQUENCE
if stage != current_stage and stage not in completed_stages
] or ["- [x] 当前无后续阶段"]
blocked_lines = [f"- [ ] {blocked_reason}"] if blocked_reason else ["- [ ] 当前无阻塞;如缺凭据或外部权限,再转入此区"]
done_lines = [f"- [x] `{stage}` {STAGE_TITLES[stage]}" for stage in completed_stages] or ["- [ ] 当前尚无完成阶段"]
return "\n".join(
[
"# ai-exchange Tasks",
"",
"## Execution Mode",
"",
"- [x] 全自动模式已启用",
"- [x] 默认不等待阶段确认",
"- [x] 每阶段必须完成实现、验证、部署、smoke、状态回写",
"",
"## In Progress",
"",
*in_progress_lines,
"",
"## Next Up",
"",
*next_up_lines,
"",
"## Backlog",
"",
"- [ ] 长期目标:在不默认开启真实自动下单的前提下,完成 `Real-time Assisted Trading V1`",
"",
"## Blocked",
"",
*blocked_lines,
"",
"## Done",
"",
*done_lines,
"",
]
)
def _get_bool(env, key: str, default: bool) -> bool:
value = env.get(key)
if value is None or value == "":
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _get_int(env, key: str, default: int) -> int:
value = env.get(key)
if value is None or value == "":
return default
return int(value)
def main() -> int:
env = dict(os.environ)
summary = run_supervised_until_stopped(env=env) if _get_bool(env, "RUN_UNTIL_BLOCKED_SUPERVISOR", False) else run_until_blocked(env=env)
return 0 if summary["status"] == "completed" else 2 if summary["status"] == "blocked" else 1
if __name__ == "__main__":
raise SystemExit(main())