diff --git a/.dockerignore b/.dockerignore index 60635a9..7f0fc28 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,6 @@ __pycache__/ .venv/ venv/ tests/ -docs/ *.pdf memory/ .DS_Store diff --git a/.env.example b/.env.example index 02fcc56..7a098ce 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ NEWS_PROVIDER_TRADINGECONOMICS_API_KEY=guest:guest NEWS_EVENT_PRE_WINDOW_MINUTES=15 NEWS_EVENT_POST_WINDOW_MINUTES=15 NEWS_EVENT_HIGH_IMPACT_ONLY=1 -LAUNCH_REQUIRE_REAL_TRADE_SAMPLE=1 +LAUNCH_REQUIRE_REAL_TRADE_SAMPLE=0 LIVE_SUBMIT_STRATEGY=blocked_until_verified TRADING_RUNTIME_ENABLED=1 TRADING_RUNTIME_SYMBOLS=BTC-USDT,ETH-USDT diff --git a/.gitignore b/.gitignore index c1a2fe3..c8d8df5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ venv/ # Environment and secrets .env +.env.local +.env.runtime *.pem *.key diff --git a/Dockerfile b/Dockerfile index f448ee6..03572e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,10 @@ RUN pip install --no-cache-dir \ COPY src /app/src COPY scripts /app/scripts COPY migrations /app/migrations +COPY data /app/data +COPY docs /app/docs +COPY SESSION.md /app/SESSION.md +COPY TASKS.md /app/TASKS.md COPY src/web /app/src/web COPY .env.example /app/.env.example COPY README.md /app/README.md diff --git a/SESSION.md b/SESSION.md index 75c6a55..ef56f31 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Continue the trading-module mainline in autonomous mode until `Real-time Assisted Trading V1` is complete. The next active step is the final hardening and launch-readiness pass. +Automation execution-layer P8 is closed locally. P8 turns reserved visual-watch requests into auditable visual review records, advisory capture artifacts, workbench-visible evidence, operator feedback, and visual evidence attached to alert dispatch payloads while keeping the system analysis/restricted decision-support only. ## Current Branch @@ -10,134 +10,73 @@ Continue the trading-module mainline in autonomous mode until `Real-time Assiste ## Last Updated -2026-05-08 UTC +2026-05-11 UTC + +## Automation Status + +- state: automation_execution_layer_p8_visual_feedback_loop_closed +- current_phase: P8 Visual Watch Feedback Loop +- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup +- latest_p8_slice: visual_alert_feedback_loop +- latest_visual_review_script_status: idle +- latest_runtime_visual_watch_task_status: ok +- latest_runtime_visual_watch_state: idle +- latest_visual_snapshot_capture_status: implemented +- latest_visual_reviews_panel_status: visible +- latest_visual_feedback_status: implemented +- latest_visual_alert_evidence_dispatch_status: implemented_existing_channels +- latest_runtime_blockers: none +- guards_unchanged: execution_allowed, real_live_ready, risk_governor, trust_bucket, operator_confirmation_protocol ## Completed In This Run -- Completed `P0-2` Real Account And Order Read Sync acceptance: - - corrected the remote OKX read-only credential mapping - - verified `/trading/broker-state` returns `broker_state_ok` - - verified `/trading/market-state` carries `broker_state_ok` -- Completed `P0-3` Runtime Scheduling And Supervision implementation: - - `TradingRuntimeScheduler` - - `TradingRuntimeSupervisor` - - runtime task state with last success, next run, blockers, degraded mode, and incidents - - `/trading/runtime-status` - - `/trading/runtime/run-once` - - `scripts/run_trading_runtime.py` - - workbench Runtime Supervisor panel -- Completed `P0-4` Decision Packet And Proactive Alerting implementation: - - `DecisionPacketService` - - `decision_packets` and `decision_alerts` persistence - - `/trading/decision-packet` - - `/trading/alerts` - - runtime decision-packet generation task - - workbench Decision Packet panel with recent alerts - - `scripts/run_decision_packet.py` -- Local quality gates passed: - - `npm run lint` - - `npm run build` - - `npm run visual:snapshots` -- Remote deploy and smoke passed: - - `/health` - - `/trading/broker-state` - - `/trading/runtime-status` - - `/trading/runtime/run-once` - - `/trading/market-state` - - `/trading/decision-packet` - - `/trading/alerts` -- Completed `P0-5` Real Trade Mapping And Post-Trade Loop implementation: - - `BrokerExecutionReconciliationService` - - real broker fill/order/position reconciliation into `trade_executions` - - external real-trade import path into synthetic signals/executions - - journal attribution fields for real trade detection and reconciliation - - runtime reconciliation task - - `/trading/reconcile` - - `scripts/run_broker_reconciliation.py` -- Completed `P1-1` Trust Scoring And Model Whitelists implementation: - - `ModelTrustService` - - `/trading/trust-score` - - `scripts/run_trust_score.py` - - workbench `Model Trust` panel - - whitelist / observe / blacklist output - - session-level trust recommendations -- Extended remote smoke passed: - - `/trading/reconcile` - - `/trading/journal/summary` - - `/trading/trust-score` -- Completed `P1-2` Operator Experience And Workflow Tools implementation: - - `operator_notes` persistence - - `SessionFocusService` - - `ReviewPacketService` - - `/trading/session-view` - - `/trading/operator-notes` - - `/trading/review-packet` - - workbench `Session Focus`, `Alert History`, and `Operator Console` panels - - richer session/trust filters in the workbench - - `scripts/run_review_packet.py` -- Additional remote smoke passed: - - `/trading/session-view` - - `/trading/operator-notes` - - `/trading/review-packet` -- Completed `P1-3` Live Adapter Readiness Prep implementation: - - standardized `live-order-v1` order schema - - standardized bracket / OCO and reconciliation semantics - - explicit `live-confirmation-v1` operator confirmation protocol - - `/trading/live-readiness` - - `scripts/run_live_readiness.py` - - workbench `Live Readiness` panel -- Final remote smoke passed: - - `/trading/live-readiness` - - workbench `Live Readiness` panel visible -- Completed launch-readiness hardening pass: - - `/trading/launch-readiness` - - `scripts/run_launch_readiness.py` - - workbench `Launch Readiness` panel - - explicit split between `analysis_only_ready`, `decision_support_ready`, `real_trade_loop_ready`, and `real_live_ready` - - stable `news_state` via provider chain + cached snapshot fallback - - blacklist models now degrade to `observe_only` instead of blocking analysis-only launch +- Verified `GSTACK_OK`. +- Promoted the visual-watch prewire into a P8 review loop slice. +- Added `VisualReviewService` to review reserved watch runs, create optional snapshot records, create visual reviews, and mark watch runs reviewed. +- Added `scripts/run_visual_review.py` for manual or automation-driven review processing. +- Added `VisualSnapshotCaptureService` and `scripts/run_visual_snapshot_capture.py` for advisory review-packet snapshot capture. +- Added API helpers and routes for `POST /trading/visual-watch/review` and `POST /trading/visual-watch/review-pending`. +- Added a read-only workbench panel for recent visual reviews. +- Added a runtime `visual_watch` task that processes pending visual-watch reviews without changing trading gates. +- Added `alert_feedback` persistence and feedback service support for alert and visual-review labels. +- Added visual-review feedback API routes and workbench actions for useful / misleading / actionable labels. +- Extended alert dispatch messages and delivery payloads with the latest visual evidence for the decision packet. +- Added focused unit tests for the service, script, API routing, and runtime task. ## Current Working State -- Autonomous execution protocol is anchored at: - - `docs/AI_ICT_全自动开发执行流程_v1.md` -- Product roadmap is anchored at: - - `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` -- Runtime market truth depends on the local LAN proxy remaining available at: - - `192.168.3.73:10808` -- Real broker read sync depends on the remote OKX read-only credentials in: - - `/root/deploy/ai-ict/.env` +- P7 is closed and remains a monitor-maturity routing layer, not a Research Council expansion. +- P8 review slice is implemented: `visual_watch_runs` with pending status can now be converted into `visual_reviews`. +- P8 capture adapter is implemented: review packets can now be rendered to advisory HTML capture artifacts and stored alongside snapshot records. +- P8 workbench visibility is implemented: recent visual reviews are visible in the main trading workbench. +- P8 feedback loop is implemented: operators can label visual reviews as helpful, misleading, or actionable, and the labels remain advisory. +- P8 alert evidence loop is implemented for existing channels: alert dispatch messages and delivery payloads include the latest visual review and snapshot paths when available. +- Latest `scripts/run_visual_review.py` smoke found no pending reviews: attempted=0, reviewed=0, status=idle. +- Latest capture smoke returned `status=ignored` when no watch run was supplied, which is the expected safe no-op. +- Latest `scripts/run_trading_runtime.py` smoke returned blockers=none and includes a healthy `visual_watch` runtime task. + +## P8 Blockers + +- none + +## Current Risks Or Open Questions + +- Browser screenshot capture adapter is a future enhancement, not a P8 closure blocker. The current adapter is an advisory review-packet HTML capture, and the review loop can still use an existing snapshot, an explicit `image_path`, or write an `unavailable` structured review. +- Visual review remains advisory and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. +- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -Proceed directly to: +P8 is locally closed. Default next action is to continue periodic analysis-decision monitoring with maturity rollup; any browser-level screenshot capture or additional external media channel adapter should be treated as a future enhancement, not as an execution-gate change. -### Final Hardening And Launch-Readiness Pass - -Goals: - -- close the remaining non-phase gaps before calling the system launch-ready for assisted trading -- verify stable news/event input instead of 429-driven degraded freshness -- verify reconciliation against real fills when the account actually has recent fills -- decide whether to keep live submit intentionally blocked or implement the real OKX submit adapter - -## Validation Commands +## Validation Commands Used Recently ```bash -npm run lint -npm run build -npm run visual:snapshots +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_review_service tests.unit.test_run_visual_review tests.unit.test_trading_runtime_service tests.unit.test_api_and_main tests.unit.test_api_server +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_monitoring_alert_rollout tests.unit.test_runtime_script_json +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_review.py +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_snapshot_capture.py +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_trading_runtime.py +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_snapshot_capture_service tests.unit.test_run_visual_snapshot_capture tests.unit.test_api_static_server ``` - -## Risks Or Open Questions - -- Do not enable default live trading. -- Do not let AI bypass Risk Governor decisions. -- If `192.168.3.73:10808` is unavailable, market truth will degrade. -- If OKX read-only credentials are changed or revoked, broker state will degrade. -- Current remote account has `0` recent fills, so live reconciliation is verified as a clean no-op; the import path itself is covered by focused tests and the runtime/manual reconcile flow. -- Launch readiness now reports the remaining assisted-trading blockers explicitly: - - `analysis_only_ready = true` - - `decision_support_ready = false` - - `real_trade_loop_ready = false` -- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only. That is the correct current boundary, not a hidden failure. diff --git a/TASKS.md b/TASKS.md index d085b0e..2782c58 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,34 +4,94 @@ - [x] Autonomous mode enabled - [x] No per-stage confirmation required -- [x] Each stage must close the loop through implementation, validation, deploy, smoke, and state writeback +- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback +- [x] P0-P6 Research Council phase expansion is frozen +- [x] P6 `no_op_healthy` must not create Research Council P7/P8 +- [x] Stable monitor quality may enter automation execution-layer P7 +- [x] User explicitly requested the next P8 product-development round ## In Progress -- [ ] Final hardening and launch-readiness pass for `Real-time Assisted Trading V1` +- [x] none ## Next Up -- [ ] Decide whether to keep live submit blocked or implement the real OKX submit path +- [x] P8 local closure complete +- [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel +- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements + +## Automation Status + +- [x] state: automation_execution_layer_p8_visual_feedback_loop_closed +- [x] current_phase: P8 Visual Watch Feedback Loop +- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup +- [x] latest_p8_slice: visual_alert_feedback_loop +- [x] latest_visual_review_script_status: idle +- [x] latest_runtime_visual_watch_task_status: ok +- [x] latest_visual_snapshot_capture_status: implemented +- [x] latest_visual_reviews_panel_status: visible +- [x] latest_visual_feedback_status: implemented +- [x] latest_visual_alert_evidence_dispatch_status: implemented_existing_channels +- [x] latest_runtime_blockers: none +- [x] no_new_research_council_phase_created: true + +## Newly Done + +- [x] Completed P7-4 bounded automation slice selector before opening P8 +- [x] Added `VisualReviewService` +- [x] Added `scripts/run_visual_review.py` +- [x] Added `VisualSnapshotCaptureService` and `scripts/run_visual_snapshot_capture.py` +- [x] Added visual review API routes +- [x] Added a read-only workbench panel for recent visual reviews +- [x] Added `alert_feedback` persistence and feedback service +- [x] Added visual review feedback API routes +- [x] Added workbench feedback actions for useful / misleading / actionable labels +- [x] Added visual evidence to alert dispatch messages and delivery payloads for existing channels +- [x] Added runtime `visual_watch` task +- [x] Added focused service, script, API, and runtime tests +- [x] Smoked the visual review script with no pending reviews +- [x] Smoked the capture script with no watch run provided +- [x] Smoked the trading runtime with blockers=none + +## Blocked + +- [x] none + +## Standing Guardrails + +- [x] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled +- [x] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready +- [x] Visual review is advisory and must not alter execution permission +- [x] Keep OKX fills out of the current analysis/restricted-decision blocker set + +## Future Enhancements Not Blocking P8 + +- [ ] Browser screenshot capture adapter +- [ ] Additional external media/channel adapters beyond current workbench/webhook payload support + +## Deferred Until Real-Trading Phase + +- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase +- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase + +## Operational Dependencies + +- [ ] Keep `D:\v2rayN-windows-64\v2rayN.exe` / `xray.exe` running on the local Windows host +- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808` + +## Major Done Milestones + +- [x] `Research Council P6` remote deploy and governance tick smoke gate +- [x] `P0-P6` reasonableness review and phase freeze gate +- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor +- [x] `automation_execution_layer_p7_monitor_maturity_rollup` P7-1 maturity rollup +- [x] `automation_execution_layer_p7_remote_drift_check` P7-2 remote drift check +- [x] `automation_execution_layer_p7_alert_decision_support_quality_review` P7-3 quality review +- [x] `automation_execution_layer_p7_bounded_automation_slice_selector` P7-4 bounded automation slice selector +- [x] `automation_execution_layer_p8_visual_watch_review_loop` P8 first slice +- [x] `automation_execution_layer_p8_visual_watch_snapshot_capture_adapter` +- [x] `automation_execution_layer_p8_visual_review_workbench_panel` ## Backlog - [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading - -## Blocked - -- [ ] `decision_support_ready` -- [ ] `real_trade_sample_verified` - -## Done - -- [x] `P0-1` Real-time market truth layer -- [x] `P0-2` Real Account And Order Read Sync -- [x] `P0-3` Runtime Scheduling And Supervision -- [x] `P0-4` Decision Packet And Proactive Alerting -- [x] `P0-5` Real Trade Mapping And Post-Trade Loop -- [x] `P1-1` Trust Scoring And Model Whitelists -- [x] `P1-2` Operator Experience And Workflow Tools -- [x] `P1-3` Live Adapter Readiness Prep -- [x] Autonomous execution protocol codified in repo docs -- [x] Market-truth proxy switched to direct v2rayN LAN proxy `192.168.3.73:10808` diff --git a/data/replay/research_council_confirmed_swings_v1.jsonl b/data/replay/research_council_confirmed_swings_v1.jsonl new file mode 100644 index 0000000..1291a7c --- /dev/null +++ b/data/replay/research_council_confirmed_swings_v1.jsonl @@ -0,0 +1,9 @@ +{"ts_open":"2026-05-11T00:00:00+00:00","ts_close":"2026-05-11T00:01:00+00:00","open":100,"high":102,"low":98,"close":101} +{"ts_open":"2026-05-11T00:01:00+00:00","ts_close":"2026-05-11T00:02:00+00:00","open":101,"high":104,"low":99,"close":103} +{"ts_open":"2026-05-11T00:02:00+00:00","ts_close":"2026-05-11T00:03:00+00:00","open":103,"high":110,"low":101,"close":106} +{"ts_open":"2026-05-11T00:03:00+00:00","ts_close":"2026-05-11T00:04:00+00:00","open":106,"high":107,"low":100,"close":102} +{"ts_open":"2026-05-11T00:04:00+00:00","ts_close":"2026-05-11T00:05:00+00:00","open":102,"high":104,"low":97,"close":99} +{"ts_open":"2026-05-11T00:05:00+00:00","ts_close":"2026-05-11T00:06:00+00:00","open":99,"high":101,"low":95,"close":96} +{"ts_open":"2026-05-11T00:06:00+00:00","ts_close":"2026-05-11T00:07:00+00:00","open":96,"high":99,"low":88,"close":90} +{"ts_open":"2026-05-11T00:07:00+00:00","ts_close":"2026-05-11T00:08:00+00:00","open":92,"high":98,"low":91,"close":96} +{"ts_open":"2026-05-11T00:08:00+00:00","ts_close":"2026-05-11T00:09:00+00:00","open":96,"high":100,"low":94,"close":99} diff --git a/docker-compose.yml b/docker-compose.yml index b1d9bb7..79d1ddf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,6 @@ services: - .env ports: - "8000:8000" + volumes: + - ./.claude:/app/.claude restart: unless-stopped diff --git a/docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md b/docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md new file mode 100644 index 0000000..5fe85d1 --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md @@ -0,0 +1,133 @@ +# AI_ICT + TradingAgents P0-P6 合理性审查与优化结论 v1 + +## 1. 审查目标 + +本文件用于在正式进入自动化流程开发阶段前,对 `Research Council P0-P6` 进行一次阶段合理性审查。 + +审查问题: + +1. P0-P6 的阶段划分是否合理 +2. 是否存在阶段过度扩张或重复建设 +3. 自动晋级 / 退出条件是否可执行 +4. 是否存在越过 execution gate / live readiness / trust bucket 的风险 +5. 是否可以停止继续写阶段文档,进入自动化执行层开发 + +--- + +## 2. 总体结论 + +结论: + +> P0-P6 作为 `AI_ICT + TradingAgents` 融合主线的自动化阶段是合理的,可以保留;但 P6 必须被定义为当前研究委员会链路的终端治理入口,不应继续自动扩展 P7。 + +补充裁定:用户明确要求“从 P7 开始设计自动化”时,P7 只能作为自动化执行层阶段使用,不得解释为 `Research Council P7`。 + +本次审查后的执行原则: + +```text +P0-P6 阶段冻结 +-> 默认运行 P6 governance tick +-> no_op_healthy 则停止 +-> cycle_required 才补跑 optimization cycle +-> bounded_slice_required 才打开最小实现切片 +-> 不再因为“继续”自动创造新阶段 +``` + +--- + +## 3. 分阶段合理性判断 + +| 阶段 | 判断 | 保留理由 | 优化要求 | +| --- | --- | --- | --- | +| P0 | 合理 | 影子接入是最低风险融合方式 | 必须保持只读,不影响主链 | +| P1 | 合理 | 让 council 进入 decision packet / runtime 解释层 | 只增强解释和 operator 可见性,不改执行门 | +| P2 | 合理 | lesson / eval / arena 是持续改进的基础 | 只能用于研究评估和 bounded follow-up | +| P3 | 合理 | 把 P2 长期运行态固化为可持久化 cycle | cycle 输出只能进入 operator follow-up | +| P4 | 合理 | 把 cycle 转为治理判断,避免盲目继续开发 | `needs_attention` 不能被解释成交易信号 | +| P5 | 合理但特殊 | P5 是由 P4 指出的具体 replay evidence 缺口触发 | 未来类似缺口应作为 P6 bounded slice,不再固定扩阶段 | +| P6 | 合理且应作为终端入口 | governance tick 解决“继续时该做什么”的自动判断问题 | P6 后默认停止扩阶段,转入自动化执行层开发 | + +--- + +## 4. 必须优化的流程点 + +### 4.1 P6 之后不再自动扩阶段 + +之前 P0-P5 的模式容易让“继续”自然变成 P7、P8。 + +优化后: + +- P6 是当前 Research Council 融合主线的终端治理入口 +- P6 返回 `no_op_healthy` 时,自动化流程必须停止开新切片 +- 只有 P6 返回 `cycle_required` 或 `bounded_slice_required`,才允许继续执行 +- analysis/decision monitor 已达到稳定质量窗口时,可进入自动化执行层 P7,用于成熟度摘要与下一 bounded slice 路由 + +### 4.2 P4 到 P5 必须是条件跳转 + +P5 不应被理解为所有情况下 P4 后的固定阶段。 + +只有当 P4 review 返回: + +```text +recommended_actions includes add_replay_fixture_with_confirmed_swings +``` + +才进入 P5。 + +如果 P4 已经 healthy,应直接进入 P6 governance tick。 + +### 4.3 P5 以后同类缺口不再扩新阶段 + +P5 是一次已经发生的具体缺口闭环,不应形成“每个缺口一个永久阶段”的模式。 + +以后如果 P6 tick 返回 `bounded_slice_required`: + +- 在当前自动化执行层内打开最小 bounded slice +- 完成后回到 P6 tick +- 不新增 Research Council P7/P8 文档,除非用户明确要求重新规划产品阶段 + +### 4.4 状态机必须统一 + +P6 后默认自动化状态机: + +| Tick status | 自动动作 | 是否写代码 | +| --- | --- | --- | +| `no_op_healthy` | 停止,记录健康 | 否 | +| `cycle_required` | 按策略补跑 optimization cycle | 仅当明确授权 | +| `bounded_slice_required` | 打开最小实现切片 | 是 | +| `cycle_run` | 写回 cycle / review 结果 | 否,除非新 review 要求 | + +--- + +## 5. 正式进入自动化流程开发阶段的条件 + +满足以下条件后,可以进入自动化流程开发阶段: + +1. `SESSION.md` 状态已写为 `automation_flow_p0_p6_review_closed` +2. `TASKS.md` 已明确下一步是自动化执行层开发 +3. P6 governance tick 可作为默认入口 +4. latest tick 为 `no_op_healthy` +5. P0-P6 文档不再继续扩阶段 +6. `npm run lint` 或至少文档一致性相关测试通过 + +--- + +## 6. 审查结论 + +P0-P6 不需要重写。 + +需要并已纳入流程的优化是: + +1. 增加 P0-P6 审查结论文档 +2. 明确 P6 为终端治理入口 +3. 明确 P4->P5 是条件跳转 +4. 明确 P6 后不自动扩 P7 +5. 明确下一步进入自动化执行层开发,而不是继续写阶段清单 + +最终判定: + +```text +P0-P6 合理 +P0-P6 阶段冻结 +可以进入自动化流程开发阶段 +``` diff --git a/docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md new file mode 100644 index 0000000..360a7f0 --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md @@ -0,0 +1,295 @@ +# AI_ICT + TradingAgents P0 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P0` 的自动推进。 + +P0 定位: + +> 影子接入,不影响现有决策,只读消费 AI_ICT 事实,结果先进入 `review packet`。 + +本清单是执行单,不是设计稿。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应切入本清单: + +1. 当前 AI_ICT 主骨架无新增主链 blocker +2. 当前阶段已被标记为 `Research Council P0` +3. 已读取: + - `AI_ICT_TradingAgents融合实施方案_最终版.md` + - `AI_ICT_全自动开发执行流程_v1.md` + - `AI_ICT_research_council脚手架与数据表草案_v1.md` + +--- + +## 3. 默认执行顺序 + +## P0-1 脚手架与导出入口 + +### 目标 + +创建 `src/services/research_council/` 基础目录和统一导出入口。 + +### 主要文件 + +- `src/services/research_council/__init__.py` +- `src/services/research_council/council_schema.py` +- `src/services/research_council/council_output_formatter.py` + +### 完成标志 + +- 目录结构存在 +- 包可被 import +- 不影响现有主链 + +### 默认验证 + +- focused import smoke +- 新增 unit test: `tests/unit/test_research_council_imports.py` + +--- + +## P0-2 context builder + +### 目标 + +完成 `council_context_builder`,只读拼装 AI_ICT 已有事实。 + +### 主要文件 + +- `src/services/research_council/council_context_builder.py` +- `src/services/decision/decision_packet_service.py` +- `src/services/operator/operator_workspace_service.py` + +### 输入源要求 + +- `market_state` +- `latest_trading_plan` +- `latest_candidate` +- `latest_signal` +- `risk_result` +- `trust_policy` +- `recent_alerts` +- `recent_journal_entries` +- `weekly_review_summary` + +### 完成标志 + +- builder 只消费现有服务输出 +- 不创建第二套事实层 + +### 默认验证 + +- focused unit test: `tests/unit/test_research_council_context_builder.py` + +--- + +## P0-3 结构化输出契约 + +### 目标 + +定义 P0 可用的最小 `CouncilDecision` 结构与 formatter。 + +### 主要文件 + +- `src/services/research_council/council_schema.py` +- `src/services/research_council/council_output_formatter.py` + +### P0 必含字段 + +- `research_summary` +- `operator_summary` +- `bull_case` +- `bear_case` +- `net_assessment` +- `committee_confidence` +- `source_snapshot_meta` + +### 完成标志 + +- formatter 输出稳定 +- review packet 可直接消费 + +### 默认验证 + +- focused unit test: `tests/unit/test_research_council_schema.py` + +--- + +## P0-4 structure / risk analysts + +### 目标 + +先接入最必要的两个 analyst: + +- `structure_analyst` +- `risk_analyst` + +### 主要文件 + +- `src/services/research_council/agents/structure_analyst.py` +- `src/services/research_council/agents/risk_analyst.py` +- `src/services/research_council/tradingagents_adapter.py` + +### 完成标志 + +- analyst 只基于 context builder 输入工作 +- 不直接调用外部股票研究数据源 + +### 默认验证 + +- focused unit test: `tests/unit/test_research_council_analysts.py` + +--- + +## P0-5 bull / bear / manager + +### 目标 + +接入最小对抗论证链路: + +- `bull_researcher` +- `bear_researcher` +- `research_manager` + +### 主要文件 + +- `src/services/research_council/agents/bull_researcher.py` +- `src/services/research_council/agents/bear_researcher.py` +- `src/services/research_council/agents/research_manager.py` +- `src/services/research_council/debate_service.py` +- `src/services/research_council/council_orchestrator.py` + +### 完成标志 + +- 能生成正反论据和最终摘要 +- 结论只作为解释层,不改写主裁决 + +### 默认验证 + +- focused unit test: `tests/unit/test_research_council_orchestrator.py` + +--- + +## P0-6 持久化主表与链接表 + +### 目标 + +落最小持久化,不一开始就把全部历史回流做满。 + +### 建议先做的表 + +- `research_council_runs` +- `research_council_messages` +- `research_council_links` + +### 主要文件 + +- `migrations/036_create_research_council_runs.sql` +- `migrations/037_create_research_council_messages.sql` +- `migrations/038_create_research_council_links.sql` +- 对应 repository 文件 + +### 完成标志 + +- 一次 council run 可持久化 +- 能和 `decision_packet_id / candidate_id / signal_id` 关联 + +### 默认验证 + +- migration static checks +- focused repository tests + +--- + +## P0-7 手动 API / script 入口 + +### 目标 + +提供手动触发入口,先不进入 runtime 自动任务。 + +### 建议新增 + +- `scripts/run_research_council.py` +- `GET /trading/research-council/latest` +- `GET /trading/research-council/runs` +- `POST /trading/research-council/run` + +### 主要文件 + +- `scripts/run_research_council.py` +- `src/api/server.py` + +### 完成标志 + +- 手动 API / script 稳定可跑 +- 没有 council 时系统主行为不变 + +### 默认验证 + +- focused API unit test +- script smoke + +--- + +## P0-8 review packet 只读展示 + +### 目标 + +把 council 摘要先接入 `review packet`,不进 `decision packet`。 + +### 主要文件 + +- `src/services/operator/operator_workspace_service.py` +- `scripts/run_review_packet.py` +- `src/web/app.js` + +### 完成标志 + +- `review packet` 能显示 council 摘要 +- council 缺席时不回归 + +### 默认验证 + +- focused review packet unit test +- API smoke: `/trading/review-packet` +- browser smoke + +--- + +## 4. P0 阶段出口判断 + +P0 做完后必须回到晋级门,不是直接进入 P1。 + +只有以下全部成立,才允许自动切到 P1: + +1. 手动 API / script 稳定 +2. council 缺席时主系统不回归 +3. council 存在时 `review packet` 展示正常 +4. council 失败时 runtime 可自动降级 +5. 连续 smoke 无新增主链 blocker + +否则状态应写为: + +- `P0 部分完成` +- `P0 待收尾` +- `P0 环境阻塞` + +--- + +## 5. P0 默认验证串 + +建议默认验证顺序: + +1. focused unit tests +2. `tests/unit` +3. `scripts/run_offline_checks.py` +4. `scripts/run_research_council.py` +5. `scripts/run_review_packet.py` +6. API smoke +7. browser smoke + +如果改动未进入 runtime 主链,可不强制远端 deploy,但必须在状态中说明。 diff --git a/docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md new file mode 100644 index 0000000..0398e51 --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md @@ -0,0 +1,252 @@ +# AI_ICT + TradingAgents P1 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P1` 的自动推进。 + +P1 定位: + +> 受控进入 `decision_packet` 与 `runtime`,增强 operator / workbench 的解释质量,但不替代风控和 trust。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应切入本清单: + +1. `P0` 晋级门已通过 +2. 当前阶段已被标记为 `Research Council P1` +3. P0 相关回归已清零 + +--- + +## 3. 默认执行顺序 + +## P1-1 runtime 任务接入 + +### 目标 + +把 `research_council` 作为受控任务接入 runtime。 + +### 主要文件 + +- `src/services/runtime/trading_runtime_service.py` +- `src/services/research_council/council_orchestrator.py` + +### 完成标志 + +- 任务顺序固定在 `watch -> research_council -> decision_packet` +- 失败自动降级,不阻塞主链 + +### 默认验证 + +- `tests/unit/test_trading_runtime_service.py` +- 新增: `tests/unit/test_research_council_runtime_integration.py` + +--- + +## P1-2 decision packet 字段扩展 + +### 目标 + +把 council 解释字段正式接入 `DecisionPacket`。 + +### 主要文件 + +- `src/services/decision/decision_packet_service.py` + +### 目标字段 + +- `research_summary` +- `committee_confidence` +- `agreement_points` +- `disagreement_points` +- `follow_up_checks` +- `recommended_watch_items` +- `operator_questions` + +### 完成标志 + +- packet 主裁决仍由原逻辑决定 +- council 只增强解释 + +### 默认验证 + +- `tests/unit/test_decision_packet_service.py` +- 新增: `tests/unit/test_research_council_decision_packet_fields.py` + +--- + +## P1-3 news_event_analyst + +### 目标 + +增加环境解释层。 + +### 主要文件 + +- `src/services/research_council/agents/news_event_analyst.py` +- `src/services/market_state/news_event_provider.py` + +### 完成标志 + +- 只解释 AI_ICT 已有 news/event 结果 +- 不自行建立第二套事实源 + +### 默认验证 + +- focused unit test + +--- + +## P1-4 visual_analyst + +### 目标 + +吸收 visual review 结果,增强解释与 follow-up checks。 + +### 主要文件 + +- `src/services/research_council/agents/visual_analyst.py` +- `src/services/visual_watch/visual_watch_service.py` + +### 完成标志 + +- visual 结果只作为补充确认和风险提示 +- 不单独作为执行许可来源 + +### 默认验证 + +- focused unit test + +--- + +## P1-5 disagreement / follow-up checks + +### 目标 + +把分歧点和补充检查项结构化输出。 + +### 主要文件 + +- `src/services/research_council/debate_service.py` +- `src/services/research_council/council_schema.py` + +### 完成标志 + +- bull / bear / manager 输出可汇总成统一分歧结构 + +### 默认验证 + +- orchestrator / formatter focused tests + +--- + +## P1-6 visual watch 建议联动 + +### 目标 + +让 council 输出建议的 watch 项,但不越过执行边界。 + +### 主要文件 + +- `src/services/visual_watch/visual_trigger_policy_service.py` +- `src/services/decision/decision_packet_service.py` + +### 完成标志 + +- `recommended_watch_items` 可进入 visual watch +- 不改变 `execution_allowed` + +### 默认验证 + +- visual watch focused tests + +--- + +## P1-7 workbench 展示增强 + +### 目标 + +把 council 解释层真正呈现在 operator workbench 上。 + +### 主要文件 + +- `src/web/app.js` +- `src/web/index.html` + +### 展示目标 + +- 支持理由 +- 反对理由 +- 分歧点 +- follow-up checks +- committee confidence + +### 完成标志 + +- operator 能直接读到增强解释 +- 不引入第二套页面 + +### 默认验证 + +- `npm run lint` +- `npm run build` +- `npm run visual:snapshots` +- browser smoke + +--- + +## P1-8 checkpoint / resume + +### 目标 + +给 council 长链路增加恢复能力。 + +### 主要文件 + +- `src/services/research_council/council_checkpoint_service.py` +- 相关 migration / repository + +### 完成标志 + +- council 运行中断后可恢复 +- 不影响主链稳定性 + +### 默认验证 + +- focused unit test +- script smoke + +--- + +## 4. P1 阶段出口判断 + +只有以下全部成立,才允许自动切入 P2: + +1. `decision_packet` 已稳定吸收 council 字段 +2. runtime 已受控接入 council 任务 +3. workbench 已稳定展示支持 / 反对 / 分歧 / follow-up checks +4. visual watch 已能消费 council 建议 +5. 未造成执行边界回归 + +否则状态应写为: + +- `P1 部分完成` +- `P1 待收尾` +- `P1 环境阻塞` + +--- + +## 5. P1 默认验证串 + +建议默认验证顺序: + +1. focused unit tests +2. `tests/unit` +3. `scripts/run_offline_checks.py` +4. `scripts/run_research_council.py` +5. `scripts/run_decision_packet.py` +6. `scripts/run_trading_runtime.py` +7. browser smoke +8. 必要时远端 deploy + smoke diff --git a/docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md new file mode 100644 index 0000000..90dcbce --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md @@ -0,0 +1,179 @@ +# AI_ICT + TradingAgents P2 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P2` 的自动推进。 + +P2 定位: + +> 经验回流、评估闭环、研究竞技场,并进入长期运行与优化模式。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应切入本清单: + +1. `P1` 晋级门已通过 +2. 当前阶段已被标记为 `Research Council P2` +3. P1 展示层和 runtime 接入已稳定 + +--- + +## 3. 默认执行顺序 + +## P2-1 lessons schema 与持久化 + +### 目标 + +建立 lesson store。 + +### 主要文件 + +- `migrations/039_create_research_council_lessons.sql` +- `src/services/research_council/council_memory_service.py` + +### 完成标志 + +- 可持久化 lesson +- 可按 symbol / session / setup_type 检索 + +--- + +## P2-2 backfill lessons + +### 目标 + +把现有 `journal / weekly review / replay / trust` 回填成可用经验。 + +### 主要文件 + +- `scripts/run_research_council_backfill_lessons.py` +- `src/services/journal/*` +- `src/services/trust/*` + +### 完成标志 + +- 历史记录可转成结构化 lesson +- backfill 可重复执行 + +--- + +## P2-3 similarity / memory retrieval + +### 目标 + +让 council 在分析时能够读取相近历史经验。 + +### 主要文件 + +- `src/services/research_council/council_memory_service.py` +- `src/services/research_council/council_context_builder.py` + +### 完成标志 + +- context builder 可注入 compact lessons +- 不挤占主事实层 + +--- + +## P2-4 checkpoint / resume 稳定化 + +### 目标 + +把 council checkpoint 从“存在”推进到“长期稳定可用”。 + +### 主要文件 + +- `src/services/research_council/council_checkpoint_service.py` +- `scripts/run_research_council_replay.py` + +### 完成标志 + +- 中断恢复稳定 +- replay 可用于调试与评估 + +--- + +## P2-5 评估脚本 + +### 目标 + +建立 council 与非 council 的效果比较。 + +### 建议输出维度 + +- operator 决策质量 proxy +- follow-up checks 命中率 +- disagreement 提示有效性 +- session / setup 适配性 + +### 主要文件 + +- `scripts/run_research_council_eval.py` +- `src/services/research_council/` 评估辅助模块 + +### 完成标志 + +- 能周期性生成比较结果 + +--- + +## P2-6 内部研究竞技场 + +### 目标 + +建立内部 council 组合 / setup / model 对比框架。 + +### 范围限制 + +只用于内部: + +- model 赛马 +- setup 对比 +- council 组合实验 + +不用于: + +- 外部平台化 +- follower/copy trade +- rewards + +### 完成标志 + +- 可服务长期迭代 + +--- + +## 4. P2 阶段出口判断 + +P2 不是“做完就停”,而是进入长期运行态。 + +只有以下条件成立,才应把状态写为 `P2 进入长期运行态`: + +1. lesson backfill 稳定 +2. checkpoint / resume 稳定 +3. 评估脚本稳定 +4. 内部竞技场可支撑后续迭代 + +进入长期运行态后的默认动作: + +```text +持续评估 +-> 识别 council 薄弱点 +-> 回到 P2 内部优化 +-> 必要时回流修正 P1 展示层或 P0 契约层 +``` + +--- + +## 5. P2 默认验证串 + +建议默认验证顺序: + +1. focused unit tests +2. `tests/unit` +3. `scripts/run_research_council_backfill_lessons.py` +4. `scripts/run_research_council_replay.py` +5. `scripts/run_research_council_eval.py` +6. 必要时 review packet / workbench smoke diff --git a/docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md new file mode 100644 index 0000000..c483365 --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md @@ -0,0 +1,161 @@ +# AI_ICT + TradingAgents P3 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P3` 的自动化闭环。 + +P3 定位: + +> 把 P2 的 `lessons -> replay -> eval -> arena` 长期运行能力,固化成可重复执行、可持久化、可查询、可回写状态的优化循环。 + +P3 不是新的执行权限阶段,也不是放开真实自动下单。它只负责让研究委员会进入稳定的长期优化模式。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应进入 P3: + +1. `Research Council P2` 已进入长期运行态 +2. lesson backfill 可重复执行 +3. replay / eval / arena 工具均能独立跑通 +4. runtime / decision packet / workbench 边界未被研究层越权 +5. 远端 deploy / smoke 最近一次可通过,或环境阻塞已被明确记录 + +--- + +## 3. 默认执行顺序 + +### P3-1 optimization cycle schema 与 repository + +目标: + +建立优化循环的持久化记录。 + +主要文件: + +- `migrations/041_create_research_council_optimization_cycles.sql` +- `src/repositories/research_council_optimization_repository.py` + +完成标志: + +- 每轮优化循环有唯一 `cycle_key` +- 可记录 lesson / replay / eval / arena summary +- 可查询 latest / recent cycles + +--- + +### P3-2 CouncilOptimizationService + +目标: + +把 P2 的分散工具串成一个稳定的优化循环: + +```text +backfill lessons +-> build fresh council run +-> replay summary +-> eval summary +-> arena summary +-> choose next bounded actions +-> persist optimization cycle +``` + +主要文件: + +- `src/services/research_council/council_optimization_service.py` +- `src/services/research_council/__init__.py` + +完成标志: + +- 一次调用能完成完整 cycle +- 失败不改变 execution gate / live readiness / risk governor +- 输出只作为 operator follow-up 与下一轮优化建议 + +--- + +### P3-3 CLI / API 入口 + +目标: + +提供可自动化触发与可查询的入口。 + +主要文件: + +- `scripts/run_research_council_optimization_loop.py` +- `scripts/deploy_with_password.py` +- `src/api/research_council.py` +- `src/api/server.py` + +建议接口: + +- `POST /trading/research-council/optimization-cycle` +- `GET /trading/research-council/optimization-cycle/latest` +- `GET /trading/research-council/optimization-cycles` + +完成标志: + +- CLI 能输出 JSON +- API 能手动触发一轮 cycle +- latest / recent 查询可用于 smoke 和状态面板 +- password-based remote deploy / smoke 不再依赖本机 `expect` + +--- + +### P3-4 状态锚点与自动化回写 + +目标: + +让 P3 完成后不是停住,而是进入长期优化运行态。 + +主要文件: + +- `SESSION.md` +- `TASKS.md` +- `docs/AI_ICT_全自动开发执行流程_v1.md` + +完成标志: + +- `SESSION.md` 写明当前进入 `Research Council P3 long-running optimization loop` +- `TASKS.md` 记录 P3 已形成闭环 +- 下一步明确为周期性运行 optimization cycle,而不是进入执行权限扩张 + +--- + +## 4. 阶段出口 + +只有同时满足以下条件,P3 才可视为完成闭环: + +1. optimization cycle 表和 repository 已落地 +2. `CouncilOptimizationService.run_cycle(...)` 可稳定执行 +3. CLI / API 均可触发或查询 cycle +4. focused unit tests 通过 +5. migration static checks 通过 +6. 本地质量门通过 +7. 必要时完成 deploy / smoke,或明确记录外部环境阻塞 + +完成后状态应写为: + +```text +state: research_council_p3_optimization_loop_closed +current_phase: Research Council P3 Long-Running Optimization Loop +``` + +--- + +## 5. 边界守卫 + +P3 不允许做以下事情: + +1. 不允许改变 `execution_allowed` +2. 不允许改变 `real_live_ready` +3. 不允许让 arena 结果直接替换默认模型 +4. 不允许让 eval 结果自动升级 trust bucket +5. 不允许引入 copy trading / follower economy / rewards + +P3 的输出只允许进入: + +- operator follow-up +- 下一轮 research council 优化建议 +- eval / arena 对比报告 +- 长期运行状态锚点 diff --git a/docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md new file mode 100644 index 0000000..6fb461e --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md @@ -0,0 +1,145 @@ +# AI_ICT + TradingAgents P4 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P4` 的自动化闭环。 + +P4 定位: + +> 把 P3 的 optimization cycle 结果转成 operator 可读的治理判断:是否 stale、是否需要更多样本、是否出现退化、是否可以开启 bounded optimization slice。 + +P4 不负责自动修改模型、信任分层、执行权限或真实交易边界。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应进入 P4: + +1. `Research Council P3` 已完成 optimization-loop 闭环 +2. `research_council_optimization_cycles` 已有可查询记录 +3. P3 latest / recent API 可用 +4. 远端 deploy / smoke 路径可用,或已明确环境阻塞 + +--- + +## 3. 默认执行顺序 + +### P4-1 optimization review service + +目标: + +读取最近若干轮 optimization cycle,生成治理判断。 + +主要文件: + +- `src/services/research_council/council_optimization_review_service.py` + +完成标志: + +- 可判断 `missing / stale / needs_more_samples / needs_attention / healthy` +- 可计算 latest metrics 与相邻 cycle delta +- 可输出 operator summary 与 bounded recommended actions + +--- + +### P4-2 CLI 入口 + +目标: + +提供自动化可运行的 review smoke 入口。 + +主要文件: + +- `scripts/run_research_council_optimization_review.py` + +完成标志: + +- 支持 JSON 输出 +- 无 cycle 时返回明确 `missing` +- 有 cycle 时返回 review payload + +--- + +### P4-3 API 与 review packet 可见性 + +目标: + +让 operator/workbench 可以看到 P4 治理结果。 + +主要文件: + +- `src/api/research_council.py` +- `src/api/server.py` +- `src/services/operator/operator_workspace_service.py` +- `src/web/app.js` + +建议接口: + +- `GET /trading/research-council/optimization-review` + +完成标志: + +- API 能返回 optimization review +- review packet 包含 `research_council_optimization_review` +- workbench operator console 显示 optimization health / summary / next actions + +--- + +### P4-4 状态锚点与远端验证 + +目标: + +完成本地与远端闭环。 + +主要文件: + +- `SESSION.md` +- `TASKS.md` + +完成标志: + +- focused tests 通过 +- `npm run lint / build / visual:snapshots` 通过 +- 数据库注入脚本 smoke 通过 +- 本地 API smoke 通过 +- 远端 deploy / smoke 通过 +- 状态写为 `research_council_p4_optimization_review_closed` + +--- + +## 4. 阶段出口 + +只有同时满足以下条件,P4 才可视为完成闭环: + +1. optimization review service 已落地 +2. CLI / API / review packet / workbench 均可消费 review +3. review 能明确区分 stale、样本不足、需要关注、健康 +4. review 只输出 operator follow-up,不自动改变模型或执行权限 +5. 本地和远端验证完成,或外部环境阻塞被明确记录 + +完成后状态应写为: + +```text +state: research_council_p4_optimization_review_closed +current_phase: Research Council P4 Optimization Review Governance +``` + +--- + +## 5. 边界守卫 + +P4 不允许做以下事情: + +1. 不允许自动切换默认模型 +2. 不允许自动升级或降级 trust bucket +3. 不允许改变 `execution_allowed` +4. 不允许改变 `real_live_ready` +5. 不允许把 `needs_attention` 直接解释成可交易信号 + +P4 的输出只允许进入: + +- operator summary +- review packet +- workbench optimization health +- 下一轮 bounded optimization slice 建议 diff --git a/docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md new file mode 100644 index 0000000..118c93c --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md @@ -0,0 +1,140 @@ +# AI_ICT + TradingAgents P5 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P5` 的自动化闭环。 + +P5 定位: + +> 把 P4 review 输出的 `add_replay_fixture_with_confirmed_swings` 从 operator 建议变成可重复执行、可部署、可审计的 replay 证据链。 + +P5 不负责改变交易执行权限、模型信任分层、真实交易就绪状态或 operator confirmation protocol。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应进入 P5: + +1. `Research Council P4` 已完成 optimization review governance 闭环 +2. P4 review 返回 `health_state=needs_attention` +3. P4 review 的 `recommended_actions` 包含 `add_replay_fixture_with_confirmed_swings` +4. optimization loop / review CLI 与 API 路径可运行 + +--- + +## 3. 默认执行顺序 + +### P5-1 confirmed swing replay fixture + +目标: +提供一份可部署的 replay 数据,默认 `left_bars=2/right_bars=2` 下能确认 swing high 与 swing low。 + +主要文件: + +- `data/replay/research_council_confirmed_swings_v1.jsonl` + +完成标志: + +- replay 文件通过 candle integrity gate +- `run_replay_file.py` 在 `REPLAY_MIN_CANDLES=9`、`REPLAY_MIN_SWINGS=2`、`REPLAY_MAX_GAP_MINUTES=1` 下通过 +- 默认窗口可检测到 2 个 confirmed swings + +--- + +### P5-2 optimization replay summary evidence + +目标: +让 optimization cycle 的 `replay_summary` 不只输出计数,还能输出可审计的 swing 细节。 + +主要文件: + +- `src/services/research_council/council_optimization_service.py` + +完成标志: + +- `replay_summary.confirmed_swing_count >= 2` +- `replay_summary.swing_counts` 包含 `swing_high` 与 `swing_low` +- `replay_summary.confirmed_swings` 包含 `source_index`、`confirmed_at_index`、`left_bars`、`right_bars` +- `choose_next_actions` 不再输出 `add_replay_fixture_with_confirmed_swings` + +--- + +### P5-3 optimization loop and review rerun + +目标: +用 P5 replay fixture 跑出新一轮 optimization cycle,并让 P4 review 看到缺口已关闭。 + +默认命令: + +```bash +DATABASE_URL='' \ +RESEARCH_COUNCIL_OUTPUT_JSON=1 \ +RESEARCH_COUNCIL_OPTIMIZATION_CYCLE_KEY='research_council_opt:p5_confirmed_swings_20260511' \ +RESEARCH_COUNCIL_REPLAY_FILE='D:/ai-exchange/data/replay/research_council_confirmed_swings_v1.jsonl' \ +D:/ai-exchange/.venv/Scripts/python.exe D:/ai-exchange/scripts/run_research_council_optimization_loop.py +``` + +完成标志: + +- latest cycle 的 `replay_summary.confirmed_swing_gate_passed=true` +- latest cycle 的 `next_actions` 不包含 `add_replay_fixture_with_confirmed_swings` +- `run_research_council_optimization_review.py` 返回 `health_state=healthy` 或只剩样本量相关状态 + +--- + +### P5-4 deploy and remote smoke + +目标: +确保远端系统也拥有同一份 replay 证据,并且远端 API 能跑出/读取 P5 cycle。 + +主要文件: + +- `Dockerfile` +- `scripts/deploy_with_password.py` + +完成标志: + +- Docker 镜像包含 `/app/data/replay/research_council_confirmed_swings_v1.jsonl` +- 远端 deploy / smoke 通过 +- 远端 `POST /trading/research-council/optimization-cycle` 可使用 P5 replay 文件 +- 远端 `GET /trading/research-council/optimization-review` 返回不再包含 `add_replay_fixture_with_confirmed_swings` + +--- + +## 4. 阶段出口 + +只有同时满足以下条件,P5 才可视为完成闭环: + +1. confirmed swing replay fixture 已落地并通过质量门 +2. optimization replay summary 已包含 swing evidence +3. 新 cycle 已消除 `add_replay_fixture_with_confirmed_swings` +4. review / review packet / workbench 可读取 P5 后的状态 +5. 本地与远端验证完成,或外部环境阻塞被明确记录 + +完成后状态应写为: + +```text +state: research_council_p5_confirmed_swing_replay_closed +current_phase: Research Council P5 Confirmed Swing Replay Evidence +``` + +--- + +## 5. 边界守卫 + +P5 不允许做以下事情: + +1. 不允许把 replay fixture 结果解释为真实可交易信号 +2. 不允许修改 `execution_allowed` +3. 不允许修改 `real_live_ready` +4. 不允许自动升级或降级 trust bucket +5. 不允许绕过 operator confirmation protocol + +P5 的输出只允许进入: + +- replay evidence +- optimization cycle payload +- optimization review +- review packet / workbench +- 下一轮 bounded optimization 建议 diff --git a/docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md b/docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md new file mode 100644 index 0000000..9b41ee9 --- /dev/null +++ b/docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md @@ -0,0 +1,137 @@ +# AI_ICT + TradingAgents P6 自动任务清单 v1 + +## 1. 文档目标 + +本清单用于驱动 `Research Council P6` 的自动化闭环。 + +P6 定位: + +> 在 P5 已恢复 healthy review 后,提供一个可调度的 governance tick,让自动化流程能判断当前应 no-op、补跑 optimization cycle,还是打开 bounded slice。 + +P6 不新增交易能力,不改变 execution gate、live readiness、risk governor、trust bucket 或 operator confirmation protocol。 + +--- + +## 2. 启动条件 + +只有满足以下条件,自动化流程才应进入 P6: + +1. `Research Council P5` 已完成 confirmed-swing replay evidence 闭环 +2. latest optimization review 为 `healthy` +3. `recommended_actions` 为 `continue_periodic_optimization_cycle` +4. `can_open_bounded_slice=false` + +--- + +## 3. 默认执行顺序 + +### P6-1 governance tick service + +目标: +把 optimization review 的状态转成自动化可消费的 tick status。 + +主要文件: + +- `src/services/research_council/council_governance_tick_service.py` + +完成标志: + +- `healthy` -> `no_op_healthy` +- `stale/missing` -> `cycle_required` +- `needs_attention` -> `bounded_slice_required` +- 只有显式开启 `auto_run_stale_cycle` 或 `force_cycle` 才补跑 optimization cycle + +--- + +### P6-2 CLI 入口 + +目标: +提供可被计划任务、远端 cron 或人工 smoke 调用的入口。 + +主要文件: + +- `scripts/run_research_council_governance_tick.py` + +完成标志: + +- 支持 JSON 输出 +- 支持 review 参数 +- 支持受控 cycle 参数 +- 默认 healthy 时只输出 no-op,不写交易状态 + +--- + +### P6-3 API 入口 + +目标: +让远端服务可通过 HTTP 执行 governance tick。 + +主要文件: + +- `src/api/research_council.py` +- `src/api/server.py` + +接口: + +- `POST /trading/research-council/governance-tick` + +完成标志: + +- healthy tick 返回 `status=no_op_healthy` +- tick 输出 `safe_to_auto_continue` +- tick 输出 `requires_cycle_run` +- tick 输出 `requires_bounded_slice` + +--- + +### P6-4 状态锚点与远端验证 + +目标: +完成本地与远端闭环。 + +完成标志: + +- focused tests 通过 +- `npm run lint / build / visual:snapshots` 通过 +- 本地 CLI smoke 通过 +- 本地 API smoke 通过 +- 远端 deploy / smoke 通过 +- 远端 governance tick 返回 `no_op_healthy` + +--- + +## 4. 阶段出口 + +只有同时满足以下条件,P6 才可视为完成闭环: + +1. governance tick 能稳定读取 optimization review +2. healthy 状态不会触发无意义写操作 +3. needs_attention 状态只返回 bounded action,不自动越权修复 +4. stale/missing 状态只有显式授权才补跑 cycle +5. 本地与远端验证完成,或外部环境阻塞被明确记录 + +完成后状态应写为: + +```text +state: research_council_p6_governance_tick_closed +current_phase: Research Council P6 Governance Tick +``` + +--- + +## 5. 边界守卫 + +P6 不允许做以下事情: + +1. 不允许把 governance tick 结果解释为真实可交易信号 +2. 不允许修改 `execution_allowed` +3. 不允许修改 `real_live_ready` +4. 不允许自动升级或降级 trust bucket +5. 不允许绕过 operator confirmation protocol + +P6 的输出只允许进入: + +- automation status +- operator summary +- optimization governance logs +- 下一轮 bounded optimization 建议 diff --git a/docs/AI_ICT_TradingAgents融合实施方案_v1.md b/docs/AI_ICT_TradingAgents融合实施方案_v1.md new file mode 100644 index 0000000..c5e05ab --- /dev/null +++ b/docs/AI_ICT_TradingAgents融合实施方案_v1.md @@ -0,0 +1,691 @@ +# AI_ICT + TradingAgents 融合实施方案 v1 + +## 1. 文档目标 + +本方案用于明确以下问题: + +1. 为什么应当选择“以 AI_ICT 为基础,把 TradingAgents 的能力加进来” +2. TradingAgents 的哪些能力值得引入,哪些能力不应直接引入 +3. 融合后的系统边界、数据流、模块落点、阶段顺序分别是什么 +4. 如何在不破坏当前 `Real-time Assisted Trading V1` 主线的前提下推进融合 + +本方案是实施方案,不是概念讨论稿。默认以当前仓库现状为前提: + +- `D:\ai-exchange` 继续作为主系统 +- TradingAgents 作为“多智能体研究与解释子层”被接入 +- 不默认开启真实自动下单 +- 不允许新引入的智能体层绕过现有风控、信任分层、人工确认边界 + +--- + +## 2. 结论先行 + +结论明确如下: + +> 选择 `AI_ICT / ai-exchange` 作为主干系统,把 `TradingAgents` 改造成其下游的多智能体研究委员会能力层,而不是反过来。 + +原因不是“哪个更先进”,而是谁更适合做系统主骨架: + +- `ai-exchange` 已具备真实市场真值、真实账户只读同步、运行时调度、决策包、告警、风控、执行确认、研究复盘闭环 +- `TradingAgents` 的长板是多角色分析、辩论、结构化输出、检查点恢复、经验记忆 +- 前者更适合做生产主干,后者更适合做分析增强层 + +因此,正确的融合方向不是“换底座”,而是: + +```text +AI_ICT 负责事实、规则、边界、执行安全 +TradingAgents 负责分析、辩论、解释、补充视角 +``` + +--- + +## 3. 当前主系统定位 + +当前 `ai-exchange` 的系统定位应保持不变: + +```text +实时市场与账户真值 +-> 盘前计划 / 盘中候选 +-> 风控与信任分层 +-> 决策包与提醒 +-> 人工确认 / paper / semi-auto +-> journal / weekly review / trust feedback +``` + +当前仓库里已经存在并应继续作为主锚点的模块包括: + +- `src/services/market_state/` +- `src/services/runtime/trading_runtime_service.py` +- `src/services/decision/decision_packet_service.py` +- `src/services/trust/model_trust_service.py` +- `src/services/execution/` +- `src/services/operator/operator_workspace_service.py` +- `src/services/journal/` +- `src/services/alerts/` +- `src/services/visual_watch/` +- `src/api/server.py` +- `src/web/app.js` + +这条主干已经与当前产品边界一致: + +- `analysis_only` +- `restricted_decision_support` +- `decision_support_only` +- `trade_inside_framework` +- `observe_only` + +融合 TradingAgents 时,不能破坏这条边界。 + +--- + +## 4. TradingAgents 真正值得借的能力 + +## 4.1 应借入的能力 + +应优先借入以下能力: + +1. 多角色分析编排 + - Market Analyst + - News Analyst + - Social/Sentiment Analyst + - Fundamentals Analyst + +2. 对抗式研究结构 + - Bull Researcher + - Bear Researcher + - Research Manager + +3. 风险辩论层 + - Aggressive + - Neutral + - Conservative + +4. 结构化输出 + - `ResearchPlan` + - `TraderProposal` + - `PortfolioDecision` + +5. 长链路恢复能力 + - checkpoint / resume + +6. 决策记忆机制 + - decision log + - reflection memory + +这些能力对 `ai-exchange` 的价值是: + +- 让当前决策包更会解释 +- 让同一候选在不同视角下被交叉审视 +- 让人工操作台看到“支持理由 + 反对理由 + 风险质询” +- 让 weekly review 的反馈真正回流到下一轮决策 + +## 4.2 不应直接借入的能力 + +以下能力不应作为当前阶段的主线引入: + +1. 以 `yfinance / alpha_vantage` 为主的外部数据层 +2. TradingAgents 原生的“模拟交易框架替代执行主线” +3. 直接让 TradingAgents 生成可越权执行的交易决定 +4. 直接复制其 CLI 作为主产品入口 + +原因很明确: + +- `ai-exchange` 已有更贴近当前目标的真值层和执行边界 +- TradingAgents 的原生数据源和研究场景偏股票研究框架 +- 当前系统需要的是“增强解释层”,不是“替换生产主线” + +--- + +## 5. 融合后的目标系统定位 + +融合后的系统不应变成“另一个 TradingAgents”,而应变成: + +> 一个具备多智能体研究委员会能力的 AI_ICT 实盘辅助决策系统 + +其目标形态如下: + +```text +真实市场 / 账户 / 风控事实 +-> AI_ICT 生成 trading plan / candidate / signal / trust / risk +-> 研究委员会对当前机会做多视角分析与辩论 +-> 输出委员会结论、分歧点、风险提示、观察清单 +-> 写回 decision packet / review packet / operator workbench +-> 仍由 AI_ICT 的风险边界决定是否允许后续动作 +``` + +这意味着: + +- TradingAgents 只增强“解释与判断质量” +- AI_ICT 继续掌管“事实与执行边界” + +--- + +## 6. 核心设计原则 + +## 6.1 AI_ICT 是 system of record + +以下事实只能以 `ai-exchange` 为准: + +- 市场状态 +- 账户状态 +- 风险结果 +- trust classification +- candidate executable status +- execution ticket status +- journal / weekly review + +TradingAgents 子层只能读取这些事实,不能自行定义一套平行真相。 + +## 6.2 TradingAgents 是研究委员会,不是执行引擎 + +TradingAgents 子层允许: + +- 解释 +- 质疑 +- 总结 +- 对比 +- 形成委员会观点 + +TradingAgents 子层不允许: + +- 直接下单 +- 直接确认 ticket +- 直接改写 risk governor 结论 +- 直接改写 trust bucket + +## 6.3 先接入“事实消费”,后接入“结果影响” + +推进顺序必须是: + +```text +先读取 AI_ICT 输出 +-> 再生成委员会结论 +-> 再把结论显示出来 +-> 最后才决定是否让结论参与 decision packet 排序或提醒 +``` + +不要一开始就让委员会输出参与执行路径。 + +## 6.4 先影子运行,后受控影响 + +P0 阶段只允许: + +- 影子运行 +- 不影响现有 action recommendation +- 不影响 execution gate + +P1 才允许其进入决策包展示与优先级提示层。 + +## 6.5 不引入第二套产品主入口 + +当前产品入口仍应保持: + +- `/` +- `/training` +- `/trading/*` + +不把 TradingAgents CLI 直接当作产品主入口;最多只保留为内部调试工具或后台适配层。 + +--- + +## 7. 总体架构 + +建议新增一个新的服务域: + +```text +src/services/research_council/ +``` + +建议结构如下: + +```text +src/services/research_council/ + __init__.py + council_context_builder.py + council_orchestrator.py + council_schema.py + council_memory_service.py + council_checkpoint_service.py + council_mapper_service.py + tradingagents_adapter.py +``` + +### 各模块职责 + +- `council_context_builder.py` + - 从 `ai-exchange` 当前事实构建委员会输入上下文 + +- `tradingagents_adapter.py` + - 适配 TradingAgents 的分析/辩论流程 + - 替换其原始数据工具输入,改为消费 AI_ICT 已有事实 + +- `council_schema.py` + - 定义委员会输出结构 + - 不直接复用 TradingAgents 原始 markdown 作为系统契约 + +- `council_mapper_service.py` + - 将委员会结果映射为 `decision packet` / `review packet` 可消费的字段 + +- `council_memory_service.py` + - 将 journal / weekly review / backtest / trust 反馈转为委员会的经验记忆 + +- `council_checkpoint_service.py` + - 管理长链路 LLM 运行的恢复能力 + +- `council_orchestrator.py` + - 提供统一入口,例如 `run_once()` / `build_preview()` / `persist_run()` + +--- + +## 8. 输入输出契约 + +## 8.1 委员会输入 + +委员会的输入必须来自 AI_ICT 当前已存在的结构化结果,而不是让 TradingAgents 自己去抓市场。 + +输入至少包括: + +1. `market_state` +2. `latest_trading_plan` +3. `latest_candidate` +4. `latest_signal` +5. `risk_result` +6. `trust_policy` +7. `broker_snapshot_summary` +8. `recent_journal_entries` +9. `weekly_review_summary` +10. `recent_alerts` +11. `session_focus_summary` + +其中,市场和账户事实由以下现有模块提供: + +- `src/services/market_state/` +- `src/services/broker/` +- `src/services/reconciliation/` +- `src/services/journal/` +- `src/services/trust/` +- `src/services/operator/` + +## 8.2 委员会输出 + +建议定义统一输出结构 `CouncilDecision`,最少包含: + +- `overall_stance` +- `committee_confidence` +- `market_view` +- `bull_case` +- `bear_case` +- `risk_challenges` +- `operator_summary` +- `agreement_points` +- `disagreement_points` +- `missing_information` +- `follow_up_checks` +- `recommended_watch_items` +- `execution_veto_signals` +- `source_snapshot_meta` + +说明: + +- 这个输出是解释层契约 +- 不是新的执行契约 +- `execution_veto_signals` 只能作为提醒参考,不能直接代替 Risk Governor + +--- + +## 9. 与现有模块的落点关系 + +## 9.1 `DecisionPacketService` + +落点文件: + +- `src/services/decision/decision_packet_service.py` + +计划变更: + +- 新增 `research_council_summary` +- 新增 `committee_confidence` +- 新增 `agreement_points` +- 新增 `disagreement_points` +- 新增 `follow_up_checks` +- 新增 `operator_questions` + +规则: + +- 若委员会运行失败,不影响当前 `DecisionPacket` 主体生成 +- `why_allowed` / `why_blocked` 仍以现有风险与信任逻辑为准 +- 委员会只能增强解释,不得覆盖现有 `action_recommendation` + +## 9.2 `TradingRuntimeScheduler` + +落点文件: + +- `src/services/runtime/trading_runtime_service.py` + +计划变更: + +- 在 `watch` 之后、`decision_packet` 之前增加新任务 `research_council` + +建议顺序: + +```text +plan +-> market_state +-> broker_sync +-> reconciliation +-> watch +-> research_council +-> decision_packet +-> alert_dispatch +``` + +规则: + +- 只在存在 active plan 或 recent candidate 时触发 +- runtime 失败只记 warning / blocker,不直接改动执行边界 +- 初期允许关闭开关运行 + +## 9.3 `ReviewPacketService` + +落点文件: + +- `src/services/operator/operator_workspace_service.py` +- `scripts/run_review_packet.py` + +计划变更: + +- 在 `review packet` 中加入委员会摘要 +- 支持查看最近一次委员会分歧 +- 支持查看“本次为何仍建议观察 / 为何不建议执行” + +## 9.4 `Workbench` + +落点文件: + +- `src/web/app.js` +- `src/web/index.html` + +计划变更: + +- 在 decision packet 区域新增“委员会视角” +- 新增 bull / bear / risk 三栏或折叠区 +- 新增“分歧点”和“补充检查项” +- 不新增第二套独立大页面,优先融入现有 operator console + +## 9.5 `ModelTrustService` + +落点文件: + +- `src/services/trust/model_trust_service.py` + +计划变更: + +- 短期不让委员会改写 trust bucket +- 中期允许委员会结果被记录为 trust 观察素材 +- 长期才考虑引入“委员会质量反馈”作为 trust 附加维度 + +规则: + +- `analysis_only_allowed` +- `restricted_decision_support_allowed` +- `decision_support_allowed` +- `execution_allowed` + +仍由现有 `ModelTrustService` 决定。 + +--- + +## 10. 数据持久化建议 + +建议新增以下表或等价持久化对象: + +1. `research_council_runs` + - 一次委员会运行的主记录 + +2. `research_council_messages` + - 各角色输出片段 + +3. `research_council_checkpoints` + - 长链路恢复锚点 + +4. `research_council_lessons` + - 从 journal / weekly review 提炼出的结构化经验 + +5. `research_council_links` + - 将一次委员会运行关联到 + - `trading_plan_id` + - `candidate_id` + - `signal_id` + - `decision_packet_id` + - `execution_ticket_id` + +注意: + +- 不建议把 `~/.tradingagents` 直接当正式生产存储 +- TradingAgents 的 memory/checkpoint 设计可以借鉴 +- 正式系统记录仍应写回 `ai-exchange` 自己的数据库与 review/journal 体系 + +--- + +## 11. P0 / P1 / P2 实施计划 + +## P0:影子接入,不影响现有决策 + +### 目标 + +把 TradingAgents 作为只读研究委员会接入,能够围绕 AI_ICT 当前事实生成一份委员会结论,但不影响现有执行路径。 + +### 范围 + +1. 新增 `research_council` 服务域 +2. 做 AI_ICT -> Committee 的上下文构建 +3. 跑通最小委员会链路: + - Market view + - Bull case + - Bear case + - Risk debate + - Operator summary +4. 持久化委员会结果 +5. 新增手动触发入口 +6. 新增只读查看入口 + +### 建议新增接口 + +- `POST /trading/research-council/run` +- `GET /trading/research-council/latest` +- `GET /trading/research-council/runs` + +### 落点 + +- `src/services/research_council/*` +- `src/api/server.py` +- `src/services/operator/operator_workspace_service.py` +- `scripts/run_review_packet.py` + +### 验收 + +1. 没有委员会结果时,现有系统行为不变 +2. 有委员会结果时,review packet 能展示委员会摘要 +3. runtime 不依赖委员会成功才可继续 +4. 任何委员会异常都不能导致 ticket / execution 主链路失效 + +### 本阶段禁止事项 + +- 禁止委员会改写 `action_recommendation` +- 禁止委员会改写 `risk_result` +- 禁止委员会改写 `execution_allowed` + +--- + +## P1:受控进入决策包与运行时 + +### 目标 + +让委员会结果成为当前决策包与 operator workbench 的正式解释层组成部分,但仍不替代风控和 trust。 + +### 范围 + +1. 在 runtime 中增加 `research_council` 任务 +2. 在 `DecisionPacket` 中加入委员会摘要字段 +3. 增加分歧显示与 follow-up 检查项 +4. 增加委员会 checkpoint / resume +5. 增加委员会 memory 回流: + - 来自 journal + - 来自 weekly review + - 来自 trust 的 session recommendations + +### 关键设计 + +委员会结果在这一阶段可以影响: + +- operator 注意力分配 +- 决策包展示优先级 +- alert 文案丰富度 +- visual watch 触发建议 + +但不能影响: + +- execution gate +- live readiness +- operator confirmation protocol + +### 落点 + +- `src/services/runtime/trading_runtime_service.py` +- `src/services/decision/decision_packet_service.py` +- `src/services/visual_watch/visual_trigger_policy_service.py` +- `src/web/app.js` + +### 验收 + +1. decision packet 中能看到委员会结论 +2. review packet 中能看到支持与反对意见 +3. council 运行失败时系统自动降级,不阻塞现有任务 +4. visual watch 可选地引用委员会的 follow-up checks + +--- + +## P2:经验回流、评估闭环、研究竞技场 + +### 目标 + +把委员会从“会说”推进到“会从历史里学”,并建立内部评估机制,判断其是否真的提高了辅助决策质量。 + +### 范围 + +1. 建立 setup / session / symbol 级别的委员会经验回流 +2. 将 journal / weekly review / plan replay 结果映射为委员会 lesson +3. 增加委员会效果评估: + - 单层决策 vs 委员会决策 + - 有分歧提示 vs 无分歧提示 + - 不同 analyst 组合对结果的影响 +4. 建立内部 challenge 机制 + +### 说明 + +这一阶段可以借鉴 `AI-Trader-main` 的两个思路,但只借内部研究能力,不借平台化产品方向: + +1. `challenge` +2. `team mission` + +建议用途: + +- 做内部模型赛马 +- 做多分析策略对比 +- 做不同委员会编排方式对比 + +不建议用途: + +- 对外社区化 +- 跟单平台化 +- 积分和 follower 经济 + +### 验收 + +1. 能客观比较委员会模式是否提升 operator 决策质量 +2. 能识别哪些 session / setup 最适合启用委员会 +3. 能从失败案例里提炼新的 follow-up checks + +--- + +## 12. 明确不做的事 + +当前阶段明确不做以下事情: + +## 12.1 不以 TradingAgents 取代 AI_ICT 主干 + +不做: + +- 用 TradingAgents 重写 market truth +- 用 TradingAgents 重写 runtime scheduler +- 用 TradingAgents 重写 risk governor +- 用 TradingAgents 重写 execution ticket + +## 12.2 不把 TradingAgents 原生数据层当主数据源 + +不做: + +- 用 `yfinance` 替代 OKX 主真值 +- 用 `alpha_vantage` 替代当前真实市场/账户事实 + +## 12.3 不把研究输出直接当执行指令 + +不做: + +- 委员会直接下单 +- 委员会直接确认 ticket +- 委员会直接开启 live + +## 12.4 不引入 AI-Trader-main 的平台化社区主线 + +当前不做: + +- copy trading +- follower economy +- rewards / points +- signal marketplace + +这些能力只有当产品目标改成“外部平台化交易社区”时才讨论。 + +--- + +## 13. 推荐执行顺序 + +推荐顺序如下: + +1. 先完成当前 `AI_ICT` 主线的 N4 收尾与外部环境恢复 +2. 再推进本方案 `P0` +3. `P0` 稳定后再进入 `P1` +4. `P1` 验证其对 operator 有真实帮助后,再考虑 `P2` + +换句话说: + +```text +先稳住 AI_ICT 主骨架 +-> 再接入委员会影子层 +-> 再让委员会参与解释层 +-> 最后再做经验回流和内部评估闭环 +``` + +--- + +## 14. 最终结论 + +最终建议可以压缩成一句话: + +> 保留 AI_ICT 的事实层、规则层、执行边界层,把 TradingAgents 改造成其下游的多智能体研究委员会,而不是反向重构底座。 + +这条路线的优点是: + +1. 保住当前仓库已经形成的生产主干 +2. 最大化吸收 TradingAgents 的高价值能力 +3. 不引入第二套平行真相 +4. 不把研究框架误用成执行框架 +5. 能按 `P0 -> P1 -> P2` 逐步验证收益,而不是一次性大重构 + +如果后续继续推进,下一份文档应直接落到: + +- `P0` 任务清单 +- 数据表草案 +- API 草案 +- `research_council` 目录脚手架说明 diff --git a/docs/AI_ICT_TradingAgents融合实施方案_最终版.md b/docs/AI_ICT_TradingAgents融合实施方案_最终版.md new file mode 100644 index 0000000..cad70a5 --- /dev/null +++ b/docs/AI_ICT_TradingAgents融合实施方案_最终版.md @@ -0,0 +1,1123 @@ +# AI_ICT + TradingAgents 融合实施方案 最终版 + +## 0. 本文档如何合成 + +本文档综合以下两份已有方案,吸收其长处并统一为一个可执行版本: + +1. `AI_ICT_方案一系统边界图与模块融合图_v1.md` +2. `AI_ICT_TradingAgents融合实施方案_v1.md` + +本最终版采用以下合并原则: + +- 保留方案一在“系统边界图、模块连接图、最小输出形态、角色设计直觉”上的优势 +- 保留实施方案 v1 在“分阶段实施、模块落点、数据契约、持久化、禁区控制”上的优势 +- 对两者存在歧义的地方做统一裁决,而不是简单拼接 + +统一裁决如下: + +1. 主系统仍然是 `AI_ICT / ai-exchange` +2. TradingAgents 不作为底座,只作为研究推理增强层 +3. 新增服务域命名采用更明确的: + +```text +src/services/research_council/ +``` + +而不是过于宽泛的 `src/services/research/` + +原因: + +- 更能体现“研究委员会”定位 +- 避免和当前仓库中已有的 `research/weekly-review` 语义混淆 +- 更利于后续做边界控制与灰度开关 + +--- + +## 1. 一句话定位 + +> **AI_ICT 继续做“实盘辅助分析决策底座”,TradingAgents 只做“研究推理增强层”。** + +进一步展开就是: + +```text +AI_ICT 负责事实、规则、边界、执行安全 +TradingAgents 负责分析、辩论、解释、反思、补充视角 +``` + +这不是“换底座”,而是“给现有底座加一个研究委员会协处理层”。 + +--- + +## 2. 最终系统边界总图 + +```text +┌──────────────────────────────────────────────────────┐ +│ AI_ICT 主系统 │ +│ (实盘辅助分析决策底座,继续做核心) │ +├──────────────────────────────────────────────────────┤ +│ 1. Market Truth / Stream / REST Fallback │ +│ 2. Broker Read Sync / Reconciliation │ +│ 3. Runtime Scheduler / Runtime Health │ +│ 4. Launch / Readiness / Live Boundary │ +│ 5. Decision Packet / Review Packet │ +│ 6. Alert / Operator / Visual Watch │ +│ 7. Journal / Weekly Review / Trust Feedback │ +└──────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Research Council Enhancement Layer │ +│ (吸收 TradingAgents 思想与能力,但不反客为主) │ +├──────────────────────────────────────────────────────┤ +│ A. Structure Analyst │ +│ B. News/Event Analyst │ +│ C. Sentiment/Flow Analyst (optional later) │ +│ D. Visual Analyst │ +│ E. Risk Analyst │ +│ F. Bull / Bear Debate │ +│ G. Research Summary / Operator Summary │ +│ H. Reflection Memory / Checkpoint Resume │ +└──────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ AI_ICT 输出层(仍由 AI_ICT 主导) │ +├──────────────────────────────────────────────────────┤ +│ - enriched decision packet │ +│ - enriched review packet │ +│ - workbench explanation │ +│ - operator next actions │ +│ - visual follow-up suggestions │ +└──────────────────────────────────────────────────────┘ +``` + +关键点: + +- Research Council 在 AI_ICT 之下,不在 AI_ICT 之上 +- 输出最终仍由 AI_ICT 管理和发布 +- 任何研究层结论都不能绕开 AI_ICT 的实盘边界 + +--- + +## 3. 哪些模块必须保留 AI_ICT 原生 + +以下模块属于 AI_ICT 的主干资产,不允许让位给 TradingAgents。 + +## 3.1 实盘状态底座 + +必须保留 AI_ICT 原生: + +- `market truth` +- `market stream` +- `rest fallback` +- `data gap detection` +- `session/news window` +- `market_state` + +原因: + +- 这是当前系统对真实盘中环境的适配基础 +- TradingAgents 原生更偏研究框架,不擅长承担这类持续实时工程职责 + +## 3.2 账户只读与实盘环境适配 + +必须保留 AI_ICT 原生: + +- broker read model +- reconciliation +- runtime env / proxy / credentials +- launch readiness / live readiness boundary + +原因: + +- 这是当前仓库最重要的工程护城河 +- 它决定系统是否真正贴近实盘,而不只是“研究上看起来合理” + +## 3.3 Decision Packet 主输出 + +必须保留 AI_ICT 原生: + +- `decision_packet_service` +- `review_packet` +- `why_allowed / why_blocked` +- `trust / restricted_decision_support` +- `operator_mode` + +原因: + +- TradingAgents 应增强它,而不是替代它 +- 这里是当前产品边界的核心载体 + +## 3.4 Alert / Operator / Visual 闭环 + +必须保留 AI_ICT 原生: + +- alerts +- operator notes +- visual watch +- visual reviews +- runtime incident semantics + +原因: + +- 这些能力已经和当前主线紧密耦合 +- 它们是“实盘辅助系统”体验层的核心资产 + +## 3.5 风控、信任、确认协议 + +必须保留 AI_ICT 原生: + +- `risk_governor` +- `model_trust_service` +- `execution_ticket` +- `operator_confirmation_protocol` +- `live adapter readiness` + +原因: + +- 这些模块定义了“哪些事绝对不能被研究层覆盖” +- TradingAgents 只能提供补充论证,不能接管这些边界 + +--- + +## 4. 哪些能力来自 TradingAgents,但必须做本地化改造 + +这一部分不是“原样搬进来”,而是“借能力、改角色、适配领域”。 + +## 4.1 应借入的能力 + +优先吸收以下能力: + +1. 多角色分析编排 +2. 正反对抗式研究结构 +3. 风险辩论机制 +4. 结构化输出契约 +5. checkpoint / resume +6. decision log / reflection memory + +## 4.2 不应照搬的地方 + +以下内容不应直接照搬: + +1. 股票研究导向的数据工具默认值 + - `yfinance` + - `alpha_vantage` + +2. 原生 Analyst 角色命名 + - 不能机械复制为 equities 场景的 analyst 组合 + +3. 原生 CLI 作为主入口 +4. 原生 simulated exchange 语义进入主执行链路 + +原因: + +- 当前 `ai-exchange` 更偏 `OKX + ICT + 实盘辅助决策` 场景 +- 角色、输入源、输出目标必须做领域化改造 + +--- + +## 5. 最终角色设计 + +这是本最终版相对于前两份文档最重要的统一裁决之一。 + +## 5.1 不是照抄 TradingAgents 的 analyst 列表 + +由于当前主场景是: + +- crypto / ICT +- 实盘辅助决策 +- 结构驱动 +- 可视化复核 + +所以不应把 TradingAgents 的 `fundamentals analyst` 作为默认角色。 + +## 5.2 最终建议的角色分层 + +### P0 默认角色 + +- `structure_analyst` +- `risk_analyst` +- `bull_researcher` +- `bear_researcher` +- `research_manager` + +### P1 增量角色 + +- `news_event_analyst` +- `visual_analyst` + +### P2 可选角色 + +- `sentiment_flow_analyst` +- `cross_session_reflection_agent` + +## 5.3 各角色职责 + +### `structure_analyst` + +负责: + +- 解读当前结构事实 +- 解释 session / swing / range / liquidity / sweep / MSS / FVG +- 不重新计算底层结构事实,只解释其组合意义 + +### `risk_analyst` + +负责: + +- 读取 Risk Governor 输出 +- 明确执行风险、环境风险、模型信任风险 +- 输出“不该动手”的理由 + +### `news_event_analyst` + +负责: + +- 解读 news window / macro event / high-impact events +- 增强盘中“当前环境解释” +- 不自行声明事实优先级高于 `market_state` + +### `visual_analyst` + +负责: + +- 吸收 visual watch / snapshot / review 结果 +- 将视觉复核转成“额外确认”或“额外风险提示” +- 不单独作为执行许可来源 + +### `bull_researcher` / `bear_researcher` + +负责: + +- 基于共同事实做正反论证 +- 输出支持做、反对做的最好论据 +- 明确哪些只是观点,哪些来自现有结构化事实 + +### `research_manager` + +负责: + +- 汇总整体结论 +- 给 operator 生成可读的委员会摘要 +- 不直接改写最终 `action_recommendation` + +--- + +## 6. 最终模块连接方式 + +```text +Market Truth +Broker Snapshot +Plan / Candidate / Signal +Risk Result +Trust Summary +Visual Review +Operator Notes +Journal / Weekly Review + │ + ▼ +Research Context Builder + │ + ▼ +Research Council Orchestrator + ├─ Structure Analyst + ├─ Risk Analyst + ├─ News/Event Analyst + ├─ Visual Analyst + ├─ Bull Researcher + ├─ Bear Researcher + └─ Research Manager + │ + ▼ +Debate Service + │ + ▼ +Research Output Formatter / Mapper + │ + ▼ +Decision Packet Enricher + │ + ├─ Decision Packet + ├─ Review Packet + ├─ Workbench UI + └─ Visual Follow-up Suggestions +``` + +统一原则: + +- Context Builder 只消费 AI_ICT 现有事实 +- Orchestrator 负责组织角色,不负责业务规则裁决 +- Debate Service 强化解释力,不负责执行许可裁决 +- 最终映射仍回到 AI_ICT 的 packet / review / workbench + +--- + +## 7. 最终代码结构建议 + +最终建议目录结构如下: + +```text +src/services/research_council/ +├── __init__.py +├── council_context_builder.py +├── council_orchestrator.py +├── council_schema.py +├── council_output_formatter.py +├── council_mapper_service.py +├── council_memory_service.py +├── council_checkpoint_service.py +├── tradingagents_adapter.py +├── debate_service.py +└── agents/ + ├── structure_analyst.py + ├── news_event_analyst.py + ├── visual_analyst.py + ├── risk_analyst.py + ├── bull_researcher.py + ├── bear_researcher.py + └── research_manager.py +``` + +其中: + +- `tradingagents_adapter.py` 用于适配 TradingAgents 的编排与结构化输出能力 +- `agents/` 是 AI_ICT 领域化后的角色定义 +- `council_mapper_service.py` 用于把研究结果映射到 AI_ICT 输出契约 + +--- + +## 8. 输入输出契约 + +## 8.1 输入契约 + +Research Council 必须优先消费 AI_ICT 已有结果,而不是自己重新抓取全套状态。 + +### 最小输入集 + +1. `market_state` +2. `latest_trading_plan` +3. `latest_candidate` +4. `latest_signal` +5. `risk_result` +6. `trust_policy` +7. `broker_snapshot_summary` +8. `recent_journal_entries` +9. `weekly_review_summary` +10. `recent_alerts` +11. `visual_review_summary` +12. `session_focus_summary` +13. `operator_notes_summary` + +### 来源模块 + +- `src/services/market_state/` +- `src/services/broker/` +- `src/services/reconciliation/` +- `src/services/decision/` +- `src/services/trust/` +- `src/services/journal/` +- `src/services/operator/` +- `src/services/visual_watch/` + +### 关键原则 + +> **Research layer 不重新定义事实,只解释和推理事实。** + +--- + +## 8.2 输出契约 + +统一定义委员会输出结构 `CouncilDecision`。 + +建议最少包含: + +- `overall_stance` +- `committee_confidence` +- `research_summary` +- `operator_summary` +- `structure_analysis` +- `risk_analysis` +- `news_event_analysis` +- `visual_analysis` +- `bull_case` +- `bear_case` +- `net_assessment` +- `agreement_points` +- `disagreement_points` +- `missing_information` +- `follow_up_checks` +- `next_confirmation_targets` +- `recommended_watch_items` +- `execution_veto_signals` +- `source_snapshot_meta` + +### 解释 + +- `execution_veto_signals` 只是研究层的风险提醒 +- 它不能直接替代 `Risk Governor` +- `committee_confidence` 是解释层信心,不是执行许可 + +--- + +## 8.3 最小融合后的目标输出形态 + +### 决策包应升级为 + +```python +{ + "action_recommendation": "...", + "status": "...", + "why_allowed": [...], + "why_blocked": [...], + + "research_summary": "...", + "committee_confidence": 0.0, + "structure_analysis": {...}, + "risk_analysis": {...}, + "news_event_analysis": {...}, + "visual_analysis": {...}, + + "bull_case": {...}, + "bear_case": {...}, + "net_assessment": "...", + "agreement_points": [...], + "disagreement_points": [...], + "follow_up_checks": [...], + "next_confirmation_targets": [...], + "recommended_watch_items": [...], + + "trust_classification": "...", + "operator_mode": "...", +} +``` + +### 复盘包应升级为 + +```python +{ + "decision_packet": {...}, + "visual_reviews": [...], + "operator_notes": [...], + "debate_summary": {...}, + "reflection_memory": [...], + "recommended_next_actions": [...], + "committee_confidence_trend": {...} +} +``` + +--- + +## 9. 与现有模块的最终落点关系 + +## 9.1 `DecisionPacketService` + +落点文件: + +- `src/services/decision/decision_packet_service.py` + +最终改法: + +- 新增 `research_summary` +- 新增 `committee_confidence` +- 新增 `agreement_points` +- 新增 `disagreement_points` +- 新增 `follow_up_checks` +- 新增 `recommended_watch_items` +- 新增 `operator_questions` + +规则: + +- 委员会失败不影响 Packet 主体生成 +- `why_allowed / why_blocked` 继续以现有风险与信任逻辑为准 +- 委员会只能增强解释,不得覆盖 `action_recommendation` + +## 9.2 `TradingRuntimeScheduler` + +落点文件: + +- `src/services/runtime/trading_runtime_service.py` + +最终改法: + +- 在 `watch` 之后、`decision_packet` 之前增加新任务 `research_council` + +建议顺序: + +```text +plan +-> market_state +-> broker_sync +-> reconciliation +-> watch +-> research_council +-> decision_packet +-> alert_dispatch +``` + +规则: + +- 只在存在 active plan 或 recent candidate 时触发 +- 初期允许配置开关关闭 +- 失败只形成 warning / blocker / noisy incident,不得直接改动执行边界 + +## 9.3 `ReviewPacketService` + +落点文件: + +- `src/services/operator/operator_workspace_service.py` +- `scripts/run_review_packet.py` + +最终改法: + +- 在 `review packet` 中加入委员会摘要 +- 支持查看最近一次委员会分歧 +- 支持查看“为何建议观察 / 为何不建议执行” +- 支持显示 reflection memory 摘要 + +## 9.4 `Workbench` + +落点文件: + +- `src/web/app.js` +- `src/web/index.html` + +最终改法: + +- 在 decision packet 区域新增“委员会视角” +- 新增 bull / bear / risk / visual 四类可读区 +- 新增“分歧点”和“补充检查项” +- 不引入第二套大页面,优先嵌入当前 operator console + +## 9.5 `ModelTrustService` + +落点文件: + +- `src/services/trust/model_trust_service.py` + +最终改法: + +- 短期不允许委员会改写 trust bucket +- 中期允许委员会结果进入 trust 观察素材 +- 长期才允许考虑“委员会质量反馈”作为附加维度 + +统一规则: + +- `analysis_only_allowed` +- `restricted_decision_support_allowed` +- `decision_support_allowed` +- `execution_allowed` + +仍只由现有 `ModelTrustService` 决定。 + +## 9.6 `VisualWatch` + +落点文件: + +- `src/services/visual_watch/visual_trigger_policy_service.py` +- `src/services/visual_watch/visual_watch_service.py` + +最终改法: + +- 委员会可输出 `recommended_watch_items` +- 可为 visual watch 提供“建议观察点” +- 但不能单独触发执行许可升级 + +--- + +## 10. 持久化与运行恢复建议 + +建议新增以下持久化对象: + +1. `research_council_runs` +2. `research_council_messages` +3. `research_council_checkpoints` +4. `research_council_lessons` +5. `research_council_links` + +### 各表职责 + +#### `research_council_runs` + +记录一次委员会运行: + +- `instrument_id` +- `trading_plan_id` +- `candidate_id` +- `signal_id` +- `decision_packet_id` +- `status` +- `committee_confidence` +- `summary` +- `created_ts` + +#### `research_council_messages` + +记录各角色输出: + +- `run_id` +- `agent_role` +- `message_type` +- `payload` + +#### `research_council_checkpoints` + +记录长链路恢复状态: + +- `run_id` +- `step_name` +- `checkpoint_payload` +- `updated_ts` + +#### `research_council_lessons` + +记录从 journal / weekly review 提炼出的经验: + +- `instrument_id` +- `session_code` +- `setup_type` +- `lesson_type` +- `summary` +- `source_ref` + +#### `research_council_links` + +把委员会运行与现有主线对象关联: + +- `run_id` +- `trading_plan_id` +- `candidate_id` +- `signal_id` +- `decision_packet_id` +- `execution_ticket_id` + +### 原则 + +- 不直接把 `~/.tradingagents` 当正式生产存储 +- 可以借其 memory / checkpoint 思路 +- 正式存储仍落回 `ai-exchange` 自己的数据库体系 + +--- + +## 11. API 与脚本建议 + +## 11.1 建议新增 API + +- `POST /trading/research-council/run` +- `GET /trading/research-council/latest` +- `GET /trading/research-council/runs` +- `GET /trading/research-council/{run_id}` + +## 11.2 建议新增脚本 + +- `scripts/run_research_council.py` +- `scripts/run_research_council_replay.py` +- `scripts/run_research_council_backfill_lessons.py` + +## 11.3 原则 + +- 手动运行入口优先于 runtime 自动接入 +- 先提供可重复触发的工具,再进自动化链路 + +## 11.4 配套执行文档 + +后续自动推进时,应直接把以下文档作为执行单使用: + +- `docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` +- `docs/AI_ICT_research_council脚手架与数据表草案_v1.md` + +--- + +## 12. 最终实施计划 + +## P0:影子接入,不影响现有决策 + +### 目标 + +把 TradingAgents 作为只读研究委员会接入,围绕 AI_ICT 当前事实生成委员会结论,但不影响现有执行路径。 + +### 范围 + +1. 新增 `research_council` 服务域 +2. 完成 `council_context_builder` +3. 跑通最小角色链路: + - `structure_analyst` + - `risk_analyst` + - `bull_researcher` + - `bear_researcher` + - `research_manager` +4. 持久化委员会结果 +5. 提供手动 API / 脚本入口 +6. 将只读摘要接入 review packet + +### 验收 + +1. 没有委员会结果时,现有系统行为不变 +2. 有委员会结果时,review packet 能展示委员会摘要 +3. runtime 不依赖委员会成功才可继续 +4. 任何委员会异常都不能导致 ticket / execution 主链路失效 + +### 晋级门 + +只有同时满足以下条件,P0 才允许自动晋级到 P1: + +1. `research_council` 手动 API / script 能稳定跑通 +2. 没有委员会结果时,原 `review packet` 与主系统行为不发生回归 +3. 有委员会结果时,`review packet` 能稳定展示委员会摘要 +4. 委员会失败时,runtime 会自动降级但不会中断主链 +5. 连续多次 focused smoke 均通过,且没有新增主链 blocker + +如果只满足部分条件,则状态应标记为: + +```text +P0 部分完成 / P0 待收尾 / P0 环境阻塞 +``` + +并继续停留在 P0 自动补剩余缺口,而不是提前进入 P1。 + +### 本阶段禁止事项 + +- 禁止委员会改写 `action_recommendation` +- 禁止委员会改写 `risk_result` +- 禁止委员会改写 `execution_allowed` + +--- + +## P1:受控进入决策包与运行时 + +### 目标 + +让委员会结果成为当前决策包与 operator workbench 的正式解释层组成部分,但仍不替代风控和 trust。 + +### 范围 + +1. 在 runtime 中增加 `research_council` 任务 +2. 在 `DecisionPacket` 中加入委员会摘要字段 +3. 新增: + - `news_event_analyst` + - `visual_analyst` +4. 增加分歧显示与 follow-up checks +5. 增加 council checkpoint / resume +6. 增加 visual watch 建议联动 + +### 可影响的范围 + +本阶段委员会结果可以影响: + +- operator 注意力分配 +- 决策包解释丰富度 +- review packet 完整度 +- alert 文案丰富度 +- visual watch 建议内容 + +### 仍不能影响的范围 + +- execution gate +- live readiness +- operator confirmation protocol +- trust bucket 主分类 + +### 验收 + +1. decision packet 中能看到委员会结论 +2. workbench 中能看到支持与反对理由 +3. council 失败时系统自动降级,不阻塞现有任务 +4. visual watch 可消费 follow-up checks + +### 晋级门 + +只有同时满足以下条件,P1 才允许自动晋级到 P2: + +1. `decision_packet` 已稳定吸收委员会解释字段 +2. `runtime` 已受控接入 `research_council` 任务,且失败可自动降级 +3. `workbench` 已能展示支持 / 反对 / 分歧 / follow-up checks +4. `visual_watch` 已能消费委员会建议,但未越过执行边界 +5. 委员会增强层未对 `execution gate`、`live readiness`、`operator confirmation protocol` 造成回归 + +如果这些条件未全部满足,则继续停留在 P1,优先修复当前解释层和运行时接入缺口。 + +--- + +## P2:经验回流、评估闭环、研究竞技场 + +### 目标 + +让委员会不只是“会说”,而是“会从历史里学”,并且能被客观评估是否真正提升了辅助决策质量。 + +### 范围 + +1. 建立 council lesson store +2. 将 journal / weekly review / trust / replay 结果映射为经验 +3. 评估: + - 单层决策 vs 委员会决策 + - 有分歧提示 vs 无分歧提示 + - 不同角色组合效果差异 +4. 做内部研究竞技场 + +### 可借鉴但不外扩的平台化思路 + +可以借鉴“challenge / mission”思想做内部比较,但只用于: + +- 模型赛马 +- setup 对比 +- council 组合实验 + +不用于: + +- 外部社区化 +- 跟单平台化 +- 奖励系统 + +### 验收 + +1. 能判断委员会是否真实提高 operator 决策质量 +2. 能识别哪些 session / setup 最值得启用委员会 +3. 能从失败案例中自动提炼 follow-up checks + +### 阶段出口 + +P2 不是“做完就结束不动”,而是转入持续运行与评估优化模式。 + +当以下条件成立时,P2 可视为进入长期运行态: + +1. lesson backfill 机制稳定可重复执行 +2. checkpoint / resume 机制稳定 +3. 评估脚本能够持续比较 council 与非 council 效果 +4. 内部研究竞技场已能服务后续模型 / setup / council 组合迭代 + +进入长期运行态后,默认后续动作变为: + +```text +持续评估 +-> 发现薄弱点 +-> 回到 P2 内部优化 +-> 必要时回流修正 P1 展示层或 P0 数据契约 +``` + +--- + +## P3:长期优化循环闭环 + +### 目标 + +把 P2 的长期运行态变成可重复执行、可持久化、可查询、可回写状态的 optimization cycle。 + +### 范围 + +1. 新增 `research_council_optimization_cycles` +2. 新增 `CouncilOptimizationService` +3. 串联: + - lessons backfill + - fresh council run + - replay summary + - eval summary + - arena summary + - next bounded actions +4. 提供 CLI / API 入口 +5. 将结果写回 `SESSION.md / TASKS.md` + +### 阶段出口 + +当以下条件成立时,P3 可视为完成闭环: + +1. optimization cycle 可持久化 +2. CLI / API 可触发和查询 +3. focused tests 与必要 smoke 通过 +4. 输出只进入 operator follow-up 与下一轮研究优化建议 +5. 未改变 execution gate / live readiness / trust bucket + +--- + +## P4:优化治理与 Operator 可见性闭环 + +### 目标 + +把 P3 的 optimization cycle 结果转成 operator 可读的治理判断,回答: + +1. 最近 cycle 是否 stale +2. 样本是否足够 +3. 是否出现需要关注的退化或缺口 +4. 是否可以开启 bounded optimization slice + +### 范围 + +1. 新增 `CouncilOptimizationReviewService` +2. 新增 optimization review CLI +3. 新增 optimization review API +4. 将 review 接入 review packet 与 workbench operator console + +### 阶段出口 + +当以下条件成立时,P4 可视为完成闭环: + +1. optimization review 能稳定读取最近 cycle +2. CLI / API / review packet / workbench 均可消费 review +3. 输出只作为 operator follow-up +4. 未改变 execution gate / live readiness / trust bucket + +--- + +## P5:Confirmed Swing Replay 证据闭环 + +### 目标 + +把 P4 review 指出的 `add_replay_fixture_with_confirmed_swings` 转成可重复执行的 replay 证据,让 optimization loop 能基于 confirmed swing high / low 样本继续迭代。 + +### 范围 + +1. 新增可部署的 confirmed swing replay fixture +2. 扩展 `replay_summary`,输出 swing counts 与 confirmed swing 明细 +3. 用 P5 fixture 重跑 optimization cycle +4. 用 optimization review 验证缺口已关闭 +5. 远端部署后通过 API 跑出/读取同一状态 + +### 阶段出口 + +当以下条件成立时,P5 可视为完成闭环: + +1. replay fixture 在默认确认窗口下能检测到 swing high 与 swing low +2. latest cycle 不再输出 `add_replay_fixture_with_confirmed_swings` +3. optimization review 不再把 replay fixture 作为待办缺口 +4. 输出仍只进入 operator follow-up 与研究优化建议 +5. 未改变 execution gate / live readiness / trust bucket + +--- + +## P6:Governance Tick 自动巡检闭环 + +### 目标 + +在 P5 后恢复 healthy review 的基础上,增加一个可调度的 governance tick,让自动化流程能判断: + +1. 当前是否无需动作 +2. 是否需要补跑 optimization cycle +3. 是否需要打开 bounded optimization slice + +### 范围 + +1. 新增 `CouncilGovernanceTickService` +2. 新增 governance tick CLI +3. 新增 governance tick API +4. 将 tick 输出限制为 automation/operator follow-up + +### 阶段出口 + +当以下条件成立时,P6 可视为完成闭环: + +1. healthy review 返回 `no_op_healthy` +2. stale/missing review 返回 `cycle_required`,且只有显式授权才补跑 cycle +3. needs_attention review 返回 `bounded_slice_required` +4. 未改变 execution gate / live readiness / trust bucket + +--- + +## P0-P6 审查结论:冻结阶段,进入自动化执行层 + +P0-P6 的阶段划分保留,但不再继续自动扩展 Research Council P7。 + +审查后的默认规则: + +```text +P6 governance tick +-> no_op_healthy:停止,不开新切片 +-> cycle_required:按策略补跑 optimization cycle +-> bounded_slice_required:打开最小 bounded slice +-> 完成后回到 P6 governance tick +``` + +P5 是 P4 review 指出的具体 replay evidence 缺口闭环,不应被复制成“每个缺口一个永久阶段”的模式。后续类似缺口都由 P6 tick 触发最小 bounded slice。 + +完成 P0-P6 审查后,后续重点转为自动化执行层开发,而不是继续扩写 Research Council 阶段清单。 + +当 analysis/decision monitor 已达到稳定质量窗口时,可进入自动化执行层 P7:`automation_execution_layer_p7_monitor_maturity_rollup`。该 P7 只做监控成熟度摘要、远端漂移检查、输出质量审查和下一 bounded slice 路由,不改变交易权限或实盘就绪边界。 + +--- + +## 13. 明确不做的事 + +## 13.1 不以 TradingAgents 取代 AI_ICT 主干 + +不做: + +- 用 TradingAgents 重写 market truth +- 用 TradingAgents 重写 runtime scheduler +- 用 TradingAgents 重写 risk governor +- 用 TradingAgents 重写 execution ticket + +## 13.2 不把 TradingAgents 原生数据层当主数据源 + +不做: + +- 用 `yfinance` 替代 OKX 主真值 +- 用 `alpha_vantage` 替代当前真实市场 / 账户事实 + +## 13.3 不把研究输出直接当执行指令 + +不做: + +- 委员会直接下单 +- 委员会直接确认 ticket +- 委员会直接开启 live + +## 13.4 不引入第二套产品主入口 + +不做: + +- 用 TradingAgents CLI 替代当前 `/` 和 `/trading/*` +- 建一套平行 workbench + +## 13.5 不引入 AI-Trader-main 的平台化社区主线 + +当前不做: + +- copy trading +- follower economy +- rewards / points +- signal marketplace + +除非产品目标改成“对外 Agent 交易平台”,否则不进入当前主线。 + +--- + +## 14. 最稳的执行顺序 + +推荐顺序如下: + +1. 先完成当前 AI_ICT 主线 N4 收尾与外部环境恢复 +2. 再做本方案 `P0` +3. `P0` 稳定后进入 `P1` +4. `P1` 确认对 operator 有真实帮助后,再推进 `P2` + +换句话说: + +```text +先稳住 AI_ICT 主骨架 +-> 再接入委员会影子层 +-> 再让委员会参与解释层 +-> 最后再做经验回流和评估闭环 +``` + +--- + +## 15. 最终架构判断 + +如果用一句话概括最终版方案: + +> **AI_ICT 继续做“实盘辅助分析决策操作系统”,TradingAgents 只做“研究推理协处理器”。** + +该最终版相较前两份方案的改进点是: + +1. 边界更清楚 +2. 角色更贴近当前 crypto / ICT 场景 +3. 数据流更明确 +4. 代码落点更明确 +5. 阶段推进更稳 +6. 禁区定义更完整 + +因此,这一版应作为后续拆 `P0` 任务清单、数据表草案、API 草案、目录脚手架说明的正式基础文档。 diff --git a/docs/AI_ICT_repair_bridge_protocol_v1.md b/docs/AI_ICT_repair_bridge_protocol_v1.md new file mode 100644 index 0000000..7393eda --- /dev/null +++ b/docs/AI_ICT_repair_bridge_protocol_v1.md @@ -0,0 +1,244 @@ +# AI ICT repair bridge protocol v1 + +## 1. 目标 + +本文档定义 `scripts/run_monitoring_alert_rollout.py` 中 `repair-bridge` 模式与外部修复代理之间的 JSON 输入输出协议。 + +目标: + +- 让自动化 orchestrator 能把当前失败阶段的上下文结构化发送给外部修复代理 +- 让外部修复代理用稳定 JSON 返回修复结果 +- 让 orchestrator 根据返回值决定是否自动复验、是否允许阶段推进 + +--- + +## 2. 协议版本 + +当前协议版本: + +```text +repair-bridge/v1 +``` + +--- + +## 3. stdin JSON 输入协议 + +外部修复代理从 stdin 读取 JSON。 + +### 必填字段 + +- `protocol_version` +- `mode` +- `phase` +- `phase_title` +- `failure_class` +- `recommended_focus` +- `phase_patch_targets` +- `validation_entrypoint` +- `repair_prompt_context` + +### 示例 + +```json +{ + "protocol_version": "repair-bridge/v1", + "mode": "repair_loop", + "phase": "N2", + "phase_title": "最小主动消息告警闭环", + "failure_class": "alert_dispatch_gap", + "recommended_focus": "alert_delivery_and_dispatch", + "phase_patch_targets": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" + ], + "validation_entrypoint": ".venv/bin/python scripts/run_alert_dispatcher.py", + "repair_prompt_context": { + "prompt_text": "...", + "phase_tasks": ["..."], + "phase_files": ["..."], + "suggested_files": ["..."], + "suggested_commands": ["..."] + } +} +``` + +--- + +## 4. stdout JSON 输出协议 + +外部修复代理必须向 stdout 输出一段 JSON。 + +### 必填字段 + +- `status` +- `summary` +- `changed_files` + +### 可选字段 + +- `protocol_version` +- `notes` +- `success_guess` +- `tests_run` + +### 字段说明 + +#### `status` +建议值: + +- `applied` +- `success` +- `fixed` +- `blocked` +- `noop` +- `failed` + +当前 orchestrator 认为下面这些值可以进入自动复验候选: + +- `applied` +- `success` +- `fixed` + +#### `summary` +一句简短摘要,说明修复做了什么。 + +#### `changed_files` +数组,列出本轮修复实际修改的文件路径。 + +#### `success_guess` +布尔值。 + +- `true` 表示修复代理认为可以尝试复验 +- `false` 表示修复代理认为还不适合复验 + +#### `tests_run` +数组,列出修复代理已经主动执行过的 focused tests / scripts。 + +orchestrator 会把它作为智能复验调度输入。 + +### 示例 + +```json +{ + "protocol_version": "repair-bridge/v1", + "status": "applied", + "summary": "Implemented missing alert dispatcher module and updated runtime hook.", + "changed_files": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" + ], + "notes": [ + "Focused on N2 alert delivery gap" + ], + "success_guess": true, + "tests_run": [ + ".venv/bin/python scripts/run_alert_dispatcher.py" + ] +} +``` + +--- + +## 5. orchestrator 当前决策逻辑 + +## 5.1 自动复验条件 + +只有满足以下条件,脚本才会自动复验: + +1. `MONITORING_ALERT_AUTO_RERUN_ENABLED=1` +2. bridge output JSON 可解析 +3. bridge output schema 合法 +4. `status in {applied, success, fixed}` +5. `success_guess != false` +6. `changed_files` 非空 + +--- + +## 5.2 智能复验调度 + +当前策略: + +### 优先级 1:如果有 `tests_run` +直接优先执行 `tests_run` 中列出的命令,作为 focused rerun。 + +### 优先级 2:如果没有 `tests_run` +根据 `changed_files` 做命令映射: + +- 改动 `src/services/alerts/` 或 `src/api/alerts.py` + - 运行 `scripts/run_alert_dispatcher.py` +- 改动 `src/services/runtime/` 或 `src/api/trading_runtime.py` + - 运行 `scripts/run_trading_runtime.py` +- 改动 `src/services/readiness/` / `src/web/` / `src/config/settings.py` + - 运行 `scripts/run_launch_readiness.py` +- 改动 `src/services/decision/` / `src/services/operator/` + - 运行 `scripts/run_review_packet.py` +- 如果全部改动只在 `docs/` + - 跳过 focused rerun,直接进入全阶段判断 + +之后仍会回到 `run-until-blocked` 做阶段级判断。 + +--- + +## 5.3 自动推进条件 + +修复后复验完成后,如果 rerun 结果状态为: + +- `running` +- `completed` + +则当前阶段可视为已推进成功。 + +否则进入: + +```text +inspect_rerun_result +``` + +--- + +## 6. 推荐外部修复代理实现要求 + +建议外部 repair agent 至少做到: + +1. 从 stdin 读取完整 JSON +2. 解析 `phase_patch_targets` 和 `repair_prompt_context` +3. 明确输出 `changed_files` +4. 给出 `success_guess` +5. 若已主动运行 focused checks,则把命令写入 `tests_run` +6. 始终输出合法 JSON 到 stdout,不要混入额外日志 + +如果需要日志,建议输出到 stderr。 + +--- + +## 7. 当前脚本内相关模式 + +- `run-until-blocked` +- `repair-loop` +- `repair-bridge` + +推荐顺序: + +```text +run-until-blocked + -> repair-loop + -> repair-bridge + -> auto rerun + -> phase advance / inspect_rerun_result +``` + +--- + +## 8. 后续可扩展项 + +后续协议可以考虑补充: + +- `applied_patches` +- `skipped_files` +- `risk_notes` +- `confidence_score` +- `requires_human_review` +- `suggested_next_tests` + +这些字段当前不是必需,但适合 `repair-bridge/v2`。 diff --git a/docs/AI_ICT_research_council脚手架与数据表草案_v1.md b/docs/AI_ICT_research_council脚手架与数据表草案_v1.md new file mode 100644 index 0000000..350e363 --- /dev/null +++ b/docs/AI_ICT_research_council脚手架与数据表草案_v1.md @@ -0,0 +1,279 @@ +# AI_ICT research_council 脚手架与数据表草案 v1 + +## 1. 文档目标 + +本草案用于明确: + +1. `research_council` 目录结构应如何搭建 +2. 每个模块的职责边界是什么 +3. 数据表与 migration 初稿应如何命名 +4. 如何避免和现有 AI_ICT 主链冲突 + +--- + +## 2. 目录脚手架建议 + +```text +src/services/research_council/ +├── __init__.py +├── council_context_builder.py +├── council_orchestrator.py +├── council_schema.py +├── council_output_formatter.py +├── council_mapper_service.py +├── council_memory_service.py +├── council_checkpoint_service.py +├── tradingagents_adapter.py +├── debate_service.py +└── agents/ + ├── structure_analyst.py + ├── risk_analyst.py + ├── news_event_analyst.py + ├── visual_analyst.py + ├── bull_researcher.py + ├── bear_researcher.py + └── research_manager.py +``` + +--- + +## 3. 模块职责 + +## 3.1 `council_context_builder.py` + +职责: + +- 读取 AI_ICT 已有结构化事实 +- 生成 council 输入上下文 + +必须只读消费: + +- `market_state` +- `trading_plan` +- `candidate` +- `signal` +- `risk_result` +- `trust_policy` +- `visual_review` +- `operator_notes` +- `journal / weekly review` + +禁止: + +- 自己重算 market truth +- 自己生成平行 readiness / trust + +## 3.2 `tradingagents_adapter.py` + +职责: + +- 适配 TradingAgents 的编排、结构化输出、checkpoint/memory 思路 +- 屏蔽掉不适用的股票数据入口 + +禁止: + +- 直接暴露 `yfinance / alpha_vantage` 作为主事实源 + +## 3.3 `debate_service.py` + +职责: + +- 统一整理支持 / 反对理由 +- 生成分歧点与 follow-up checks + +## 3.4 `council_mapper_service.py` + +职责: + +- 将 council 结果映射到: + - `decision_packet` + - `review_packet` + - `workbench` + - `visual_watch` 建议项 + +## 3.5 `council_memory_service.py` + +职责: + +- 读写 lesson store +- 读取相似历史经验 +- 回填历史复盘数据 + +## 3.6 `council_checkpoint_service.py` + +职责: + +- 管理 council 运行中断恢复 +- 提供 replay / resume 支撑 + +--- + +## 4. 数据表草案 + +## 4.1 `research_council_runs` + +建议 migration: + +- `036_create_research_council_runs.sql` + +建议字段: + +- `id` +- `instrument_id` +- `trading_plan_id` +- `candidate_id` +- `signal_id` +- `decision_packet_id` +- `status` +- `committee_confidence` +- `summary_text` +- `payload_json` +- `created_ts` +- `updated_ts` + +用途: + +- 记录一次 council 运行的总结果 + +## 4.2 `research_council_messages` + +建议 migration: + +- `037_create_research_council_messages.sql` + +建议字段: + +- `id` +- `run_id` +- `agent_role` +- `message_type` +- `payload_json` +- `created_ts` + +用途: + +- 记录各角色输出 + +## 4.3 `research_council_links` + +建议 migration: + +- `038_create_research_council_links.sql` + +建议字段: + +- `id` +- `run_id` +- `trading_plan_id` +- `candidate_id` +- `signal_id` +- `decision_packet_id` +- `execution_ticket_id` +- `created_ts` + +用途: + +- 连接 council run 与主链对象 + +## 4.4 `research_council_lessons` + +建议 migration: + +- `039_create_research_council_lessons.sql` + +建议字段: + +- `id` +- `instrument_id` +- `session_code` +- `setup_type` +- `lesson_type` +- `importance_score` +- `summary_text` +- `source_type` +- `source_ref` +- `payload_json` +- `created_ts` + +用途: + +- 持久化结构化经验 + +## 4.5 `research_council_checkpoints` + +建议 migration: + +- `040_create_research_council_checkpoints.sql` + +建议字段: + +- `id` +- `run_id` +- `step_name` +- `checkpoint_payload` +- `status` +- `updated_ts` + +用途: + +- 管理中断恢复 + +--- + +## 5. repository 草案 + +建议新增: + +- `src/repositories/research_council_run_repository.py` +- `src/repositories/research_council_message_repository.py` +- `src/repositories/research_council_link_repository.py` +- `src/repositories/research_council_lesson_repository.py` +- `src/repositories/research_council_checkpoint_repository.py` + +--- + +## 6. 脚本草案 + +建议新增: + +- `scripts/run_research_council.py` +- `scripts/run_research_council_replay.py` +- `scripts/run_research_council_backfill_lessons.py` +- `scripts/run_research_council_eval.py` + +--- + +## 7. 测试草案 + +建议新增: + +- `tests/unit/test_research_council_imports.py` +- `tests/unit/test_research_council_context_builder.py` +- `tests/unit/test_research_council_schema.py` +- `tests/unit/test_research_council_analysts.py` +- `tests/unit/test_research_council_orchestrator.py` +- `tests/unit/test_research_council_runtime_integration.py` +- `tests/unit/test_research_council_decision_packet_fields.py` + +--- + +## 8. 边界守卫 + +以下规则必须在脚手架阶段就固定: + +1. council 不重写主事实层 +2. council 不重写 `risk_result` +3. council 不重写 `execution_allowed` +4. council 不替代 `DecisionPacketService` 主裁决 +5. council 不引入第二套 readiness / trust + +--- + +## 9. 推荐落地顺序 + +1. 先落目录与 schema +2. 再落 context builder +3. 再落最小 analysts / orchestrator +4. 再落 runs / messages / links 三张表 +5. 再落 lessons / checkpoints +6. 最后接入 runtime / packet / workbench diff --git a/docs/AI_ICT_下一阶段开发任务清单_v1.md b/docs/AI_ICT_下一阶段开发任务清单_v1.md new file mode 100644 index 0000000..5817eda --- /dev/null +++ b/docs/AI_ICT_下一阶段开发任务清单_v1.md @@ -0,0 +1,519 @@ +# AI ICT 下一阶段开发任务清单 v1 + +## 1. 文档目标 + +本文档将 `docs/AI_ICT_下一阶段开发计划_v1.md` 拆解为可执行任务清单,按下一阶段的四个子阶段组织: + +- `N1` Launch Readiness 收尾 +- `N2` 最小主动消息告警闭环 +- `N3` 告警质量与 runtime 硬化 +- `N4` 视觉盯盘增强预埋 + +每个子阶段都给出: + +- 目标 +- 具体任务 +- 影响文件 +- 接口/脚本 +- 验收标准 + +--- + +## 2. 总体原则 + +1. 优先收尾和硬化,不做大规模新能力扩张 +2. 优先把已有 queue alert 变成真正可外发提醒 +3. 视觉层只做预埋,不抢当前主线 +4. 不默认开启真实自动交易 +5. assisted trading ready 与 real live ready 必须持续分离 + +--- + +## 3. N1:Launch Readiness 收尾 + +### 当前状态 +本地 assisted-trading readiness 已恢复并验证通过: + +- `scripts/run_launch_readiness.py` 返回 `Assisted trading ready: True` +- `scripts/run_trading_runtime.py` 返回 `current_blockers = []` +- broker、market truth、news provider 的单次启动链路已打通 +- `real_live_ready` 仍然保持 `false` + +因此当前主线应进入 `N2`,本节保留为已完成记录与后续回归参考。 + +### 已完成关键结果 +- 修复 Windows venv 路径与自动化脚本单次调度兼容性 +- 补齐本地 PostgreSQL 驱动并验证数据库连接 +- 通过显式 OKX 只读凭据注入恢复 `broker_state_ok` +- 修复单次 market-truth / news warm-up 路径,使 launch readiness 不再误判 stream/news 初始化阶段 +- 使 `market_stream_connected`、`market_data_gap_clear`、`news_provider_cached` 在本地 readiness 流程中恢复 + +### 当前剩余关注项 +- `decision_support_ready` +- `real_trade_sample_verified` +- `real_live_ready = false` 边界维护 + +### 下一主线入口 +- `N2-1` `alert_deliveries` 持久化 +- `N2-2` 单通道 notifier +- `N2-3` `AlertDispatcherService` +- `N2-4` runtime `alert_dispatch` task + +--- + +## 3. N1:Launch Readiness 收尾 + +## N1-1 明确 launch readiness 判定口径 + +### 目标 +让 launch readiness 成为明确的上线辅助判断器,而不是仅展示状态。 + +### 任务 +- 审核 `LaunchReadinessService` 当前 checklist 项 +- 确认哪些项是 blocker、哪些项是 warning +- 明确 `assisted_trading_ready` 与 `real_live_ready` 的业务口径 +- 明确 `live_submit_strategy_defined` 的配置来源与默认值 + +### 主要文件 +- `src/services/readiness/launch_readiness_service.py` +- `src/config/settings.py` +- `docs/AI_ICT_下一阶段开发计划_v1.md` + +### 验收 +- checklist 各项含义明确 +- assisted 与 real-live 边界无歧义 +- `next_actions` 输出与真实剩余工作一致 + +--- + +## N1-2 收敛 trust / live / real-trade 样本 warning + +### 目标 +尽量减少 warning 项,使 readiness 更接近实际软上线要求。 + +### 任务 +- 评估 `trust_model_available`: + - 是补更多样本 + - 还是调整 readiness 门槛 +- 评估 `real_trade_sample_verified`: + - 等真实 fill + - 或明确以受控演练替代 +- 明确 `live_submit_strategy_defined`: + - 保持 blocked + - 或写成明确策略配置 +- 明确 `real_live_path_ready` 持续为 false 时的产品语义 + +### 主要文件 +- `src/services/readiness/launch_readiness_service.py` +- `src/services/trust/` +- `src/services/broker/` +- `SESSION.md` +- `TASKS.md` + +### 验收 +- warning 数量最小化 +- 每个 warning 都有明确解释和下一步动作 + +--- + +## N1-3 workbench / API 展示硬化 + +### 目标 +让 launch readiness 信息适合直接指导人工使用。 + +### 任务 +- 硬化 `/trading/launch-readiness` 输出结构 +- workbench 明确展示: + - assisted trading ready + - real live ready + - blockers + - warnings + - next actions + +### 主要文件 +- `src/api/server.py` +- `src/api/trading_runtime.py` +- `src/web/app.js` +- `src/web/index.html` + +### 验收 +- API 与前端展示一致 +- 人工可根据页面直接判断当前是否适合 soft rollout + +--- + +## 4. N2:最小主动消息告警闭环 + +## N2-1 新增 alert delivery 持久化 + +### 目标 +给外发消息建立状态记录。 + +### 任务 +- 新增 `alert_deliveries` 表 +- 新增 `AlertDeliveryRepository` +- 支持按 `alert_id` 查询发送记录 + +### 主要文件 +- `migrations/*_create_alert_deliveries.sql` +- `src/repositories/alert_delivery_repository.py` + +### 验收 +- 可插入一条 delivery 记录 +- 可查询某 alert 的所有 delivery 历史 + +--- + +## N2-2 新增 notifier 基础设施 + +### 目标 +先打通一个真实外发通道。 + +### 任务 +- 新增 `src/services/notifications/__init__.py` +- 新增 `src/services/notifications/telegram_notifier.py` 或 `webhook_notifier.py` +- settings 增加相关配置 +- 提供最小发送接口: + - `send_text(...)` + - `send_json(...)` + +### 范围控制 +- 只做一个主通道 +- 不做多通道编排 +- 不做图片发送 + +### 验收 +- 可以从脚本触发一条真实测试消息 +- 发送失败时能返回清晰错误 + +--- + +## N2-3 新增 AlertDispatcherService + +### 目标 +把 queued alerts 真正发出去。 + +### 任务 +- 新增 `src/services/alerts/__init__.py` +- 新增 `src/services/alerts/alert_dispatcher_service.py` +- 新增 `src/services/alerts/message_template_service.py` +- 新增 `src/services/alerts/channel_router_service.py` +- 新增 `src/services/alerts/delivery_policy_service.py` + +### 初始支持的 alert type +- `candidate_actionable` +- `ticket_ready` +- `runtime_degraded`(如当前未产出,则补最小支持) + +### 行为要求 +- queued -> delivered / failed / skipped +- 成功消息不重复发送 +- dashboard-only 告警可以 skipped + +### 验收 +- 有 pending alert 时 dispatcher 能完成完整状态流转 +- 重复运行不会重复发送同一条已发送告警 + +--- + +## N2-4 API / CLI / runtime 接入 + +### 目标 +让 dispatch 既可手动执行,也可运行时自动执行。 + +### 任务 +- 新增 `src/api/alerts.py` +- 扩展 `src/api/server.py` +- 新增 `scripts/run_alert_dispatcher.py` +- runtime 新增 `alert_dispatch` task + +### 建议接口 +- `POST /trading/alerts/dispatch` +- `GET /trading/alerts/{alert_id}/deliveries` + +### 主要文件 +- `src/api/alerts.py` +- `src/api/server.py` +- `src/services/runtime/trading_runtime_service.py` +- `scripts/run_alert_dispatcher.py` + +### 验收 +- 可通过脚本 dispatch pending alerts +- 可通过 API 查看 delivery 结果 +- runtime status 中可看到 alert_dispatch task + +--- + +## 5. N3:告警质量与 runtime 硬化 + +### 当前状态 +`N2` 最小主动消息告警闭环已经完成并验证通过: + +- `alert_deliveries` 已落库 +- `AlertDispatcherService` 已接入 API / CLI / runtime +- 本地 webhook capture 已收到真实 POST +- `alert_deliveries` 已存在 `channel=webhook`、`status=delivered` 的真实记录 +- `candidate_blocked` 也已被用于验证真实外发路径 + +同时,已确认一个新的硬化问题: + +- 当以很大的 `ALERT_DISPATCH_LIMIT` 清理 backlog 时,远端 PostgreSQL 可能在状态回写阶段触发 `connection timeout expired` + +因此当前主线已切换到 `N3`,重点不再是“把消息发出去”,而是“在真实 backlog 与数据库波动下稳定地发出去”。 + +## N3-1 告警节流与重复抑制 + +### 新增验收重点 +- blocked 类消息不能持续重复轰炸外部 webhook +- 对相同 candidate / action / reason 组合需要有最小重发间隔 +- 大 backlog 不能依赖一次超大批量 drain + +### 新增主要文件 +- `src/services/alerts/delivery_policy_service.py` +- `src/services/alerts/channel_router_service.py` +- `src/services/alerts/alert_dispatcher_service.py` +- `src/services/runtime/trading_runtime_service.py` +- `SESSION.md` +- `TASKS.md` + +### 新增验收 +- backlog 在保守批次下可持续推进 +- webhook 噪声显著下降 +- runtime `alert_dispatch` task 仍可观测且不会放大数据库抖动 + +### 当前结果 +本轮已完成以下 N3 硬化动作: + +- `alert_dispatch_max_per_run` 配置上限已接入 dispatcher 与 runtime +- `alert_resend_cooldown_seconds` 已接入 delivery policy +- 同一条 alert 的 resend cooldown 已生效 +- 跨 alert 记录的等价消息 dedupe 已生效 +- `candidate_blocked` 的外发边界已收紧: + - `wait_for_confirmations` -> workbench-only + - `do_not_execute` -> webhook eligible +- 小批量真实 dispatch 已返回 `attempted = 0`,当前 pending backlog 已无待发项 +- runtime status 已完成 blocker / warning / noisy-incident 三层分离 +- runtime incidents 已带 `severity` 字段 +- runtime summary 已新增 `runtime_health` +- `watch_skipped_market_degraded:*` 已从 warning 下沉到 noisy incident +- trust / readiness 已新增 restricted decision support 层: + - `restricted_decision_support_list` + - `restricted_decision_support_ready` +- 在显式环境注入下,restricted 决策支持口径已验证通过: + - `analysis_only_ready = true` + - `decision_support_ready = true` + - `restricted_decision_support_ready = true` + - `execution_assist_ready = false` +- readiness / trust / decision packet 已完成产品语义对齐: + - `product_operating_mode` + - `product_capabilities` + - `product_boundary_notes` + - `restricted_decision_support` recommendation tier +- 当前最终验证边界已确认: + - `analysis_only_ready = true` + - `restricted_decision_support_ready = true` + - `decision_support_ready = false` + - `execution_assist_ready = false` + - `execution_assist_ready = false` 目前按产品边界处理,而不是按 bug 处理 + +--- + +## N3-2 backlog-safe dispatch 与数据库压力控制 + +### 目标 +避免因为一次性清理过多 queued alerts 而导致 dispatcher 整批失败。 + +### 任务 +- 将 backlog drain 调整为更稳健的分批策略 +- 评估是否需要在 dispatcher 内部降低单次状态回写压力 +- 明确 runbook:当数据库抖动时,优先小批量 dispatch,而不是超大 `limit` +- 保持已验证的 webhook 外发能力不被回退 + +### 主要文件 +- `src/services/alerts/alert_dispatcher_service.py` +- `scripts/run_alert_dispatcher.py` +- `docs/AI_ICT_下一阶段开发计划_v1.md` +- `docs/AI_ICT_下一阶段开发任务清单_v1.md` + +### 验收 +- 即使远端 PostgreSQL 偶发超时,dispatcher 也不会因为单次大 backlog drain 而整体不可用 +- 运维侧有明确的小批次 dispatch 策略 +- 最小外发闭环继续保持有效 + +--- + +## N3-3 workbench-only 与 external-only 告警边界 + +### 目标 +明确哪些 alert 应该只在 workbench 看,哪些值得真实外发。 + +### 任务 +- 收敛 blocked / observe 类告警策略 +- 明确 `candidate_actionable`、`ticket_ready`、`runtime_degraded` 的优先级 +- 只保留必要的 blocked 类外发场景 + +### 验收 +- 外部消息聚焦真正值得通知的事件 +- workbench 继续保留更完整的内部告警上下文 + +--- + +## N3-1 告警节流与重复抑制 + +### 目标 +避免告警噪声过大。 + +### 任务 +- 同一 candidate/action/status 不重复刷 +- 相同 `reason_codes` 设置最小发送间隔 +- 明确哪些 alert type 仅保留 workbench 展示 + +### 主要文件 +- `src/services/alerts/delivery_policy_service.py` +- `src/services/decision/decision_packet_service.py` + +### 验收 +- 相同机会不会在短时间内反复推送 +- 低价值告警不会频繁外发 + +--- + +## N3-2 runtime blockers 分级 + +### 目标 +让 runtime 状态更适合值班视角。 + +### 任务 +- 区分: + - blocker + - warning + - noisy incident +- 调整 `current_blockers` 与 `incidents` 的表达语义 +- 必要时增加 `warning_codes` 或 `incident_severity` + +### 主要文件 +- `src/services/runtime/trading_runtime_service.py` +- `src/services/readiness/launch_readiness_service.py` + +### 验收 +- runtime status 不再把所有异常都当成同等级 blocker +- 页面上能清楚区分“不可用”和“需关注” + +--- + +## N3-3 决策包展示一致性硬化 + +### 目标 +让消息、API、workbench 对决策包字段理解一致。 + +### 任务 +- 检查 `DecisionPacketService` 的字段来源与展示字段 +- 强化: + - `why_allowed` + - `why_blocked` + - `risk_result` + - `entry_zone` + - `targets` + - `invalidation` +- 保证 API / workbench / notifier 文案一致 + +### 主要文件 +- `src/services/decision/decision_packet_service.py` +- `src/api/trading_runtime.py` +- `src/web/app.js` + +### 验收 +- 同一条 decision packet 在不同界面展示含义一致 +- 人工能用统一心智模型理解当前 recommendation + +--- + +## 6. N4:视觉盯盘增强预埋 + +## N4-1 固化视觉数据模型设计 + +### 目标 +保留视觉层落地接口,不现在全面实现。 + +### 任务 +- 固化以下数据模型设计文档: + - `visual_watch_runs` + - `visual_snapshots` + - `visual_reviews` +- 固化触发类型: + - `candidate_actionable_watch` + - `candidate_block_reason_review` + - `market_anomaly_review` + - `session_focus_watch` + +### 主要文件 +- `docs/AI_ICT_视觉盯盘与主动告警增强方案_v1.md` +- `docs/AI_ICT_盯盘告警技术设计稿_v1.md` +- `docs/AI_ICT_盯盘告警开发任务清单_v1.md` + +### 验收 +- 后续视觉层实现所需的数据模型和触发器定义已稳定 + +--- + +## N4-2 仅做代码预留点 + +### 目标 +不重投入实现视觉层,只预留扩展接口。 + +### 任务 +- 预留 decision alert payload 扩展位 +- 预留 runtime task 扩展位 +- 如有必要,在 API 层预留空实现接口或 TODO 锚点 + +### 不做 +- 不做截图 pipeline +- 不做 browser automation +- 不做 visual review service +- 不做 visual workbench panel + +### 验收 +- 未来接视觉层时不需要推翻当前告警闭环 + +--- + +## 7. 推荐执行顺序 + +建议按以下顺序推进: + +1. `N1-1` 明确 launch readiness 判定口径 +2. `N1-2` 收敛 trust / live / real-trade warning +3. `N1-3` launch readiness 展示硬化 +4. `N2-1` alert delivery 持久化 +5. `N2-2` notifier 基础设施 +6. `N2-3` AlertDispatcherService +7. `N2-4` API / CLI / runtime 接入 +8. `N3-1` 告警节流与重复抑制 +9. `N3-2` runtime blockers 分级 +10. `N3-3` 决策包展示一致性硬化 +11. `N4-1` 视觉数据模型固化 +12. `N4-2` 视觉扩展位预留 + +--- + +## 8. 完成定义 + +当以下目标成立时,可认为“下一阶段”完成: + +### N1 完成 +- assisted trading readiness 口径明确 +- blockers / warnings 被压缩并解释清楚 + +### N2 完成 +- `decision_alerts` 能真实外发到外部通道 +- delivery 结果可查 +- runtime 可自动 dispatch + +### N3 完成 +- 告警噪声可控 +- runtime 状态分级清晰 +- decision packet 展示一致 + +### N4 完成 +- 视觉增强的后续数据模型和扩展位已固定 +- 当前代码不需要返工即可接视觉层 diff --git a/docs/AI_ICT_下一阶段开发计划_v1.md b/docs/AI_ICT_下一阶段开发计划_v1.md new file mode 100644 index 0000000..8ddc40e --- /dev/null +++ b/docs/AI_ICT_下一阶段开发计划_v1.md @@ -0,0 +1,472 @@ +# AI ICT 下一阶段开发计划 v1 + +## 1. 目标 + +本文档基于以下三类信息制定: + +- `docs/` 下已有方案文档 +- 当前代码实际实现情况 +- 当前 `SESSION.md` 记录的阶段进度与剩余问题 + +目标是明确: + +1. 当前方案哪些部分已经完成 +2. 哪些方案合理但应延期 +3. 下一阶段真正应该做什么 +4. 以什么顺序推进最符合当前仓库现实 + +--- + +## 2. 当前状态总结 + +### 2.1 主方案完成度判断 + +从 `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` 看,原主线核心阶段已经基本落地: + +- 实时市场真值层:已落地主骨架 +- 真实账户与订单只读同步:已落地 +- 盘中持续调度与运行监督:已落地 +- 决策包与主动提醒:已落地到 workbench / queue 层 +- 真实交易映射与盘后闭环:已落地 +- 辅助可信度与模型白名单:已落地 +- 操作体验与运营工具:已落地 +- live adapter readiness prep:已落地 +- launch readiness:已新增并可输出 blockers/warnings + +从代码上看,这些结论有直接映射: + +- runtime:`src/services/runtime/trading_runtime_service.py` +- decision packet:`src/services/decision/decision_packet_service.py` +- session/operator/review:`src/services/operator/operator_workspace_service.py` +- launch readiness:`src/services/readiness/launch_readiness_service.py` +- API 聚合入口:`src/api/server.py` + +### 2.2 当前真正未完成的部分 + +虽然主线骨架已经出来,但仍有三个非常现实的未完成点: + +#### A. 告警还停留在“落库 + workbench 可见” +`DecisionPacketService.build_persist_and_alert()` 当前只把 alert 写入 `decision_alerts`,默认 channel 仍是 `workbench`,没有真正外发到 Telegram / webhook 等外部通道。 + +对应代码: +- `src/services/decision/decision_packet_service.py:110` +- `src/services/decision/decision_packet_service.py:121` + +#### B. launch readiness 还存在明确 blocker / warning +`LaunchReadinessService` 当前仍然暴露剩余问题: + +- `trust_model_available` +- `real_trade_sample_verified` +- `real_live_path_ready` +- live submit strategy 仍需明确 + +对应代码: +- `src/services/readiness/launch_readiness_service.py:60` +- `src/services/readiness/launch_readiness_service.py:111` +- `src/services/readiness/launch_readiness_service.py:150` + +#### C. 视觉盯盘增强仍是方案态,不是代码态 +当前代码中: + +- 没有 `visual_watch` 服务目录 +- 没有截图存储模型 +- 没有 visual review API +- 没有 alert delivery 机制 + +说明视觉盯盘方案仍处在设计阶段。 + +--- + +## 3. 对现有方案合理性的判断 + +## 3.1 原主线方案是否合理 + +结论:**合理,而且大部分方向已经被代码验证是正确的。** + +尤其这条主线是正确的: + +```text +实时真值 -> 持续调度 -> 决策包 -> 提醒 -> 真实账户映射 -> 研究闭环 +``` + +从当前代码看,主架构已经成型,因此主方案不需要推翻,只需要进入“收尾硬化 + 缺口补齐”阶段。 + +## 3.2 视觉盯盘与主动告警增强方案是否合理 + +结论:**方向合理,但不适合现在整体重投入。** + +合理点: + +- 结构化主引擎 + 视觉增强的分层是对的 +- 事件触发式截图比全量高频截图更合理 +- 视觉层不仅能看图表,也能看工作台/新闻页/公告页 +- 视觉结论结构化回写是正确设计 + +不适合当前立即全面落地的原因: + +- 上游主交易系统还处于 launch-readiness 收尾阶段 +- 外发消息闭环还没完成 +- launch blockers 还未完全收敛 +- 视觉层依赖主判断层稳定,否则容易放大噪声 + +因此: + +> 视觉方案应保留,但应作为下一阶段中的后置子线,而不是当前主线。 + +--- + +## 4. 当前最合理的下一阶段定位 + +我认为下一阶段不应该命名为“视觉盯盘阶段”,而应该命名为: + +# Assisted Trading V1 收尾硬化 + 最小外发告警闭环阶段 + +目标分两部分: + +### 第一优先级:收敛当前 launch readiness 剩余缺口 +### 第二优先级:把已落库 alert 变成真正外发的最小闭环 + +也就是说,下一阶段重点不是大规模新功能扩张,而是: + +- 收尾 +- 硬化 +- 最小通知闭环 +- 为后续视觉增强打基础 + +--- + +## 5. 下一阶段开发计划 + +## Phase N1:Launch Readiness 收尾与可信度闭环 + +### 当前结果 +本地环境下已完成这一阶段的关键收尾: + +- 使用显式 `DATABASE_URL`、代理和 OKX 只读凭据完成本地 launch readiness 验证 +- `scripts/run_launch_readiness.py` 已返回 `Assisted trading ready: True` +- `scripts/run_trading_runtime.py` 已返回 `current_blockers = []` +- `broker_state_ok`、`market_stream_connected`、`market_data_gap_clear`、`news_provider_cached` 均已在本地单次流程中恢复 +- `real_live_ready` 仍保持 `false`,边界未被放宽 + +### 目标 +让系统尽可能接近“可用于 assisted trading 软上线”的状态。 + +### 阶段结论 +N1 的本地主目标已经达成。后续如果不出现环境回退,主线应切换到 `N2` 最小主动消息告警闭环。 + +### 剩余关注项 +- `decision_support_ready` +- `real_trade_loop_ready` +- `real_live_ready = false` 的边界维护 + +### 任务 +(以下条目保留为后续环境回归或继续硬化时参考) + +### 任务 + +#### N1-1 收敛 launch readiness warning/blocker +重点围绕: + +- `trust_model_available` +- `real_trade_sample_verified` +- `live_submit_strategy_defined` +- `real_live_path_ready` + +其中: + +- `real_live_path_ready` 可以继续保持 `false`,但必须是清晰边界,不是模糊状态 +- `live_submit_strategy_defined` 需要变成明确配置与明确文档 +- `trust_model_available` 需要决定: + - 是补更多样本 + - 还是放宽 assisted-trading readiness 对 trust 的门槛 +- `real_trade_sample_verified` 需要明确是: + - 等真实成交样本 + - 还是用受控演练替代 + +#### N1-2 强化 launch readiness 页面与输出解释 +- 让 `/trading/launch-readiness` 的 blocker / warning 更可操作 +- next_actions 输出更具体 +- workbench 中清楚区分: + - assisted trading ready + - real live ready + +### 验收 +- `/trading/launch-readiness` 的输出可直接指导人工下一步动作 +- 现有 blocker 与 warning 被压缩到最少 +- 边界清晰,不再出现“看起来 ready 但其实不能用”的模糊状态 + +--- + +## Phase N2:最小主动消息告警闭环 + +### 目标 +把当前仅落库的 `decision_alerts` 变成真正可外发的提醒系统。 + +### 为什么这是下一阶段应该做的 +因为当前代码里已经有: + +- alert 生成 +- alert 落库 +- runtime 里持续生成 decision packet +- workbench 已能看到 recent alerts + +缺的只是最后一公里: + +```text +decision_alerts -> dispatcher -> external channel -> delivery record +``` + +### 任务 + +#### N2-1 增加 `alert_deliveries` +- 新增迁移表 +- 增加 `AlertDeliveryRepository` + +#### N2-2 增加 notifier 基础设施 +优先只支持一个通道: + +- Telegram 或 Webhook + +不要一开始做多通道。 + +#### N2-3 增加 `AlertDispatcherService` +- 扫描 queued alerts +- 发送成功回写 delivered +- 失败写入 failed +- 已发送不重复发送 + +#### N2-4 API / 脚本 / runtime 接入 +- `POST /trading/alerts/dispatch` +- `GET /trading/alerts/{id}/deliveries` +- `scripts/run_alert_dispatcher.py` +- runtime 增加 `alert_dispatch` task + +### 范围约束 +当前阶段优先支持: + +- `candidate_actionable` +- `ticket_ready` +- `runtime_degraded`(若当前 alert 模型还未包含,则补最小支持) + +本轮实现与验证中,`candidate_blocked` 也已接入 webhook 路由,用于验证 blocked 类消息的真实外发链路。 +这证明外发机制已打通,但也同时暴露出下一阶段必须处理的噪声与 backlog 压力问题。 + +### 当前结果 +N2 最小闭环已经完成: + +- `alert_deliveries` 已新增并成功落库 +- `AlertDispatcherService` 已接入 API / CLI / runtime +- `POST /trading/alerts/dispatch` 与 `GET /trading/alerts/{alert_id}/deliveries` 已可用 +- `scripts/run_alert_dispatcher.py` 已可单次运行 +- runtime 已包含 `alert_dispatch` task +- 本地 webhook capture 已完成真实端到端演练 +- `alert_deliveries` 中已出现 `channel=webhook`、`status=delivered` 的真实记录 + +### 阶段结论 +主线应从 `N2` 切换到 `N3`。当前主要问题已经不是“能不能发出去”,而是: + +- backlog 很大时 dispatch 是否稳定 +- blocked 类消息是否会造成噪声洪水 +- 在远端 PostgreSQL 不稳定时如何避免整批 dispatch 中断 + +### 验收 +- alert 可以从 queued 变成 delivered / failed / skipped +- runtime 能自动 dispatch +- 外部通道能收到最小文本提醒 +- 已确认在大批量 backlog drain 时仍可能触发 PostgreSQL connection timeout;该问题转入 `N3` 处理 + +--- + +## Phase N3:告警质量与 runtime 硬化 + +### 目标 +在已验证 webhook 外发成功的基础上,解决噪声、重复发送、backlog 处理与数据库不稳定时的稳态运行问题。 + +### 关键问题 +- 重复 blocked alert 会快速堆积 queue +- 大 `limit` backlog dispatch 会在状态回写过程中触发 PostgreSQL connection timeout +- 当前 dispatcher 适合最小闭环验证,但还不适合直接长时间清空大 backlog + +### 建议任务 +- 增加重复抑制与发送节流 +- 将大 backlog drain 改为更保守的批次策略 +- 明确哪些 blocked / observe 类 alert 仅保留 workbench 展示 +- 在 runtime 中降低 alert dispatch 对数据库瞬时压力 + +### 当前新增结果 +N3 第一轮硬化已完成: + +- 增加单条 alert resend cooldown +- 增加跨 alert 记录的等价消息 cooldown / dedupe +- 将 dispatcher 单次处理量收敛到配置上限 +- runtime `alert_dispatch` 任务已跟随配置上限运行 +- 收紧 blocked 外发边界: + - `wait_for_confirmations` 保持 workbench-only + - `do_not_execute` 可进入 webhook 路径 +- 小批量真实 dispatch 验证当前返回 `attempted = 0`,说明当前 pending backlog 已清空 +- 新增 restricted decision support 产品层: + - trust 输出新增 `restricted_decision_support_list` + - readiness 输出新增 `restricted_decision_support_ready` + - blacklist 但 analysis-only 的模型现在可进入“受限辅助决策”语义层,但不会进入执行层 +- 在显式环境注入(数据库 + 代理 + OKX 只读凭据)下已验证: + - `analysis_only_ready = true` + - `decision_support_ready = true` + - `restricted_decision_support_ready = true` + - `execution_assist_ready = false` +- 当前又进一步完成了产品语义收口: + - readiness 输出新增并验证 `product_operating_mode` / `product_capabilities` / `product_boundary_notes` + - decision packet 输出已从错误的 `observe_only + trust_model_policy_missing` 修正为真实的 `restricted_decision_support` + - trust policy 映射已支持模型家族级别的模糊匹配 +- 当前最终验证边界已确认: + - `analysis_only_ready = true` + - `restricted_decision_support_ready = true` + - `decision_support_ready = false` + - `execution_assist_ready = false` + - 其中 `execution_assist_ready = false` 当前是产品边界,不是待修复 bug + +### 当前结论 +N3 的“噪声与批量压力第一轮治理”已经落地。并且本轮已经补上 runtime severity 分层: + +- runtime status 已拆分为 `current_blockers`、`current_warnings`、`current_noisy_incidents` +- runtime incidents 已带 `severity` +- runtime summary 已新增 `runtime_health` +- 派生性的 watch skip 信号已从 warning 下沉到 noisy incident + +下一步更适合进入: + +- queue hygiene 和文档去重收尾 +- 再决定是否继续扩大外发范围 +- 如有需要,再把 noisy incidents 单独接入更轻量的展示或路由策略 + +### 阶段验收 +- backlog drain 在数据库偶发抖动下仍可稳定推进 +- 重复 blocked alert 不再持续轰炸外部通道 +- runtime 中 `alert_dispatch` 任务保持可观测且低噪声 +- `assisted_trading_ready = true` 与 `real_live_ready = false` 边界保持不变 + +--- + +## Phase N3:运行时与告警质量硬化 + +### 目标 +在外发闭环接通后,先治理噪声和稳定性,再谈视觉增强。 + +### 任务 + +#### N3-1 告警节流与去重 +- 同一 candidate/action/status 不重复刷 +- 相同 reason code 最小间隔控制 + +#### N3-2 runtime blockers 分级 +目前 runtime blocker 很容易和“系统不可用”混在一起。下一步建议分: + +- blocker +- warning +- noisy incident + +#### N3-3 决策包文案与字段硬化 +当前 `DecisionPacketService` 已可用,但还可以继续强化: + +- `why_allowed` +- `why_blocked` +- `risk_result` +- `entry/targets/invalidation` 的最终展示一致性 + +### 验收 +- 告警数量可控 +- 同一机会不会过度重复提醒 +- runtime 状态更接近真实值班视角 + +--- + +## Phase N4:视觉盯盘增强预埋,不全面展开 + +### 目标 +只做基础预埋,不做完整视觉主线实现。 + +### 任务 + +#### N4-1 保留视觉设计文档与触发模型 +- 保留 `trigger_type` 设计 +- 保留 `visual_watch_runs / visual_snapshots / visual_reviews` 数据模型设计 + +#### N4-2 只做接口预留,不重投入实现 +如果要动代码,也只做: + +- visual review payload 预留字段 +- runtime task 扩展点预留 + +不要现在就做: + +- 全套 screenshot pipeline +- browser automation service +- visual workbench panels + +### 原因 +当前阶段应先保证: + +- 上游主判断稳定 +- 外发消息真实可用 +- readiness 缺口收敛 + +之后再做视觉增强,收益更高、返工更少。 + +--- + +## 6. 当前不建议作为主线推进的事项 + +以下事项目前不建议作为下一阶段主线: + +### 6.1 不建议现在全面启动视觉盯盘实现 +原因: +- 主系统还在收尾 +- 外发闭环尚未完成 +- launch blockers 还未清空 +- 容易放大噪声 + +### 6.2 不建议现在扩大策略识别器范围 +原因: +- 当前瓶颈不在“少一个 ICT 识别器” +- 瓶颈在可运行性、可信度、通知闭环、soft rollout readiness + +### 6.3 不建议现在推进真实自动下单 +原因: +- 当前边界明确要求不要默认开启 +- assisted trading ready 与 real live ready 必须分离 + +--- + +## 7. 推荐执行顺序 + +下一阶段推荐按下面顺序推进: + +1. `N1` Launch Readiness 收尾 +2. `N2` 最小主动消息告警闭环 +3. `N3` 告警质量与 runtime 硬化 +4. `N4` 视觉盯盘增强预埋 + +换句话说: + +```text +先把“能不能上线辅助使用”讲清楚 +再把“提醒能不能真正发出来”打通 +再治理噪声和稳定性 +最后再为视觉增强铺路 +``` + +--- + +## 8. 最终结论 + +### 结论一 +`AI_ICT_实盘辅助决策系统开发方案_v2.md` 的主方向是合理的,而且当前代码已经证明主骨架基本成立。 + +### 结论二 +视觉盯盘方案也是合理的,但对当前项目阶段来说,应该后置,不应抢主线。 + +### 结论三 +当前最合适的下一阶段,不是做大而全的新能力,而是: + +> **Launch Readiness 收尾 + 最小外发告警闭环 + 运行时/告警硬化** + +这是最符合当前代码现实、最少返工、最容易尽快进入 assisted-trading soft rollout 的路线。 diff --git a/docs/AI_ICT_全自动开发执行流程_v1.md b/docs/AI_ICT_全自动开发执行流程_v1.md index 44c172d..b3cadad 100644 --- a/docs/AI_ICT_全自动开发执行流程_v1.md +++ b/docs/AI_ICT_全自动开发执行流程_v1.md @@ -2,104 +2,292 @@ ## 1. 目标 -本流程用于约束后续 `ai-exchange` 交易模块的默认推进方式: +本流程用于约束后续 `ai-exchange` 项目的默认推进方式。 -> 在用户尽量不介入的前提下,按既定开发方案连续推进,直到完成当前主线全部开发目标。 +从本文件更新后开始,项目默认采用: -这里的“全自动”不是指跳过验证或跳过风险控制,而是指: +> **文档锚点驱动 + 连续自动推进 + 分阶段闭环验证 + 状态自动回写** -- 不等待阶段确认 -- 不把中间子任务交还给用户决策 -- 不在完成一部分后停住 -- 除非遇到明确外部阻塞,否则持续向下一个阶段推进 +这里的“全自动”不是指跳过验证、跳过风控、跳过状态记录,而是指: + +- 不等待每个子阶段的用户确认 +- 不在完成一小部分后停住 +- 不把本可自行继续的工作交还给用户 +- 除非遇到真实外部阻塞,否则自动进入下一可执行步骤 + +本流程已经根据以下文档统一调整: + +- `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` +- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` + +后续该项目默认以本流程作为自动推进协议。 + +--- ## 2. 适用范围 -本流程适用于当前交易主线: +本流程适用于当前仓库两条连续主线: -- `P0-2` 真实账户与订单只读同步 -- `P0-3` 盘中持续调度与运行监督 -- `P0-4` 决策包与主动提醒 -- `P0-5` 真实交易映射与盘后闭环 -- `P1-1` 辅助可信度与模型白名单 -- `P1-2` 操作体验与运营工具 -- `P1-3` live adapter 前准备 +## 2.1 主线 A:AI_ICT 当前主系统收尾与稳态推进 -当前已完成: +包括但不限于: -- `P0-1` 实时市场真值层 +- 当前 N4 visual-watch 相关收尾 +- runtime env 恢复 +- market stream / broker read / readiness 验证恢复 +- 当前 `Real-time Assisted Trading V1` 未完成收尾项 -## 3. 默认执行原则 +## 2.2 主线 B:AI_ICT + TradingAgents 融合实施 -1. **阶段顺序固定** - - 先完成当前阶段,再自动进入下一阶段 - - 不因为阶段内局部成功而停住 +按最终版方案顺序推进: -2. **实现优先于讨论** - - 若需求已明确,则直接实现 - - 若有小歧义,采用合理默认值并记录假设 +1. `P0` 影子接入,不影响现有决策 +2. `P1` 受控进入决策包与运行时 +3. `P2` 经验回流、评估闭环、研究竞技场 +4. `P3` 长期优化循环闭环与状态化运行 +5. `P4` 优化治理与 operator 可见性闭环 +6. `P5` confirmed swing replay 证据闭环 +7. `P6` governance tick 自动巡检闭环 -3. **每阶段必须闭环** - - 代码实现 - - 单元测试 - - 验收命令 - - 部署 - - 远端 smoke - - 状态记录 +--- -4. **不跳过质量门** - - `npm run lint` - - `npm run build` - - `npm run visual:snapshots` - - 阶段相关 focused smoke +## 3. 自动化推进总顺序 -5. **不跳过部署验证** - - 本地通过后必须部署 Docker 服务器 - - 必须核对远端容器健康和关键 API 行为 +默认顺序不是“哪里有想法先做哪里”,而是: -6. **不依赖聊天记忆** - - 每轮结束必须更新状态锚点 - - 使用 `SESSION.md` 和 `TASKS.md` 作为继续推进依据 +```text +先稳住当前 AI_ICT 主骨架 +-> 再推进 Research Council P0 +-> P0 稳定后推进 P1 +-> P1 证明对 operator 真有帮助后再推进 P2 +-> P2 进入长期运行态后推进 P3 优化循环闭环 +-> P3 cycle 稳定后推进 P4 优化治理与可见性 +-> P4 review 指出 bounded replay 缺口后推进 P5 证据闭环 +-> P5 review 恢复 healthy 后推进 P6 governance tick 自动巡检 +-> P6 tick 为 no_op_healthy 后冻结 P0-P6 +-> 进入自动化执行层开发阶段 +``` -## 4. 单阶段标准流程 +更具体地说: -每一个阶段都按以下顺序执行: +1. 先完成当前主系统可执行收尾 +2. 清理外部环境与 runtime 验证阻塞 +3. 再进入融合主线 +4. 融合主线必须遵守影子运行 -> 受控影响 -> 经验回流的顺序 -### Step 1:读取当前锚点 +如果当前出现主系统 bug、readiness 回退、runtime 烟雾失败,则: -读取: +```text +优先修主系统,再继续融合 +``` + +--- + +## 4. 文档锚点与唯一真源 + +每一轮自动推进,必须先读取并以如下锚点为准: + +## 4.1 必读锚点 - `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` +- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` +- `docs/AI_ICT_全自动开发执行流程_v1.md` +- `SESSION.md` +- `TASKS.md` + +## 4.2 辅助锚点 + +当需要判断当前历史阶段、上下文或旧计划映射时,补充读取: + +- `docs/AI_ICT_下一阶段开发计划_v1.md` +- `docs/AI_ICT_下一阶段开发任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P0自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P1自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P2自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P3自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P4自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P5自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P6自动任务清单_v1.md` +- `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` +- `docs/AI_ICT_research_council脚手架与数据表草案_v1.md` + +## 4.3 真源原则 + +需要统一裁决时,优先级如下: + +```text +最新可执行状态 +-> SESSION.md / TASKS.md +-> 当前正式方案文档 +-> 较早的旧计划文档 +``` + +也就是说: + +- 不依赖聊天记忆 +- 不依赖“上次大概做到哪” +- 不依赖旧文档里已经被新方案覆盖的内容 + +--- + +## 5. 默认执行原则 + +1. **阶段顺序固定** + - 不跳阶段 + - 不把 P0 和 P1 混做 + - 不在主骨架未稳时提前把研究层接入执行路径 + +2. **实现优先于讨论** + - 只要锚点已经足够明确,就直接实现 + - 有小歧义时采用合理默认值并在状态中记录假设 + +3. **每一轮必须闭环** + - 代码 / 文档实现 + - focused validation + - 相关质量门 + - 必要时 API / browser / runtime smoke + - 必要时远端 deploy 与 smoke + - `SESSION.md / TASKS.md / docs` 回写 + +4. **先小闭环,再自动进入下一小闭环** + - 每次推进一个可验证切片 + - 完成后立即判断下一个切片,不停顿 + +5. **不制造第二套真相** + - AI_ICT 继续做事实层、规则层、执行边界层 + - Research Council 只做研究推理增强层 + - 不允许平行状态机、平行 readiness、平行 trust + +6. **不让研究层越权** + - 不允许委员会结果直接改写 `risk_result` + - 不允许直接改写 `execution_allowed` + - 不允许直接替代 `DecisionPacketService` 主裁决 + +7. **文档与状态必须同步** + - 新能力落地后,相关 docs 必须同步 + - `SESSION.md` 和 `TASKS.md` 必须反映最新可执行现实 + +--- + +## 6. 单轮自动执行标准流程 + +每一轮自动推进,按以下顺序执行。 + +## Step 0:验证自动化前置条件 + +必须先检查: + +1. 当前仓库路径正确 +2. 基础工具可用 +3. `GSTACK_OK` 规则是否满足(如当前工作依赖该规则) +4. 当前 `.venv` 或等价运行环境可用 + +如果这些前置条件缺失且可自动修复,则先修复再继续。 + +--- + +## Step 1:读取当前锚点 + +必须读取: + +- `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` +- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` - `docs/AI_ICT_全自动开发执行流程_v1.md` - `SESSION.md` - `TASKS.md` 确认: +- 当前活跃主线 - 当前活跃阶段 -- 当前阻塞项 +- 当前真实阻塞项 - 当前验收口径 +- 当前下一默认入口 -### Step 2:实现阶段目标 +--- + +## Step 2:确定“本轮最小可执行切片” + +自动从当前锚点中选出最小可闭环切片。 + +优先级规则: + +1. 先处理主系统 blocker +2. 再处理当前活跃阶段的最小闭环 +3. 再处理增强解释层 +4. 最后才处理扩展和优化 + +切片应满足: + +- 能在一轮内形成可验证结果 +- 能落在现有模块边界内 +- 不需要等待用户做新的产品决策 + +不允许把一个阶段拆成大量无验证的长链未完工改动。 + +--- + +## Step 3:实现当前切片 + +实现时必须遵守: + +1. 优先复用现有服务、仓储、API、脚本、前端结构 +2. 不新增平行状态机 +3. 不把研究层反向写成主事实层 +4. Research Council 相关改动统一落到: + +```text +src/services/research_council/ +``` + +5. 优先接入现有主链: + - `decision_packet` + - `review_packet` + - `runtime` + - `workbench` + - `visual_watch` + +--- + +## Step 4:补足 focused tests / checks 要求: -- 优先复用现有服务与仓储 -- 不新增平行状态机 -- 优先接入当前主链 -- 只在必要时扩展 schema、API、脚本、前端 +1. 先补 touched modules 的 focused tests +2. 再补相关脚本 smoke +3. 再补相关 API smoke +4. 再决定是否需要更大范围回归 -### Step 3:补足阶段测试 +对于不同改动类型,要求不同: -要求: +### A. 文档-only -- 先补 focused tests -- 再跑相关链路回归 -- 最后纳入全量 discover +- 检查文档结构、标题、路径、锚点引用 -### Step 4:执行本地验收 +### B. 纯后端逻辑 -必须执行: +- focused unit tests +- 相关脚本 smoke +- 相关 API smoke + +### C. runtime / API / workbench / DB + +- focused tests +- 相关脚本 smoke +- API smoke +- browser smoke +- 必要时 deploy smoke + +--- + +## Step 5:执行本地质量门 + +默认本地验证顺序为: + +1. touched modules focused tests +2. `tests/unit` +3. `scripts/run_offline_checks.py` +4. 若涉及前端或静态资源,再执行: ```bash npm run lint @@ -107,122 +295,532 @@ npm run build npm run visual:snapshots ``` -补充执行: +5. 若需要本地 API / browser 验证: + - 启动本地 API server + - 执行 API smoke + - 执行 browser smoke -- 当前阶段 focused tests -- 当前阶段脚本 smoke -- 当前阶段 API smoke +原则: -### Step 5:提交代码 +- 不因为“只是小改动”跳过应有验证 +- 也不因为“只是文档改动”强行做无意义部署 -要求: +--- -- 每个阶段至少形成一个清晰 commit -- commit message 必须能表明当前阶段核心目标 +## Step 6:决定是否需要远端部署验证 -### Step 6:部署 Docker 服务器 +不是每轮都必须远端部署,但以下情况必须部署并做远端 smoke: -要求: +1. 改动涉及 runtime +2. 改动涉及 API +3. 改动涉及 DB schema / migration +4. 改动涉及 workbench / operator 真实可见行为 +5. 改动涉及 alert / broker / readiness / decision packet / review packet 主链 -- 部署当前 `HEAD` -- 执行 migration -- 等待容器 `healthy` +以下情况可不部署远端: -### Step 7:执行远端 smoke +1. 文档-only +2. 纯本地测试支撑代码 +3. 尚未进入运行链路的孤立脚手架 -至少验证: +如果不部署远端,必须在状态里明确说明原因。 + +--- + +## Step 7:执行远端 deploy 与 smoke + +需要远端验证时,默认流程为: + +1. 部署当前 `HEAD` +2. 执行 migration +3. 等待容器 `healthy` +4. 验证关键 API +5. 验证关键 runtime 状态 +6. 验证 browser / operator 关键可见结果 + +至少应覆盖: - `/health` -- 当前阶段新增/影响的核心 API -- 当前阶段关键运行状态 +- 当前阶段新增或影响的核心 API +- 当前阶段关键状态输出 -### Step 8:更新状态锚点 +涉及主系统时,还要特别验证: -必须更新: +- `launch-readiness` +- `runtime-status` +- `decision-packet` +- `trust-score` +- `review-packet` + +涉及 Research Council 时,还要验证: + +- `research-council` 手动触发入口 +- packet / review 中的研究增强字段 +- 失败降级行为 + +--- + +## Step 8:写回状态锚点与方案文档 + +每轮结束必须更新: - `SESSION.md` - `TASKS.md` -更新内容: +必要时同步更新: -- 已完成阶段 -- 当前阶段结果 -- 新的阻塞项 -- 下一阶段默认入口 +- `docs/AI_ICT_TradingAgents融合实施方案_最终版.md` +- 当前阶段相关任务清单 / 技术设计文档 -### Step 9:自动进入下一阶段 +必须写回的内容: -如果当前阶段已经满足: +- 当前完成了什么 +- 当前切片是否闭环 +- 当前未完成但仍可继续的下一入口 +- 新出现的 blocker / warning / noisy incident +- 是否已 deploy / smoke +- 若未 deploy,为何未 deploy -- 代码完成 -- 测试通过 -- 部署通过 -- smoke 通过 +原则: + +> 状态锚点必须反映“现在真实能继续做什么”,而不是只写“做过什么”。 + +--- + +## Step 9:自动决定下一轮入口 + +若本轮已经满足: + +- 当前切片代码/文档完成 +- 相应验证完成 +- 必要时 deploy / smoke 完成 +- 状态锚点已回写 则: ```text -直接进入下一阶段,不等待用户确认 +直接进入下一可执行切片,不等待用户确认 ``` -## 5. 阻塞处理规则 +下一入口优先级: + +1. 同一阶段剩余闭环切片 +2. 当前阶段收尾 +3. 下一阶段最小切片 + +但当当前阶段为 `Research Council P6` 且 governance tick 返回 `no_op_healthy` 时,不进入下一阶段,也不自动创建 P7。此时应停止 Research Council 阶段扩展,并进入自动化执行层开发准备。 + +--- + +## Step 10:阶段晋级判定 + +当同一阶段的切片已经基本闭环时,不能只因为“本轮跑完了”就默认跳到下一阶段,必须执行阶段晋级判定。 + +统一规则如下: + +### A. P0 晋级到 P1 + +只有同时满足以下条件,才允许自动进入 P1: + +1. `research_council` 手动 API / script 能稳定跑通 +2. 没有委员会结果时,原系统行为不回归 +3. 有委员会结果时,`review packet` 能稳定展示委员会摘要 +4. council 失败时,runtime 自动降级但不中断主链 +5. 连续多次 focused smoke 无新增主链 blocker + +否则: + +```text +继续停留在 P0 +-> 自动补同阶段剩余缺口 +-> 不提前切到 P1 +``` + +### B. P1 晋级到 P2 + +只有同时满足以下条件,才允许自动进入 P2: + +1. `decision_packet` 已稳定吸收委员会解释字段 +2. `runtime` 已受控接入 `research_council` +3. `workbench` 已稳定展示支持 / 反对 / 分歧 / follow-up checks +4. `visual_watch` 已能消费委员会建议 +5. 未对 `execution gate` / `live readiness` / `operator confirmation protocol` 造成回归 + +否则继续停留在 P1 收尾。 + +### C. P2 阶段出口 + +P2 不是一次性结束点,而是进入持续运行与评估优化模式。 + +当以下条件成立时,自动切换为“长期运行态”: + +1. lesson backfill 机制稳定 +2. checkpoint / resume 机制稳定 +3. 评估脚本能持续比较 council 与非 council 效果 +4. 内部研究竞技场已能服务后续迭代 + +进入长期运行态后,默认动作变为: + +```text +持续评估 +-> 识别问题 +-> 回到 P2 内部优化 +-> 必要时回流修正 P1 或 P0 +``` + +### D. 阶段判定失败时的统一状态写法 + +如果阶段执行完成但晋级门未通过,状态锚点应明确写成以下之一: + +- `P0 部分完成` +- `P0 待收尾` +- `P0 环境阻塞` +- `P1 部分完成` +- `P1 待收尾` +- `P1 环境阻塞` +- `P2 进入长期运行态` + +不允许写成模糊的“基本完成”后直接跳段。 + +### E. P3-P6 状态化出口 + +P3-P6 不再使用传统“晋级门”语义,而使用状态化出口: + +1. P3 完成后进入 P4,前提是 optimization cycle 可持久化、可查询、可通过 CLI / API 消费。 +2. P4 完成后根据 review 结果分流: + - 若返回 `add_replay_fixture_with_confirmed_swings`,进入 P5。 + - 若 review 已是 `healthy` 且无 bounded action,直接进入 P6。 + - 若返回其他 `needs_attention`,只打开最小 bounded slice,不自动扩新阶段。 +3. P5 完成后必须回到 P4/P6 review,确认原 bounded action 已关闭。 +4. P6 是 Research Council 当前主线的终端治理入口: + - `no_op_healthy`:停止,不写代码,不扩 P7。 + - `cycle_required`:只有策略允许时补跑 cycle。 + - `bounded_slice_required`:打开最小 bounded slice,完成后回到 P6。 + +P6 之后如需继续扩大阶段,必须是用户明确提出的新产品阶段规划,而不是自动化默认行为。 + +--- + +## 7. 针对当前融合主线的自动化阶段策略 + +## 7.1 阶段 A:先稳住当前 AI_ICT 主骨架 + +默认优先处理: + +- 当前 N4 visual-watch 相关收尾 +- runtime env 恢复 +- market stream / broker credentials / proxy 恢复 +- readiness / runtime / broker smoke 回归 + +这一阶段不应被新的 Research Council 扩展打断。 + +--- + +## 7.2 阶段 B:Research Council P0 + +目标: + +- 影子接入 +- 不影响现有决策 +- 只读消费 AI_ICT 事实 +- 结果先进入 review packet,再逐步扩展 + +默认切片顺序: + +1. `research_council` 目录脚手架 +2. `council_context_builder` +3. `structure_analyst` +4. `risk_analyst` +5. `bull_researcher / bear_researcher` +6. `research_manager` +7. 手动 API / script 触发 +8. review packet 只读展示 + +本阶段禁止: + +- 改写 `action_recommendation` +- 改写 `risk_result` +- 改写 `execution_allowed` + +完成本阶段后,必须执行 `Step 10` 的 P0 晋级判定,判定通过才可切入 P1。 + +--- + +## 7.3 阶段 C:Research Council P1 + +目标: + +- 受控进入 `decision_packet` +- 受控进入 `runtime` +- 增强 operator / workbench 的解释质量 + +默认切片顺序: + +1. runtime 任务接入 +2. packet 字段扩展 +3. `news_event_analyst` +4. `visual_analyst` +5. disagreement / follow-up checks +6. visual watch 建议联动 +7. workbench 展示增强 + +本阶段仍禁止: + +- 改写执行闸门 +- 改写 live readiness +- 绕过 operator confirmation protocol + +完成本阶段后,必须执行 `Step 10` 的 P1 晋级判定,判定通过才可切入 P2。 + +--- + +## 7.4 阶段 D:Research Council P2 + +目标: + +- 经验回流 +- checkpoint / reflection / lessons +- 内部评估闭环 + +默认切片顺序: + +1. `research_council_lessons` +2. replay / journal / weekly-review lesson backfill +3. council checkpoint / resume +4. 评估脚本 +5. 内部研究竞技场 + +本阶段仍然不是平台化阶段。 + +本阶段完成后,不是直接“项目结束”,而是执行 `Step 10` 的 P2 阶段出口判定,进入长期运行与评估优化模式。 + +--- + +## 7.5 阶段 E:Research Council P3 + +目标: +- 把 P2 的长期运行态固化为可重复执行的 optimization cycle +- 持久化 lesson / replay / eval / arena summary +- 给下一轮 bounded optimization 输出明确动作 + +默认切片顺序: +1. `research_council_optimization_cycles` +2. `CouncilOptimizationService` +3. `run_research_council_optimization_loop.py` +4. optimization cycle API +5. focused unit tests / script smoke / API smoke +6. `SESSION.md` 与 `TASKS.md` 状态回写 + +本阶段完成后,进入 Research Council P3 long-running optimization loop。该循环只产生 operator follow-up 与下一轮研究优化建议,不扩大 execution gate / live readiness / trust bucket 权限。 + +--- + +## 7.6 阶段 F:Research Council P4 + +目标: +- 将最近 P3 optimization cycles 转成治理判断 +- 暴露 stale / needs_more_samples / needs_attention / healthy 状态 +- 接入 CLI / API / review packet / workbench + +默认切片顺序: +1. `CouncilOptimizationReviewService` +2. `run_research_council_optimization_review.py` +3. optimization review API +4. review packet 与 workbench 可见性 +5. focused unit tests / script smoke / API smoke +6. remote deploy / smoke +7. `SESSION.md` 与 `TASKS.md` 状态回写 + +本阶段完成后,进入 Research Council P4 Optimization Review Governance。该阶段只产生 operator follow-up 和下一轮 bounded optimization 建议,不自动改变模型、trust bucket、execution gate 或 live readiness。 + +--- + +## 7.7 阶段 G:Research Council P5 + +目标: +- 将 P4 的 `add_replay_fixture_with_confirmed_swings` 建议落成可部署 replay evidence +- 用 confirmed swing high / low 样本重跑 optimization loop +- 确认 latest review 不再把 replay fixture 作为待办缺口 + +默认切片顺序: +1. `data/replay/research_council_confirmed_swings_v1.jsonl` +2. replay quality gate 与 focused tests +3. optimization replay summary evidence +4. optimization loop rerun +5. optimization review / review packet / workbench smoke +6. remote deploy / remote API cycle / remote review smoke +7. `SESSION.md` 与 `TASKS.md` 状态回写 + +本阶段完成后,进入 Research Council P5 Confirmed Swing Replay Evidence。该阶段只关闭 replay evidence 缺口,不自动改变模型、trust bucket、execution gate 或 live readiness。 + +--- + +## 7.8 阶段 H:Research Council P6 + +目标: +- 将 healthy optimization review 转成可调度 no-op tick +- 将 stale/missing/needs_attention 转成明确自动化状态 +- 避免健康状态下重复打开无意义开发切片 + +默认切片顺序: +1. `CouncilGovernanceTickService` +2. `run_research_council_governance_tick.py` +3. governance tick API +4. focused unit tests / script smoke / API smoke +5. remote deploy / remote API smoke +6. `SESSION.md` 与 `TASKS.md` 状态回写 + +本阶段完成后,进入 Research Council P6 Governance Tick。该阶段只做周期治理判断,不自动改变模型、trust bucket、execution gate 或 live readiness。 + +--- + +## 7.9 P0-P6 审查与阶段冻结 + +目标: +- 审查 P0-P6 是否合理 +- 修正自动化流程中可能导致无限扩阶段的地方 +- 明确下一步进入自动化执行层开发 + +审查结论: + +```text +P0-P6 合理 +P0-P6 阶段冻结 +P6 为默认治理入口 +no_op_healthy 时停止 +不自动创建 P7 +``` + +正式进入自动化执行层开发的条件: + +1. `docs/AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md` 已存在 +2. `SESSION.md` / `TASKS.md` 已写明 `automation_flow_p0_p6_review_closed` +3. P6 governance tick 最近一次结果为 `no_op_healthy` +4. 文档一致性测试通过 + +--- + +## 7.10 自动化执行层 P7:监控成熟度与晋级路由 + +用户明确要求从 P7 开始设计自动化时,P7 只代表自动化执行层的新阶段,不代表 `Research Council P7`。 + +P7 的目标是打破以下健康巡检循环: + +```text +continue_periodic_analysis_decision_monitoring +-> monitor passed +-> quality gate passed +-> continue_periodic_analysis_decision_monitoring +``` + +新的稳定窗口出口为: + +```text +analysis_decision_monitor_quality_gate=passed +stable_monitoring_window=true +latest blockers=[] +latest warnings=[] +P6 governance tick=no_op_healthy +-> automation_execution_layer_p7_monitor_maturity_rollup +``` + +P7 默认不扩大交易能力,只做: + +1. 监控成熟度摘要 +2. 本地/远端状态漂移检查 +3. alert 与 decision-support 输出质量审查 +4. 唯一下一个 bounded automation slice 选择 +5. `SESSION.md` / `TASKS.md` / `.claude` 状态回写 + +P7 必须继续遵守: + +- 不修改 `execution_allowed` +- 不修改 `real_live_ready` +- 不绕过 risk governor +- 不升级或降级 trust bucket +- 不绕过 operator confirmation protocol +- 不把 OKX fills 为 0 当作当前 analysis/decision-only 阶段 blocker +- 不创建 Research Council P7 + +P7 任务清单以以下文档为准: + +```text +docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md +``` + +--- + +## 8. 阻塞处理规则 只有以下情况允许停下来请求用户: -1. 密钥/凭据缺失,且无法从现有环境推断 +1. 密钥 / 凭据缺失,且无法从现有环境推断 2. 外部账号权限不足 -3. 需要破坏性操作且风险不可逆 +3. 需要不可逆破坏性操作 4. 产品决策确实无法合理默认 -5. 上游网络/供应商限制导致无法继续验证 +5. 上游网络 / 供应商 / 远端环境阻塞导致无法继续验证 除上述情况外: - 默认自行排查 - 默认自行修复 -- 默认最多连续尝试 2-3 轮 +- 默认继续推进当前阶段的非阻塞部分 -## 6. 风险控制规则 +如果 blocker 只影响远端验证而不影响本地实现,则: + +```text +先完成本地闭环 -> 记录阻塞 -> 转入下一本地可执行切片 +``` + +--- + +## 9. 边界守卫规则 + +以下守卫是硬规则: 1. 不默认开启真实自动下单 2. 不让 LLM 绕过风控 -3. 不在未验证前跳过 Docker 部署验证 -4. 不在远端 smoke 未完成时宣告阶段完成 -5. 不把环境问题伪装成代码完成 +3. 不让 Research Council 重写主事实层 +4. 不让 Research Council 直接替代 trust / readiness / execution gate +5. 不在远端 smoke 未完成时把 runtime / API / workbench 主链改动宣告完成 +6. 不把环境问题伪装成代码完成 +7. 不因为“自动化”省略状态回写 -## 7. 当前阶段队列 +--- -当前默认执行顺序如下: +## 10. 单轮完成定义 -1. `P0-2` 真实账户与订单只读同步 -2. `P0-3` 盘中持续调度与运行监督 -3. `P0-4` 决策包与主动提醒 -4. `P0-5` 真实交易映射与盘后闭环 -5. `P1-1` 辅助可信度与模型白名单 -6. `P1-2` 操作体验与运营工具 -7. `P1-3` live adapter 前准备 +一个自动化切片只有满足以下条件,才算真正完成: -## 8. 单阶段完成定义 - -一个阶段只有满足以下条件,才算真正完成: - -- 阶段文档目标已实现 -- 阶段验收条件已满足 -- 本地质量门通过 -- Docker 部署成功 -- 远端 smoke 成功 -- 状态锚点已更新 +- 锚点目标已实现 +- 对应 focused validation 已完成 +- 需要时本地质量门已通过 +- 需要时 deploy / remote smoke 已通过 +- `SESSION.md / TASKS.md / docs` 已回写 否则只能算: ```text -部分完成 / 待收尾 / 被环境阻塞 +部分完成 / 待收尾 / 被环境阻塞 / 仅本地完成 ``` -## 9. 当前默认工作模式 +--- -从本文件生效起,默认工作模式为: +## 11. 当前默认工作模式 + +从本文件生效起,后续该项目默认工作模式为: ```text -自动读取锚点 -> 自动实现当前阶段 -> 自动验证 -> 自动部署 -> 自动 smoke -> 自动写回状态 -> 自动进入下一阶段 +自动读取锚点 +-> 自动判定当前主线与最小可执行切片 +-> 自动实现 +-> 自动 focused validation +-> 自动执行必要质量门 +-> 自动决定是否 deploy / smoke +-> 自动写回 SESSION / TASKS / docs +-> 自动进入下一切片 ``` +换句话说: + +> **以后该项目默认全程采用自动化流程推进,除非遇到真实外部阻塞或必须用户拍板的产品决策。** diff --git a/docs/AI_ICT_外部repair_agent接入说明_v1.md b/docs/AI_ICT_外部repair_agent接入说明_v1.md new file mode 100644 index 0000000..48b012f --- /dev/null +++ b/docs/AI_ICT_外部repair_agent接入说明_v1.md @@ -0,0 +1,521 @@ +# AI ICT 外部 repair agent 接入说明 v1 + +## 1. 文档目标 + +本文档用于说明: + +> 如果你想编写一个外部 repair agent,并通过 `scripts/run_monitoring_alert_rollout.py` 的 `repair-bridge` 模式接入当前自动化流程,应该如何实现。 + +本文档面向: + +- 你自己写的 Python 修复脚本 +- 其他 AI 代理包装器 +- Codex / Claude / 其他模型的桥接程序 +- 后续可能扩展出来的自动修复子进程 + +--- + +## 2. repair agent 的职责 + +外部 repair agent 的职责不是管理整个流程,而是专注完成这一步: + +```text +接收失败阶段上下文 +-> 分析应该修什么 +-> 修改代码 +-> 可选地执行少量 focused tests +-> 返回结构化 JSON 结果 +``` + +也就是说: + +- **主 orchestrator** 负责:阶段推进、状态管理、复验、phase advance +- **外部 repair agent** 负责:拿到上下文之后,尽量完成本轮修复 + +--- + +## 3. 调用方式 + +当前主脚本通过环境变量指定外部 repair agent 命令,例如: + +```bash +MONITORING_ALERT_AUTO_REPAIR_ENABLED=1 \ +MONITORING_ALERT_REPAIR_BRIDGE_COMMAND="python your_repair_agent.py" \ +MONITORING_ALERT_ROLLOUT_MODE=repair-bridge \ +.venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +主脚本会: + +1. 生成 bridge input JSON +2. 通过 **stdin** 传给 `your_repair_agent.py` +3. 从 **stdout** 读取 repair result JSON +4. 解析结果并决定是否自动复验 + +--- + +## 4. stdin 输入协议 + +repair agent 必须从 stdin 读取 JSON。 + +当前协议版本: + +```text +repair-bridge/v1 +``` + +### 必填字段 + +- `protocol_version` +- `mode` +- `phase` +- `phase_title` +- `failure_class` +- `recommended_focus` +- `phase_patch_targets` +- `validation_entrypoint` +- `repair_prompt_context` + +### 示例 + +```json +{ + "protocol_version": "repair-bridge/v1", + "mode": "repair_loop", + "phase": "N2", + "phase_title": "最小主动消息告警闭环", + "failure_class": "alert_dispatch_gap", + "recommended_focus": "alert_delivery_and_dispatch", + "phase_patch_targets": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" + ], + "validation_entrypoint": ".venv/bin/python scripts/run_alert_dispatcher.py", + "repair_prompt_context": { + "prompt_text": "...", + "phase_tasks": ["..."], + "phase_files": ["..."], + "suggested_files": ["..."], + "suggested_commands": ["..."] + } +} +``` + +--- + +## 5. 你应该如何使用输入字段 + +## 5.1 `phase` +表示当前处在哪个开发阶段。 + +比如: + +- `N1` +- `N2` +- `N3` +- `N4` + +你可以用它决定修复策略范围。 + +--- + +## 5.2 `failure_class` +这是 orchestrator 归纳后的失败类型。 + +常见值: + +- `missing_dependency_or_import` +- `missing_file_or_artifact` +- `validation_failure` +- `missing_implementation` +- `readiness_gap` +- `alert_dispatch_gap` +- `runtime_gap` +- `decision_packet_gap` +- `blocker:` + +用途: + +- 帮你先快速判断本轮修复重点 +- 降低 repair agent 自己重新归类的成本 + +--- + +## 5.3 `recommended_focus` +这是主脚本给 repair agent 的聚焦建议。 + +例如: + +- `readiness_and_boundary_hardening` +- `alert_delivery_and_dispatch` +- `runtime_scheduler_and_status` +- `decision_packet_and_operator_view` + +建议: + +- 优先服从这个 focus +- 避免本轮修到无关模块 + +--- + +## 5.4 `phase_patch_targets` +这是当前最值得优先看的文件列表。 + +建议: + +- 先读这些文件 +- 只在必要时扩展到其它文件 + +这有助于控制修复范围。 + +--- + +## 5.5 `validation_entrypoint` +这是本轮首个失败命令或者最重要的验证入口。 + +例如: + +```text +.venv/bin/python scripts/run_alert_dispatcher.py +``` + +建议: + +- 修完后优先自己尝试跑这个命令 +- 如果你已经跑了,把命令写到 `tests_run` + +--- + +## 5.6 `repair_prompt_context` +这里面通常包含最完整的上下文: + +- `prompt_text` +- `phase_tasks` +- `phase_files` +- `suggested_files` +- `suggested_commands` +- `reasoning` + +如果你只想用一段“人类可读修复提示”,优先用: + +```text +repair_prompt_context.prompt_text +``` + +--- + +## 6. stdout 输出协议 + +repair agent 必须向 stdout 输出 **一段合法 JSON**。 + +### 当前必填字段 + +- `status` +- `summary` +- `changed_files` + +### 可选字段 + +- `protocol_version` +- `notes` +- `success_guess` +- `tests_run` + +--- + +## 7. 每个输出字段该怎么写 + +## 7.1 `status` +建议值: + +- `applied` +- `success` +- `fixed` +- `failed` +- `blocked` +- `noop` + +当前 orchestrator 认为这些值可以进入自动复验候选: + +- `applied` +- `success` +- `fixed` + +如果你根本没改代码,不要写 `applied`。 + +--- + +## 7.2 `summary` +一句简短总结。 + +好的写法: + +- `Implemented missing alert dispatcher module and updated runtime hook.` +- `Fixed readiness panel payload mismatch and aligned API fields.` + +不好的写法: + +- `done` +- `fixed stuff` + +--- + +## 7.3 `changed_files` +必须是数组,列出本轮真正改动的文件路径。 + +例如: + +```json +"changed_files": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" +] +``` + +非常重要: + +- 如果你没改任何文件,不要伪造 +- 如果只改 docs,也应如实返回 docs 文件 +- 这个字段会影响 orchestrator 后续智能复验选择 + +--- + +## 7.4 `success_guess` +布尔值。 + +建议: + +- `true`:你认为这轮修复值得复验 +- `false`:你知道还没修完,不建议进入自动复验 + +如果你不确定,可以不写;但如果明确没修好,最好写 `false`。 + +--- + +## 7.5 `tests_run` +建议写上本轮 repair agent 自己已经执行过的 focused commands。 + +例如: + +```json +"tests_run": [ + ".venv/bin/python scripts/run_alert_dispatcher.py" +] +``` + +好处: + +- orchestrator 会优先用这些命令做 focused rerun +- 复验逻辑更精准 + +如果你没跑任何命令,可以不写。 + +--- + +## 7.6 `notes` +可选。可以写本轮修复的一些额外说明。 + +例如: + +```json +"notes": [ + "Focused on N2 alert delivery gap", + "Did not touch visual-watch code" +] +``` + +--- + +## 8. 一个最小合法输出示例 + +```json +{ + "protocol_version": "repair-bridge/v1", + "status": "applied", + "summary": "Implemented missing alert dispatcher module and updated runtime hook.", + "changed_files": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" + ], + "success_guess": true, + "tests_run": [ + ".venv/bin/python scripts/run_alert_dispatcher.py" + ] +} +``` + +--- + +## 9. stdout 和 stderr 的规则 + +这是最重要的实践规则之一: + +## stdout +只能输出最终 JSON。 + +不要把这些内容输出到 stdout: + +- 调试日志 +- print 进度 +- traceback +- 人类说明文字 + +否则 orchestrator 会解析失败。 + +## stderr +所有日志都应写到 stderr。 + +包括: + +- 调试日志 +- 运行步骤 +- 中间状态 +- 额外说明 + +--- + +## 10. 最小 Python repair agent 示例 + +下面给一个最小骨架: + +```python +import json +import sys +from pathlib import Path + + +def main(): + raw = sys.stdin.read() + request = json.loads(raw) + + phase = request.get("phase") + patch_targets = request.get("phase_patch_targets") or [] + context = request.get("repair_prompt_context") or {} + + print(f"[repair-agent] phase={phase}", file=sys.stderr) + print(f"[repair-agent] patch_targets={patch_targets}", file=sys.stderr) + + changed_files = [] + tests_run = [] + + # 这里写你自己的修复逻辑 + # 例如:读取 patch_targets,修改文件,运行一个 focused command + + response = { + "protocol_version": "repair-bridge/v1", + "status": "applied", + "summary": "Applied a placeholder repair.", + "changed_files": changed_files, + "success_guess": True, + "tests_run": tests_run, + } + + sys.stdout.write(json.dumps(response, ensure_ascii=False)) + + +if __name__ == "__main__": + main() +``` + +--- + +## 11. 推荐实现步骤 + +如果你要做一个真正可用的 repair agent,建议按这个顺序: + +### Step 1 +先实现: +- stdin 读 JSON +- stdout 写 JSON +- stderr 写日志 + +### Step 2 +只消费: +- `phase` +- `failure_class` +- `phase_patch_targets` +- `repair_prompt_context.prompt_text` + +### Step 3 +修完后正确写: +- `changed_files` +- `success_guess` +- `tests_run` + +### Step 4 +再逐步加高级能力: +- patch target 优先级 +- 局部测试 +- 风险边界保护 +- 多轮修复策略 + +--- + +## 12. 与 orchestrator 的交互建议 + +repair agent 最好遵守这些原则: + +1. **范围尽量小** + 优先修当前 phase patch targets,不要顺手大改无关模块。 + +2. **日志进 stderr** + 保持 stdout 纯 JSON。 + +3. **changed_files 要真实** + 后续智能复验靠它决定。 + +4. **tests_run 要真实** + 不要写没执行过的命令。 + +5. **success_guess 要诚实** + 没修好就写 `false`,比误导自动复验更好。 + +--- + +## 13. 当前脚本会如何使用你的返回值 + +当前 orchestrator 会消费: + +- `status` +- `summary` +- `changed_files` +- `success_guess` +- `tests_run` + +用于决定: + +1. 是否自动复验 +2. 跑哪些 focused rerun commands +3. 是否推进 phase +4. patch round 历史记录内容 + +--- + +## 14. 常见错误 + +### 错误 1:stdout 混入日志 +后果:JSON 解析失败。 + +### 错误 2:`changed_files` 不是数组 +后果:bridge output schema invalid。 + +### 错误 3:没改文件却返回 `applied` +后果:会触发不必要复验,污染历史。 + +### 错误 4:已经知道没修好还写 `success_guess: true` +后果:会误导自动复验逻辑。 + +### 错误 5:运行了 focused tests 却不写 `tests_run` +后果:orchestrator 无法利用你的执行信息做更精准复验。 + +--- + +## 15. 总结 + +如果你要做兼容当前自动化流程的外部 repair agent,最重要的就是三句话: + +1. **stdin 读 JSON** +2. **stdout 只输出 JSON** +3. **真实返回 changed_files / success_guess / tests_run** + +做到这一点,就已经可以稳定接入当前的 `repair-bridge` 自动化链路。 \ No newline at end of file diff --git a/docs/AI_ICT_方案一系统边界图与模块融合图_v1.md b/docs/AI_ICT_方案一系统边界图与模块融合图_v1.md new file mode 100644 index 0000000..4eda44e --- /dev/null +++ b/docs/AI_ICT_方案一系统边界图与模块融合图_v1.md @@ -0,0 +1,447 @@ +# 方案一的系统边界图 + 模块融合图 + +目标架构一句话: + +> **AI_ICT 保留“实盘辅助分析决策底座”** +> **TradingAgents 只提供“研究推理增强层”** + +--- + +# 一、系统边界总图 + +```text +┌──────────────────────────────────────────────┐ +│ AI_ICT 主系统 │ +│ (实盘辅助分析决策底座,继续做核心) │ +├──────────────────────────────────────────────┤ +│ 1. Market Truth / Stream / Fallback │ +│ 2. Broker Read Sync │ +│ 3. Runtime Scheduler │ +│ 4. Launch / Readiness │ +│ 5. Decision Packet │ +│ 6. Alert / Review / Operator Flow │ +│ 7. Visual Watch / Snapshot / Review │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Research Enhancement Layer │ +│ (吸收 TradingAgents 思想,不反客为主) │ +├──────────────────────────────────────────────┤ +│ A. Multi-Analyst │ +│ B. Bull / Bear Debate │ +│ C. Research Summary │ +│ D. Reflection Memory │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ AI_ICT 输出层(仍由 AI_ICT 主导) │ +├──────────────────────────────────────────────┤ +│ - enriched decision packet │ +│ - enriched review packet │ +│ - workbench explanation │ +│ - operator next actions │ +└──────────────────────────────────────────────┘ +``` + +--- + +# 二、哪些模块必须保留 AI_ICT 原生 + +这些不能让位给 TradingAgents。 + +## 1. 实盘状态底座 +保留: + +- `market truth` +- `market stream` +- `rest fallback` +- `data gap detection` +- `session/news window` + +原因: +TradingAgents 不擅长这块,AI_ICT 这部分更贴近真实盘中环境。 + +--- + +## 2. 账户只读与实盘环境适配 +保留: + +- broker read model +- reconciliation +- runtime env / proxy / credentials +- readiness / launch boundary + +原因: +这是 AI_ICT 的实盘工程护城河。 + +--- + +## 3. Decision Packet 主输出 +保留: + +- `decision_packet_service` +- `review_packet` +- `why_blocked / why_allowed` +- `trust / restricted_decision_support` + +原因: +TradingAgents 应该增强它,不应该替代它。 + +--- + +## 4. Alert / Operator / Visual 闭环 +保留: + +- alerts +- operator notes +- visual watch +- visual review +- runtime incident semantics + +原因: +这些都属于 AI_ICT 现有主线资产。 + +--- + +# 三、哪些能力来自 TradingAgents 思想 + +这些适合“嵌进去”。 + +## 1. Multi-Analyst +新加研究角色: + +- structure analyst +- news analyst +- sentiment analyst +- visual analyst +- risk analyst + +注意: +这里是“借思想”,不是整包引 TradingAgents 的全部实现。 + +--- + +## 2. Bull / Bear Debate +新加对立论证层: + +- bull case +- bear case +- shared facts +- net assessment + +它不直接替代决策包,只是增强解释质量。 + +--- + +## 3. Reflection Memory +新加分析反思层: + +- 记录过去决策 +- 回填后验结果 +- 抽取经验 +- 为下一次分析提供上下文 + +--- + +## 4. Structured Research Output +统一输出 research 结果,供: + +- decision packet +- review packet +- UI 展示 + +--- + +# 四、推荐的模块连接方式 + +--- + +## 连接方式总图 + +```text +Market Truth +Broker Snapshot +Plan / Candidate / Signal +Trust Summary +Visual Review +Operator Notes + │ + ▼ +Research Context Builder + │ + ▼ +Research Orchestrator + ├─ Structure Analyst + ├─ News Analyst + ├─ Sentiment Analyst + ├─ Visual Analyst + └─ Risk Analyst + │ + ▼ +Debate Service + ├─ Bull Case + └─ Bear Case + │ + ▼ +Research Output Formatter + │ + ▼ +Decision Packet Enricher + │ + ├─ Decision Packet + ├─ Review Packet + └─ Workbench UI +``` + +--- + +# 五、模块融合图 + +## 1. AI_ICT 现有核心模块不动,只加一个新层 + +### 新增一层: +```text +src/services/research/ +``` + +建议拆分: + +```text +src/services/research/ +├── research_context_builder.py +├── research_orchestrator_service.py +├── research_output_formatter.py +├── debate_service.py +├── research_memory_service.py +└── agents/ + ├── structure_analyst.py + ├── news_analyst.py + ├── sentiment_analyst.py + ├── visual_analyst.py + └── risk_analyst.py +``` + +--- + +## 2. Decision Packet 作为主汇总点 +现有: + +- `src/services/decision/decision_packet_service.py` + +改造方式: + +### 现在 +它主要整合: +- plan +- candidate +- signal +- ticket +- risk +- trust + +### 改后 +在这些之上,再整合: +- research summary +- bull case +- bear case +- net assessment +- next confirmation targets + +即: + +```text +AI_ICT 原始决策包 ++ Research Enhancement += Enriched Decision Packet +``` + +--- + +## 3. Review Packet 吸收研究结果 +现有 review packet 可以升级为: + +```text +Review Packet += Decision Packet ++ Visual Review ++ Operator Notes ++ Debate Summary ++ Reflection Memory +``` + +这样 review packet 就会成为真正的“复盘与解释中心”。 + +--- + +# 六、哪些接口应该怎么接 + +--- + +## A. research_context_builder +输入来源: + +- `market truth` +- `broker state` +- `candidate` +- `signal` +- `trust` +- `visual review` +- `notes` + +不要自己查太多新状态,优先消费 AI_ICT 已有结果。 + +### 原则 +**Research layer 不重新定义事实,只解释和推理事实。** + +这点很重要。 + +--- + +## B. research_orchestrator_service +职责: + +- 统一调用各 analyst +- 收集结果 +- 交给 debate service +- 输出统一 research payload + +不要在这里再塞业务规则判断。 +业务规则仍以 AI_ICT 的 decision packet 为准。 + +--- + +## C. debate_service +职责: + +- 从多 analyst 输出中整理支持/反对理由 +- 生成更适合人读的对立论证 + +它的角色是增强“解释力”,不是单独做最终裁决。 + +--- + +## D. research_memory_service +职责: + +- 存历史研究结论 +- 查相似历史 +- 返回最近经验摘要 + +它是“辅助上下文”,不是“覆盖当前事实”。 + +--- + +# 七、边界控制原则 + +这是最关键的。 + +## 1. AI_ICT 决定“事实” +AI_ICT 负责: + +- 市场真值 +- 账户真值 +- readiness +- trust +- 风险 +- 当前系统边界 + +--- + +## 2. TradingAgents 风格层决定“解释和论证” +Research layer 负责: + +- 多角度分析 +- 正反论据 +- 结论说明 +- 反思记忆 + +--- + +## 3. 不能让 Research Layer 反向重写底层事实 +例如: + +- 不允许研究层把 `market_stream_disconnected` 说成“可以忽略” +- 不允许研究层绕过 trust boundary +- 不允许研究层越过 readiness 规则 + +也就是说: + +> **Research layer 可以增强决策解释,但不能覆盖 AI_ICT 的实盘约束。** + +--- + +# 八、最小融合后的最终输出长什么样 + +## 决策包应变成: + +```python +{ + "action_recommendation": "...", + "status": "...", + "why_allowed": [...], + "why_blocked": [...], + + "research_summary": "...", + "structure_analysis": {...}, + "news_analysis": {...}, + "risk_analysis": {...}, + "visual_analysis": {...}, + + "bull_case": {...}, + "bear_case": {...}, + "net_assessment": "...", + "next_confirmation_targets": [...], + + "trust_classification": "...", + "operator_mode": "...", +} +``` + +--- + +## 复盘包应变成: + +```python +{ + "decision_packet": {...}, + "visual_reviews": [...], + "operator_notes": [...], + "debate_summary": {...}, + "reflection_memory": [...], + "recommended_next_actions": [...] +} +``` + +--- + +# 九、最稳的实施顺序 + +## Phase 1 +- `research_context_builder` +- `structure_analyst` +- `risk_analyst` +- `research_orchestrator_service` +- 接入 `decision_packet_service` + +## Phase 2 +- `news_analyst` +- `visual_analyst` +- `debate_service` +- 接入 review packet + +## Phase 3 +- `research_memory_service` +- 轻量 reflection store +- workbench 展示增强 + +--- + +# 十、最终架构判断 + +如果用一句话概括这个方案: + +> **AI_ICT 继续做“实盘辅助分析决策操作系统”,TradingAgents 只做“研究推理协处理器”。** + +这个定位最稳。 diff --git a/docs/AI_ICT_盯盘告警开发任务清单_v1.md b/docs/AI_ICT_盯盘告警开发任务清单_v1.md new file mode 100644 index 0000000..1a66a12 --- /dev/null +++ b/docs/AI_ICT_盯盘告警开发任务清单_v1.md @@ -0,0 +1,453 @@ +# AI ICT 盯盘告警开发任务清单 v1 + +## 1. 文档目标 + +本文档把 `docs/AI_ICT_盯盘告警技术设计稿_v1.md` 进一步拆成可执行开发任务清单,并按 `P0 / P1 / P2` 排优先级。 + +设计原则: + +- 先打通最短闭环,再叠加视觉增强 +- 优先做“真正能发消息”的能力 +- 不让视觉层抢主判断权 +- 自动化执行尽量持续推进,只有遇到重大 blocker 才停 + +--- + +## 2. 总体优先级 + +### P0:最小可用盯盘告警闭环 +目标: + +> candidate / decision alert 已经落库的前提下,让系统真正把告警发出去,并可观测、可回执、可重试。 + +### P1:视觉证据与工作台增强 +目标: + +> 给高优先级机会附加截图与视觉总结,并让 dashboard 能看到这些证据。 + +补充原则: + +- 原型验证阶段可先用现有人工页面或 browser 工具验证触发 ROI +- 正式产品实现不得依赖人工开着外部工具会话 +- 视觉层除了 chart,也应覆盖新闻日历页、公告异常页、workbench 页面 +- 所有视觉结论必须结构化回写,不能只留截图 +- 告警最终目标是让人 30 秒内完成判断 + +### P2:反馈回流与自动化运营增强 +目标: + +> 让系统具备反馈标记、统计、调优与长期自动化运转能力。 + +--- + +## 3. P0 任务清单 + +## P0-1 数据库与仓储补齐 + +### 目标 +补齐告警真正发送所需的数据存储。 + +### 任务 +- 新增 `alert_deliveries` 表 +- 为告警发送结果建立 repository +- 如需要,扩展 `decision_alerts` 查询字段 + +### 建议文件 +- `migrations/*_create_alert_deliveries.sql` +- `src/repositories/alert_delivery_repository.py` + +### 验收 +- 可写入一条 delivery 记录 +- 可按 `alert_id` 查询 delivery 历史 + +--- + +## P0-2 消息通道基础设施 + +### 目标 +先打通一个可靠通道,建议 Telegram 或 Webhook。 + +### 任务 +- 新增 `src/services/notifications/telegram_notifier.py` +- 新增 `src/services/notifications/webhook_notifier.py` +- 新增 `src/services/notifications/__init__.py` +- 在 settings 中增加 bot token / chat id / webhook url + +### 建议接口 +- `send_text(...)` +- `send_photo(...)` +- `send_json(...)` + +### 验收 +- 用测试脚本能成功发出一条测试消息 +- 失败时能返回明确错误而不是静默吞掉 + +--- + +## P0-3 告警模板与分发策略 + +### 目标 +把落库 alert 转成真正适合人查看的消息。 + +### 任务 +- 新增 `src/services/alerts/message_template_service.py` +- 新增 `src/services/alerts/channel_router_service.py` +- 新增 `src/services/alerts/delivery_policy_service.py` +- 定义三类模板: + - actionable + - blocked / watch + - runtime degraded + +### 建议输出 +- 标的 +- timeframe +- 当前 action recommendation +- why blocked / why allowed +- entry / stop / targets(若存在) + +### 验收 +- 能根据 alert type 正确输出不同文案 +- 冷却策略能避免重复刷屏 + +--- + +## P0-4 Alert Dispatcher 主闭环 + +### 目标 +打通:`decision_alerts -> dispatcher -> notifier -> deliveries`。 + +### 任务 +- 新增 `src/services/alerts/alert_dispatcher_service.py` +- 实现: + - `dispatch_pending(limit=50)` + - `dispatch_alert(alert_id)` + - 发送成功回写 `delivered` + - 发送失败写入 `failed` + - dashboard only 类型标记 `skipped` +- 新增 `src/services/alerts/__init__.py` + +### 依赖 +- P0-1 +- P0-2 +- P0-3 + +### 验收 +- queued alert 可以成功变成 delivered / failed / skipped +- 重复执行 dispatcher 不会重复发送同一条已成功告警 + +--- + +## P0-5 API 与脚本接入 + +### 目标 +让本地和 runtime 都能手动或自动触发 dispatcher。 + +### 任务 +- 新增 `src/api/alerts.py` +- 扩展 `src/api/server.py` +- 新增脚本 `scripts/run_alert_dispatcher.py` +- 增加接口: + - `POST /trading/alerts/dispatch` + - `GET /trading/alerts/{alert_id}/deliveries` + +### 验收 +- 可通过脚本单次发送 pending alerts +- 可通过 API 查看某条 alert 的 delivery 历史 + +--- + +## P0-6 runtime 接入自动分发 + +### 目标 +让运行时在不遇到重大 blocker 的情况下持续自动发送提醒。 + +### 任务 +- 扩展 `src/services/runtime/trading_runtime_service.py` +- 新增 runtime task:`alert_dispatch` +- 在 task details 中记录: + - `alerts_dispatched` + - `delivery_failures` + - `last_dispatch_status` + +### 验收 +- runtime run once 时能顺带执行一次 alert dispatch +- `/trading/runtime-status` 可看到 alert dispatch task 状态 + +--- + +## P0-7 P0 验证与最小上线标准 + +### 验证项 +- `decision packet` 能持续落库 alert +- dispatcher 能把 queued alerts 发到外部通道 +- delivery 结果可查 +- runtime 状态里能看到 dispatch task +- workbench 仍然正常工作 + +### 建议命令 +- `.venv/bin/python scripts/run_alert_dispatcher.py` +- `.venv/bin/python scripts/run_decision_packet.py` +- `.venv/bin/python scripts/run_trading_runtime.py` +- `.venv/bin/python scripts/run_offline_checks.py` + +### P0 完成定义 +当以下闭环成立时,P0 完成: + +```text +candidate -> decision alert -> dispatcher -> 外部消息 -> delivery record +``` + +--- + +## 4. P1 任务清单 + +## P1-1 截图存储模型 + +### 目标 +为视觉增强准备持久化结构。 + +### 任务 +- 新增 `visual_watch_runs` 表 +- 新增 `visual_snapshots` 表 +- 新增 `visual_reviews` 表 +- 新增 `src/repositories/visual_snapshot_repository.py` +- 将 `trigger_type`、`source_name`、`watch_run_id` 作为主关联字段贯穿视觉链路 +- 为后续统计预留 `candidate_actionable_watch / candidate_block_reason_review / market_anomaly_review / session_focus_watch` 等触发类型 + +### 验收 +- 可保存截图元数据 +- 可保存一次 visual review 结果 + +--- + +## P1-2 截图采集服务 + +### 目标 +对高价值时刻生成可回放截图。 + +### 任务 +- 新增 `src/services/visual_watch/snapshot_capture_service.py` +- 新增 `src/services/visual_watch/chart_source_adapter.py` +- 支持至少一个源: + - local workbench 或 TradingView + +### 验收 +- 对指定 symbol/timeframe 生成截图文件 +- 截图路径、时间、source 可持久化 + +--- + +## P1-3 视觉复核摘要 + +### 目标 +不是只留图,而是生成结构化视觉摘要。 + +### 任务 +- 新增 `src/services/visual_watch/vision_review_service.py` +- 新增 `src/services/visual_watch/visual_watch_service.py` +- 输出: + - `visual_state` + - `visual_confidence` + - `visual_notes` + - `ui_anomalies` + +### 验收 +- 对指定 candidate 生成 visual review 记录 +- review 与 candidate / packet 有关联关系 + +--- + +## P1-4 视觉触发策略 + +### 目标 +只在高价值时刻启用视觉层。 + +### 任务 +- 新增 `src/services/visual_watch/visual_trigger_policy_service.py` +- 触发条件首批支持: + - `candidate.status == executable` + - `action_recommendation in {build_execution_ticket, confirm_ticket}` + - runtime degraded + +### 验收 +- 普通 observe 状态不触发视觉层 +- 高优先级事件触发视觉层 + +--- + +## P1-5 告警与视觉结果融合 + +### 目标 +让消息里带视觉证据,而不是结构化信息孤立存在。 + +### 任务 +- 扩展 `message_template_service.py` +- 告警支持附带: + - screenshot path 或图片发送 + - visual summary + - visual caution / mismatch 提示 + +### 验收 +- actionable alert 可带图或带视觉摘要 +- visual mismatch 能提升为更高优先级警告 + +--- + +## P1-6 API 与 Workbench 增强 + +### 目标 +让前端能看到视觉证据与发送结果。 + +### 任务 +- 新增 `src/api/visual_watch.py` +- 扩展 `src/api/server.py` +- 扩展 `src/web/app.js` +- 扩展 `src/web/index.html` +- 新增页面区块: + - Recent Alerts + - Delivery Status + - Snapshot Gallery + - Visual Review + +### 验收 +- workbench 能显示最近 visual review +- 能查看 alert delivery 历史 + +--- + +## P1-7 runtime 接入视觉层 + +### 目标 +把视觉复核纳入运行时自动化,但只在策略命中时触发。 + +### 任务 +- runtime 新增 `visual_watch` task +- task details 记录: + - `visual_reviews_created` + - `last_visual_state` + - `last_snapshot_source` + +### 验收 +- runtime run once 时,在高优先级时刻自动产出 visual review + +--- + +## 5. P2 任务清单 + +## P2-1 人工反馈回流 + +### 目标 +让告警与视觉增强可被持续调优。 + +### 任务 +- 新增 `alert_feedback` 表 +- 新增 `src/repositories/alert_feedback_repository.py` +- 新增 `src/services/feedback/alert_feedback_service.py` +- 新增 `src/api/feedback.py` + +### 标签建议 +- `useful` +- `too_early` +- `too_late` +- `false_positive` +- `visual_helpful` +- `visual_misleading` +- `actionable` + +### 验收 +- 可为 alert 添加反馈 +- 可查询 alert 的反馈记录 +- 2026-05-11 P8 实现已扩展到 visual review feedback:可为视觉复核添加 `visual_helpful` / `visual_misleading` / `actionable` 标签。 +- 2026-05-11 P8 实现已让 alert dispatch 在现有 workbench/webhook payload 中附带最新 visual evidence;额外图片通道属于后续增强,不作为 P8 阻塞。 + +--- + +## P2-2 统计与复盘面板 + +### 目标 +让系统不只是会发,还能衡量质量。 + +### 任务 +- 统计 delivery success rate +- 统计 alert usefulness rate +- 统计 visual helpfulness rate +- 在 review packet 或 dashboard 增加简报视图 + +### 验收 +- 能看到最近一段时间的发送成功率与反馈标签分布 + +--- + +## P2-3 自动化运维增强 + +### 目标 +让这套能力适合长期 unattended 运行。 + +### 任务 +- 增强自动恢复与重试 +- 增加静默窗口 / 降噪策略 +- 增加 stuck task 检测 +- 增加 screenshot 存储清理策略 + +### 验收 +- 非重大错误不打断主循环 +- delivery 失败可重试 +- 存储不会无限膨胀 + +--- + +## P2-4 多通道与多场景扩展 + +### 目标 +从单通道提升为多场景编排。 + +### 任务 +- 增加 WeCom / Email +- 区分系统健康告警与交易机会告警 +- 区分值班模式 / 静默模式 + +### 验收 +- 可按 alert type 路由到不同通道 + +--- + +## 6. 推荐实施顺序 + +按最小闭环优先,推荐顺序: + +1. `P0-1` 数据库与仓储补齐 +2. `P0-2` 消息通道基础设施 +3. `P0-3` 告警模板与分发策略 +4. `P0-4` Alert Dispatcher 主闭环 +5. `P0-5` API 与脚本接入 +6. `P0-6` runtime 接入自动分发 +7. `P0-7` P0 验证 +8. `P1-1` ~ `P1-7` 视觉增强 +9. `P2-1` ~ `P2-4` 反馈与运营增强 + +--- + +## 7. 重大 blocker 定义 + +自动化执行脚本只有遇到以下情况才应暂停: + +1. 缺失外部凭据 +2. 需要人工授权的外部通道配置 +3. 数据库迁移失败且无法安全回滚 +4. 浏览器/截图依赖缺失且无法自动安装 +5. 会导致真实交易风险的边界不明确 + +除此之外,应持续自动推进。 + +--- + +## 8. 最小可用版本定义 + +最小可用版本只要求完成 P0: + +- alert 能真正发出 +- 有 delivery 记录 +- runtime 能持续分发 +- workbench 与 API 可观察 + +达到这一点后,这个系统就已经从“只会生成数据库内提醒”升级成“真正可用的盯盘主动通知系统”。 diff --git a/docs/AI_ICT_盯盘告警技术设计稿_v1.md b/docs/AI_ICT_盯盘告警技术设计稿_v1.md new file mode 100644 index 0000000..c022fb8 --- /dev/null +++ b/docs/AI_ICT_盯盘告警技术设计稿_v1.md @@ -0,0 +1,706 @@ +# AI ICT 盯盘告警技术设计稿 v1 + +## 1. 文档目标 + +本文档基于当前仓库目录,细化“AI 盯盘 + 主动告警”方案需要新增的模块、文件、接口、数据流和落地顺序。 + +目标不是推翻现有交易主链,而是在当前结构化交易运行时之上补齐: + +- 外发消息告警 +- 视觉复核 +- 截图与证据留存 +- 告警回执与人工反馈 + +## 2. 当前代码基线 + +已存在核心模块: + +- `src/services/runtime/trading_runtime_service.py` +- `src/services/market_state/market_state_service.py` +- `src/services/trade_plan_candidates/setup_watcher_service.py` +- `src/services/trade_plan_candidates/confirmation_service.py` +- `src/services/decision/decision_packet_service.py` +- `src/repositories/decision_packet_repository.py` +- `src/api/server.py` +- `src/web/app.js` + +当前系统已经具备: + +- runtime 调度 +- market truth gate +- active trading plan 生成 +- trade plan candidate 生成 +- decision packet 构建 +- decision alerts 落库 +- operator notes + +缺口主要在“告警真正发出去”和“高价值时刻的视觉复核”。 + +## 3. 设计目标 + +### 3.1 本轮新增目标 + +1. 把 queued alerts 真正发到外部消息通道 +2. 给高优先级机会附加截图和视觉摘要 +3. 让 dashboard 可查看截图、告警发送结果、视觉结论 +4. 支持人工对告警进行反馈标记 + +### 3.2 明确不做 + +1. 不让视觉层替代 candidate 生成 +2. 不让视觉层绕过 risk governor +3. 不让视觉层直接自动触发真实下单 +4. 不做全时全量高频截图 + +## 4. 建议新增目录与文件 + +以下按当前仓库结构给出建议。 + +--- + +## 4.1 services 层 + +### A. 新增 `src/services/alerts/` + +用途:负责消息编排、分发、模板、重试和发送状态回写。 + +建议文件: + +#### `src/services/alerts/__init__.py` +导出告警服务。 + +#### `src/services/alerts/alert_dispatcher_service.py` +核心职责: + +- 拉取 `decision_alerts` 中 `status=queued` 的记录 +- 根据 channel policy 发送 +- 回写 `delivered / failed / skipped` +- 支持冷却时间与重复抑制 + +建议类: + +- `AlertDispatcherService` +- `AlertDispatchResult` + +建议方法: + +- `dispatch_pending(limit: int = 50) -> dict` +- `dispatch_alert(alert_id: int) -> dict` +- `select_channel(alert: dict) -> str` +- `should_skip(alert: dict) -> tuple[bool, str]` + +#### `src/services/alerts/message_template_service.py` +职责:把 decision packet / visual summary 组装成用户可读消息。 + +建议方法: + +- `build_actionable_message(alert: dict, packet: dict, visual: dict | None) -> str` +- `build_blocked_message(...) -> str` +- `build_system_warning_message(...) -> str` + +#### `src/services/alerts/channel_router_service.py` +职责:根据 alert type / priority 决定发送到哪个通道。 + +例如: + +- `candidate_actionable` -> Telegram + 企业微信 +- `candidate_blocked` -> dashboard only +- `runtime_degraded` -> Telegram + dashboard + +#### `src/services/alerts/delivery_policy_service.py` +职责: + +- 冷却时间 +- 同一 alert_key 去重策略 +- 升级策略 +- 夜间静默策略(如需要) + +--- + +### B. 新增 `src/services/notifications/` + +用途:各具体消息通道适配器。 + +建议文件: + +#### `src/services/notifications/__init__.py` + +#### `src/services/notifications/telegram_notifier.py` +建议类:`TelegramNotifier` + +建议方法: + +- `send_text(chat_id: str, text: str) -> dict` +- `send_photo(chat_id: str, text: str, image_path: str) -> dict` + +#### `src/services/notifications/wecom_notifier.py` +建议类:`WecomNotifier` + +#### `src/services/notifications/webhook_notifier.py` +建议类:`WebhookNotifier` + +#### `src/services/notifications/email_notifier.py` +可选,优先级较低。 + +--- + +### C. 新增 `src/services/visual_watch/` + +用途:高价值时刻截图、视觉复核、结果回写。 + +补充吸收点: + +- 研发验证阶段允许先使用现有手工页面、browser/computer use、截图工具验证视觉触发 ROI +- 正式产品阶段必须切换到系统内建浏览器自动化与截图闭环,不能依赖人工开着工具会话 +- 视觉数据源不只包含 chart,也应包含新闻日历页、交易所公告/异常页、本地 workbench 页面 + +建议文件: + +#### `src/services/visual_watch/__init__.py` + +#### `src/services/visual_watch/visual_watch_service.py` +总控服务。 + +职责: + +- 接收 symbol / timeframe / candidate / packet +- 选择数据源 +- 触发截图 +- 调用视觉复核 +- 生成 visual summary +- 写入数据库 + +建议类: + +- `VisualWatchService` +- `VisualWatchRequest` +- `VisualWatchResult` + +建议方法: + +- `review_candidate(candidate_id: int) -> dict` +- `review_decision_packet(packet_id: int) -> dict` +- `review_runtime_incident(symbol: str, incident_code: str) -> dict` + +#### `src/services/visual_watch/chart_source_adapter.py` +职责:统一不同图表来源。 + +建议适配: + +- TradingView +- OKX Web +- 本地 workbench + +建议类: + +- `ChartSourceAdapter` +- `TradingViewChartAdapter` +- `OkxChartAdapter` +- `LocalWorkbenchAdapter` + +建议方法: + +- `build_chart_url(symbol: str, timeframe: str) -> str` +- `build_capture_spec(...) -> dict` + +#### `src/services/visual_watch/snapshot_capture_service.py` +职责:截图执行层。 + +生产建议基于 Playwright;研发期可保留兼容抽象。 + +建议方法: + +- `capture_page(url: str, viewport: dict, clip: dict | None = None) -> dict` +- `capture_chart(symbol: str, timeframe: str, source: str) -> dict` + +输出建议: + +- `image_path` +- `captured_ts` +- `source` +- `meta` + +#### `src/services/visual_watch/vision_review_service.py` +职责:把截图和结构化上下文融合为视觉摘要。 + +建议方法: + +- `review_snapshot(image_path: str, context: dict) -> dict` +- `summarize_candidate_visual_state(...) -> dict` + +输出字段建议: + +- `visual_state`: `confirm | caution | mismatch | unavailable` +- `visual_confidence` +- `visual_notes` +- `ui_anomalies` + +#### `src/services/visual_watch/visual_trigger_policy_service.py` +职责:控制何时启动视觉层。 + +建议规则: + +- `action_recommendation in {build_execution_ticket, confirm_ticket}` +- `candidate.status == executable` +- `runtime incident` 高优先级 +- session 切换 +- candidate 临近 `entry zone` +- candidate 从 blocked 进入接近 actionable +- news window 激活后需要页面级复核 +- reconciliation 发现异常后需要人工式视觉巡检 + +建议触发类型枚举: + +- `candidate_actionable_watch` +- `candidate_block_reason_review` +- `market_anomaly_review` +- `session_focus_watch` +- `news_window_review` +- `reconciliation_review` + +这些触发类型建议贯穿 visual watch run、snapshot、review、alert payload,便于后续统计 ROI。 + +--- + +### D. 新增 `src/services/feedback/` + +用途:人工对 alert / visual review 做反馈。 + +建议文件: + +#### `src/services/feedback/__init__.py` + +#### `src/services/feedback/alert_feedback_service.py` +建议方法: + +- `record_feedback(alert_id: int, label: str, note: str | None = None) -> dict` +- `list_feedback(alert_id: int) -> list[dict]` + +可选标签: + +- `useful` +- `too_early` +- `too_late` +- `false_positive` +- `visual_helpful` +- `visual_misleading` +- `actionable` + +--- + +## 4.2 repositories 层 + +### A. 新增 `src/repositories/alert_delivery_repository.py` + +职责: + +- 写入每次实际发送结果 +- 查询失败记录 +- 查询最近发送统计 + +建议方法: + +- `insert_delivery(...) -> int` +- `fetch_recent_deliveries(alert_id: int, limit: int = 20) -> list[dict]` +- `fetch_failed_deliveries(limit: int = 100) -> list[dict]` + +### B. 新增 `src/repositories/visual_snapshot_repository.py` + +职责: + +- 记录截图元数据 +- 记录 visual review 结果 +- 按 candidate / packet 查询最近截图 + +建议方法: + +- `insert_snapshot(...) -> int` +- `insert_visual_review(...) -> int` +- `fetch_latest_snapshot_for_candidate(candidate_id: int) -> dict | None` +- `fetch_reviews_for_packet(packet_id: int, limit: int = 10) -> list[dict]` + +### C. 新增 `src/repositories/alert_feedback_repository.py` + +职责: + +- 保存人工反馈标签和备注 + +--- + +## 4.3 api 层 + +在 `src/api/` 下建议新增或扩展以下文件。 + +### A. 新增 `src/api/alerts.py` + +职责:告警与发送结果查询。 + +建议函数: + +- `get_recent_alerts(instrument_id: int, limit: int = 20) -> list[dict]` +- `dispatch_pending_alerts(limit: int = 50) -> dict` +- `get_alert_deliveries(alert_id: int, limit: int = 20) -> list[dict]` + +### B. 新增 `src/api/visual_watch.py` + +职责:视觉触发和结果查询。 + +建议函数: + +- `run_visual_candidate_review(candidate_id: int) -> dict` +- `run_visual_packet_review(packet_id: int) -> dict` +- `get_visual_reviews(candidate_id: int | None = None, packet_id: int | None = None, limit: int = 20) -> list[dict]` + +### C. 新增 `src/api/feedback.py` + +职责:人工反馈接口。 + +建议函数: + +- `create_alert_feedback(alert_id: int, label: str, note: str | None = None) -> dict` +- `get_alert_feedback(alert_id: int) -> list[dict]` + +### D. 扩展 `src/api/trading_runtime.py` + +建议新增: + +- `run_trading_alert_dispatch() -> dict` +- `run_visual_watch_for_candidate(candidate_id: int) -> dict` +- `get_trading_visual_status(...) -> dict` + +### E. 扩展 `src/api/server.py` + +建议增加路由: + +#### 告警 +- `GET /trading/alerts?instrument_id=1&limit=20` +- `POST /trading/alerts/dispatch` +- `GET /trading/alerts/{alert_id}/deliveries` + +#### 视觉复核 +- `POST /trading/candidates/{candidate_id}/visual-review` +- `POST /trading/decision-packets/{packet_id}/visual-review` +- `GET /trading/visual-reviews?candidate_id=...&limit=20` + +#### 人工反馈 +- `POST /trading/alerts/{alert_id}/feedback` +- `GET /trading/alerts/{alert_id}/feedback` + +--- + +## 4.4 runtime 层 + +建议扩展: + +### 文件:`src/services/runtime/trading_runtime_service.py` + +现有 tasks: + +- market_state +- broker_sync +- reconciliation +- watch +- decision_packet +- plan + +建议新增两个 task: + +### A. `visual_watch` +职责: + +- 只在触发策略命中时执行 +- 对高价值事件生成截图和视觉摘要 + +### B. `alert_dispatch` +职责: + +- 定时扫描 queued alerts +- 发送并回写结果 + +### 需要新增状态字段 +在 `RuntimeTaskState.details` 中补充: + +- `alerts_dispatched` +- `visual_reviews_created` +- `failed_delivery_count` +- `last_visual_state` + +--- + +## 4.5 web 层 + +### A. 扩展 `src/web/app.js` + +建议新增 UI 模块: + +1. Recent Alerts 面板 +2. Alert Delivery 状态面板 +3. Visual Review 面板 +4. Snapshot Gallery +5. Alert Feedback 面板 + +### B. 扩展 `src/web/index.html` + +增加以下区域: + +- 告警列表 +- 截图证据区 +- 视觉总结区 +- 反馈标记按钮 + +--- + +## 4.6 scripts 层 + +建议新增脚本: + +### `scripts/run_alert_dispatcher.py` +用途:单独运行告警发送流程。 + +### `scripts/run_visual_review.py` +用途:对指定 candidate / packet 手动触发视觉复核。 + +### `scripts/run_monitoring_cycle.py` +用途:做一轮“运行时 + 视觉 + 告警”的调试型总入口。 + +--- + +## 4.7 migrations 层 + +建议新增迁移,至少补三张表。 + +### A. `visual_snapshots` +建议字段: + +- `id` +- `instrument_id` +- `candidate_id` +- `decision_packet_id` +- `source` +- `image_path` +- `captured_ts` +- `meta jsonb` + +### B. `visual_watch_runs` +建议字段: + +- `id` +- `instrument_id` +- `trigger_type` +- `source_name` +- `status` +- `started_ts` +- `completed_ts` +- `candidate_id` +- `decision_packet_id` +- `meta jsonb` + +### C. `visual_reviews` +建议字段: + +- `id` +- `watch_run_id` +- `snapshot_id` +- `instrument_id` +- `candidate_id` +- `decision_packet_id` +- `visual_state` +- `visual_confidence` +- `visual_bias` +- `visual_location` +- `sweep_visible` +- `mss_visible` +- `fvg_visible` +- `entry_zone_valid` +- `invalidation_intact` +- `summary` +- `notes jsonb` +- `anomalies jsonb` +- `payload jsonb` +- `created_ts` + +### D. `alert_deliveries` +建议字段: + +- `id` +- `alert_id` +- `channel` +- `status` +- `attempted_ts` +- `delivered_ts` +- `external_message_id` +- `error` +- `payload jsonb` + +### D. `alert_feedback` +建议字段: + +- `id` +- `alert_id` +- `label` +- `note` +- `created_by` +- `created_ts` + +--- + +## 5. 接口契约建议 + +## 5.1 visual review 输出契约 + +```json +{ + "id": 101, + "instrument_id": 1, + "candidate_id": 88, + "decision_packet_id": 55, + "snapshot": { + "image_path": "/data/visual/btc_1m_20260508_101500.png", + "source": "tradingview", + "captured_ts": "2026-05-08T10:15:00Z" + }, + "visual_state": "confirm", + "visual_confidence": 0.82, + "summary": "结构与候选一致,回踩较干净,未见明显失效。", + "notes": [ + "Sweep 后价格已回到区间内部", + "MSS 后 pullback 较浅", + "FVG 回踩仍在有效区" + ], + "anomalies": [], + "payload": {} +} +``` + +## 5.2 alert delivery 输出契约 + +```json +{ + "alert_id": 301, + "channel": "telegram", + "status": "delivered", + "attempted_ts": "2026-05-08T10:15:05Z", + "delivered_ts": "2026-05-08T10:15:06Z", + "external_message_id": "998877" +} +``` + +## 5.3 feedback 输出契约 + +```json +{ + "alert_id": 301, + "label": "useful", + "note": "提醒时机合适,截图也有帮助", + "created_by": "local_operator" +} +``` + +--- + +## 6. 触发策略建议 + +### 6.1 哪些事件触发视觉层 + +首批建议: + +1. `candidate.status == executable` +2. `action_recommendation in {build_execution_ticket, confirm_ticket}` +3. `market_state` 进入 degraded +4. `session` 切换到重点时段 + +### 6.2 哪些事件直接发消息 + +首批建议: + +1. actionable candidate +2. ticket ready +3. runtime degraded +4. visual mismatch(高风险提示) + +### 6.3 哪些只写 dashboard 不推送 + +1. blocked candidate +2. 低质量 observe 状态 +3. 重复告警冷却期间事件 + +--- + +## 7. 建议配置项 + +建议在 settings 中补充: + +- `ALERTS_ENABLED` +- `ALERT_DISPATCH_INTERVAL_SECONDS` +- `ALERT_DEFAULT_CHANNEL` +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHAT_ID` +- `WECOM_WEBHOOK_URL` +- `VISUAL_WATCH_ENABLED` +- `VISUAL_WATCH_SOURCE` +- `VISUAL_WATCH_VIEWPORT_WIDTH` +- `VISUAL_WATCH_VIEWPORT_HEIGHT` +- `VISUAL_WATCH_STORAGE_DIR` +- `VISUAL_REVIEW_ON_ACTIONABLE_ONLY` + +--- + +## 8. 分阶段落地计划 + +### Phase 1:先打通消息外发 + +目标: + +- 新增 alert dispatcher +- 接入 Telegram 或 Webhook +- 把现有 `decision_alerts` 真正发出去 + +### Phase 2:加入截图证据 + +目标: + +- 对 actionable alert 生成截图 +- 告警附上 image_path 和视觉摘要占位 + +### Phase 3:加入视觉复核 + +目标: + +- 生成 visual_state / confidence / notes +- dashboard 展示 visual review + +### Phase 4:加入反馈回流 + +目标: + +- alert feedback 标记 +- delivery 成功率统计 +- visual helpfulness 统计 + +--- + +## 9. 风险与边界 + +1. 视觉层不能成为唯一真相源 +2. 截图成本和延迟必须受 trigger policy 控制 +3. 消息发送失败不能阻塞 runtime 主循环 +4. 所有视觉结论都必须带 candidate / packet 关联关系 +5. 实盘执行边界继续保持,盯盘增强不等于放开自动交易 + +--- + +## 10. 最小可用版本定义 + +最小可用版本建议只做: + +- `AlertDispatcherService` +- `TelegramNotifier` +- `alert_deliveries` 表 +- `POST /trading/alerts/dispatch` +- runtime 新增 `alert_dispatch` task + +这样先让系统具备: + +> 候选机会出现 -> 决策包生成 -> 告警落库 -> 实际发消息 + +之后再叠加视觉层,是最稳的推进路线。 diff --git a/docs/AI_ICT_盯盘告警架构图_v1.md b/docs/AI_ICT_盯盘告警架构图_v1.md new file mode 100644 index 0000000..b552c23 --- /dev/null +++ b/docs/AI_ICT_盯盘告警架构图_v1.md @@ -0,0 +1,249 @@ +# AI ICT 盯盘告警架构图 v1 + +## 1. 文档目标 + +本文档保存基于当前代码库梳理出的“AI 盯盘 + 主动告警”总体架构图,作为后续实现技术设计、任务拆分和模块落地的基准版本。 + +结论: + +- 当前仓库已经具备结构化主引擎骨架 +- 后续重点不是重做策略核心,而是在现有 runtime / candidate / decision packet 之上增加视觉复核与外发告警 +- 最优路径是“结构化主判断 + 视觉增强 + 主动通知 + 人工反馈回流” + +## 2. 当前代码库中的现有基础 + +已存在的关键能力: + +- 运行时调度:`src/services/runtime/trading_runtime_service.py` +- 市场状态监控:`src/services/market_state/market_state_service.py` +- 候选机会生成:`src/services/trade_plan_candidates/setup_watcher_service.py` +- 候选确认链:`src/services/trade_plan_candidates/confirmation_service.py` +- 决策包生成:`src/services/decision/decision_packet_service.py` +- 告警落库:`src/repositories/decision_packet_repository.py` +- API 暴露:`src/api/server.py` +- 交易工作台页面:`src/web/` + +这些能力已经构成了盯盘系统的主骨架。 + +## 3. 分层架构 + +建议整体拆成六层: + +1. 市场数据与运行时层 +2. 结构化机会识别层 +3. 决策与风险闸门层 +4. 视觉观察层 +5. 告警编排与消息分发层 +6. 人工确认与反馈学习层 + +### 3.1 市场数据与运行时层 + +职责: + +- 维护调度循环 +- 获取最新市场状态 +- 检查 freshness / stream / gap / news / session +- 维护 degraded mode + +代码映射: + +- `src/services/runtime/trading_runtime_service.py` +- `src/services/market_state/market_state_service.py` + +### 3.2 结构化机会识别层 + +职责: + +- 绑定 active trading plan +- 识别 sweep / MSS / FVG / target plan +- 生成 trade plan candidate +- 明确缺失确认条件与阻断原因 + +代码映射: + +- `src/api/trading_runtime.py` +- `src/services/trade_plan_candidates/setup_watcher_service.py` +- `src/services/trade_plan_candidates/confirmation_service.py` + +### 3.3 决策与风险闸门层 + +职责: + +- 将 plan / candidate / signal / ticket / risk 汇总为统一 decision packet +- 生成 actionable / blocked / monitoring 等业务结论 +- 形成可落库、可审计的 alert candidate + +代码映射: + +- `src/services/decision/decision_packet_service.py` +- `src/services/execution/` +- `src/repositories/decision_packet_repository.py` + +### 3.4 视觉观察层 + +职责: + +- 打开图表或工作台页面 +- 截图采样 +- 对高价值时刻做视觉复核 +- 输出 visual_confirm / caution / mismatch / unavailable + +这层应当只做增强,不替代结构化主判断。 + +### 3.5 告警编排与消息分发层 + +职责: + +- 从 decision alerts 队列读取 queued 事件 +- 套用消息模板 +- 分发到 Telegram / 企业微信 / Webhook / 邮件等通道 +- 写回发送状态、失败原因、发送时间 + +### 3.6 人工确认与反馈学习层 + +职责: + +- 支持人工查看告警、截图、理由 +- 支持添加 operator note +- 将“有用 / 太早 / 太晚 / 误报”等反馈回流到后续优化 + +## 4. 关键原则 + +1. 结构化层是主引擎 +2. 视觉层只做高价值时刻增强 +3. 风控和市场真值层优先级高于视觉层 +4. 所有提醒必须结构化,可审计、可回放 +5. 系统优先支持“提醒人”,不是直接自动实盘下单 +6. 原型验证与正式产品要分开:研发期可以借助 computer use / 截图工具验证思路,但正式落地必须依赖系统内建的浏览器自动化、截图和告警闭环 +7. 告警必须带结论和理由,不能只发截图 + +## 5. 额外吸收的实施边界 + +结合 `AI_ICT_视觉盯盘与主动告警增强方案_v1.md`,补充以下边界: + +- 视觉层除了看图表,也适合看新闻日历页、公告页、工作台状态页 +- 视觉层适合处理“候选接近可执行”“系统异常巡检”“session 切换复核”三类高价值场景 +- 不做 24 小时全量高频截图;只在事件触发时启动 +- 视觉结论必须结构化回写,不能只留图片文件 +- 研发验证阶段可先用现有人工页面或 browser 工具验证 ROI,确认有价值后再产品化 + +## 5. 总体时序 + +标准流程: + +```text +TradingRuntimeScheduler.run_cycle() + ↓ +更新 market_state + ↓ +检查 freshness / stream / gap / news / session + ↓ +生成 / 更新 active trading plan + ↓ +SetupWatcherService.build_candidate() + ↓ +DecisionPacketService.build_persist_and_alert() + ↓ +alert 写入 decision_alerts + ↓ +Alert Dispatcher 读取 queued alerts + ↓ +发送消息给用户 +``` + +高价值机会的增强流程: + +```text +Decision packet 进入 actionable 状态 + ↓ +触发 Visual Watch Service + ↓ +打开 chart / workbench + ↓ +截图 + 视觉总结 + ↓ +把 visual summary 追加到 alert payload + ↓ +发送高优先级提醒 +``` + +## 6. 最终架构图(文字版) + +```text +[Market Data / OKX / Sessions / News / Stream] + ↓ + [MarketStateService] + freshness / stream / gap / news / session + ↓ + [TradingRuntimeScheduler] + plan / watch / decision / broker / reconcile + ↓ + [SetupWatcherService] + active plan + sweep + MSS + FVG + target plan + ↓ + [ConfirmationService] + osok/range + sweep + MSS + FVG retrace + ↓ + [DecisionPacketService] + status / action / risk / explanation / alerts + ↓ + [Decision Alert Queue DB] + ↓ + ┌───────────────┴───────────────┐ + ↓ ↓ +[Alert Dispatcher] [Visual Watch Service] +send TG/WeCom/Webhook screenshot / chart review + ↓ ↓ + └───────────────┬───────────────┘ + ↓ + [Final Human Alert] + actionable / blocked / caution / system degraded + ↓ + [Operator Notes / Review / Journal] + ↓ + [Feedback-driven tuning] +``` + +## 7. 架构落地建议 + +建议按以下顺序推进: + +### 阶段 1 + +先打通结构化盯盘与消息通知: + +- runtime 周期运行 +- candidate / decision packet 持续生成 +- alert dispatcher 真实发消息 + +### 阶段 2 + +在高优先级时刻追加截图证据: + +- candidate actionable +- ticket ready +- runtime degraded + +### 阶段 3 + +增加视觉复核: + +- visual confirm +- visual caution +- visual mismatch + +### 阶段 4 + +增加人工反馈回流: + +- operator notes +- alert usefulness 标签 +- 后续统计和调优 + +## 8. 文档用途 + +本文件用于: + +- 保存总体结构图 +- 给后续技术设计稿提供总纲 +- 为模块拆分、接口定义、数据库扩展提供边界约束 diff --git a/docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md b/docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md new file mode 100644 index 0000000..a50cc63 --- /dev/null +++ b/docs/AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md @@ -0,0 +1,184 @@ +# AI_ICT 自动化执行层 P7 监控成熟度与晋级路由 v1 + +## 1. 文档目标 + +本文件用于打破 `continue_periodic_analysis_decision_monitoring` 的健康巡检循环。 + +P7 的定位是: + +> 当分析/决策-only monitor 已经形成稳定窗口后,把“继续巡检”升级为“监控成熟度评估 + 下一 bounded automation slice 选择”。 + +P7 不是 `Research Council P7`。`Research Council P0-P6` 仍然冻结,P6 governance tick 仍然是研究委员会主线的终端治理入口。 + +--- + +## 2. 触发条件 + +只有同时满足以下条件,自动化才允许从 periodic monitor 进入 P7: + +1. latest analysis decision monitor `passed=true` +2. latest blockers 为空 +3. latest warnings 为空 +4. `analysis_decision_monitor_quality_gate.status=passed` +5. `stable_monitoring_window=true` +6. `consecutive_pass_count >= stable_sample_threshold` +7. `decision_action=restricted_decision_support` +8. `decision_operator_mode=observe_only` +9. `real_trade_sample_gate_status=deferred_analysis_decision_only` +10. `p6_governance_tick_status=no_op_healthy` + +若任一条件不满足: + +- monitor blocker 存在:进入 `repair_analysis_decision_monitor_blockers` +- quality gate 未过但没有 blocker:继续 `continue_periodic_analysis_decision_monitoring` +- 出现外部环境阻塞:记录阻塞并停止在最小可解释状态 + +--- + +## 3. P7 默认切片顺序 + +### P7-1 monitor maturity rollup + +目标:把稳定窗口从“若干条 JSON 记录”提升为可读、可消费、可审计的成熟度摘要。 + +建议输出: + +- `.claude/analysis_decision_monitor_maturity_rollup.json` +- `SESSION.md` / `TASKS.md` 中的 P7 当前状态 + +摘要字段至少包含: + +- local record count +- local consecutive pass count +- local quality gate status +- latest observed broker/runtime/market/decision state +- stable window conclusion +- current allowed lane +- prohibited lane +- next bounded automation slice + +--- + +### P7-2 local/remote drift check + +目标:对比本地与远端 monitor 成熟度,避免本地健康但远端状态漂移。 + +检查项: + +- 本地 quality gate +- 远端容器 quality gate +- 远端宿主机持久化 `.claude` quality gate +- local/remote record count 差异 +- latest observed 关键字段差异 + +可接受状态: + +- local 与 remote 均 `passed` +- remote record count 不要求等于 local,但必须达到稳定窗口阈值 +- key guards 一致未变 + +若漂移不可解释,进入: + +```text +repair_analysis_decision_monitor_remote_drift +``` + +--- + +### P7-3 alert and decision-support quality review + +目标:判断当前 restricted decision-support 输出是否已经值得长期运行,而不是只判断 API 是否健康。 + +检查项: + +- decision packet 是否持续存在 +- review packet 是否持续包含 trust score +- review packet 是否持续包含 research summary +- operator next actions 是否仍然处于 observe-only +- alert 是否没有制造误导性“可交易”语义 + +若输出质量不足,进入最小 bounded slice: + +```text +improve_analysis_decision_support_quality +``` + +--- + +### P7-4 bounded automation slice selector + +目标:P7 不直接扩大系统能力,而是从成熟度评估中选择唯一的下一动作。 + +允许的下一动作只有: + +| 条件 | next_slice | +|---|---| +| monitor 未稳定 | `continue_periodic_analysis_decision_monitoring` | +| monitor blocker | `repair_analysis_decision_monitor_blockers` | +| local/remote drift | `repair_analysis_decision_monitor_remote_drift` | +| 输出质量不足 | `improve_analysis_decision_support_quality` | +| 全部健康且无新问题 | `continue_periodic_analysis_decision_monitoring_with_maturity_rollup` | + +P7 不允许一次打开多个改动方向。 + +--- + +### P7-5 state writeback + +目标:让锚点文件反映“系统已从原地巡检升级到成熟度路由”。 + +必须回写: + +- `SESSION.md` +- `TASKS.md` +- `.claude/automation_status.json` +- `.claude/analysis_decision_monitor_maturity_rollup.json` + +--- + +## 4. 边界守卫 + +P7 不允许做以下事情: + +1. 不修改 `execution_allowed` +2. 不修改 `real_live_ready` +3. 不绕过 risk governor +4. 不升级或降级 trust bucket +5. 不绕过 operator confirmation protocol +6. 不把 OKX fills 为 0 当作当前阶段 blocker +7. 不创建 `Research Council P7` +8. 不把 stable monitor 解释成实盘交易就绪 + +--- + +## 5. 阶段出口 + +P7 完成闭环后,状态应写为: + +```text +state: automation_execution_layer_p7_monitor_maturity_rollup_closed +current_phase: Restricted Decision-Support Soft Rollout +next_slice: +``` + +如果 P7-4 选择的是: + +```text +continue_periodic_analysis_decision_monitoring_with_maturity_rollup +``` + +则后续 periodic monitor 不再被视为“原地循环”,而是带有成熟度摘要的长期运行监控。 + +--- + +## 6. 验收标准 + +P7 可视为完成时,必须同时满足: + +1. P7 文档存在 +2. `run_until_blocked.py` 能在 stable quality gate 后输出 P7 next slice +3. P7 不创建 Research Council 新阶段 +4. P7 不改变任何交易权限或实盘就绪门 +5. focused unit tests 通过 +6. script static checks 通过 +7. `SESSION.md` / `TASKS.md` 已回写到 P7 当前入口 diff --git a/docs/AI_ICT_自动化流程使用教程_v1.md b/docs/AI_ICT_自动化流程使用教程_v1.md new file mode 100644 index 0000000..981ee58 --- /dev/null +++ b/docs/AI_ICT_自动化流程使用教程_v1.md @@ -0,0 +1,518 @@ +# AI ICT 自动化流程使用教程 v1 + +## 0. Current Default After P0-P6 Freeze + +As of 2026-05-11, the default automation entrypoint for this project is: + +```text +scripts/run_until_blocked.py +``` + +When `SESSION.md` / `TASKS.md` contain `automation_flow_p0_p6_review_closed`, this script does not use the older monitoring rollout phase queue. It first runs the Research Council P6 governance tick and applies the frozen P0-P6 policy: + +- `no_op_healthy`: record health, stop Research Council phase expansion, and do not create P7. +- `cycle_required`: run an optimization cycle only when the automation policy explicitly authorizes it. +- `bounded_slice_required`: open only the smallest bounded implementation slice, then return to P6. + +After the analysis/decision-only monitor reaches a stable quality window, the default loop no longer stays on `continue_periodic_analysis_decision_monitoring`. It advances to: + +```text +automation_execution_layer_p7_monitor_maturity_rollup +``` + +This is automation execution-layer P7, not Research Council P7. It must keep the P0-P6 Research Council freeze, preserve observe-only semantics, and keep execution/live/risk/trust/operator confirmation gates unchanged. + +The older `scripts/run_monitoring_alert_rollout.py` repair bridge remains historical/supporting tooling. It is not the default entrypoint for the current automation execution-layer flow. + +The first execution-layer bootstrap slices are: + +```text +P0: P6 governance tick routing +P1: anchor-driven smallest-slice selector +P2: validation and quality-gate router +P3: deploy/smoke router +P4: state writeback renderer +P7: monitor maturity rollup and bounded next-slice routing +``` + +## 1. 文档目标 + +本文档用于说明当前项目内自动化编排工具的用途、使用方式、运行模式、repair bridge 协议、常见命令和实践建议,作为后续学习和日常使用手册。 + +当前这套工具仍然是 **`ai-exchange` 项目专用版本**。 + +不建议现在就抽成通用模板,因为它还需要继续通过实际项目推进来检验和修正。 + +--- + +## 2. 工具概览 + +当前主脚本: + +```text +scripts/run_monitoring_alert_rollout.py +``` + +相关配套文档: + +- `docs/AI_ICT_下一阶段开发任务清单_v1.md` +- `docs/AI_ICT_repair_bridge_protocol_v1.md` + +这个脚本的目标不是直接替你完成所有业务开发,而是把当前项目的阶段推进流程固化为: + +```text +阶段执行 +-> 验证 +-> 发现失败 +-> 生成修复上下文 +-> 桥接外部修复代理 +-> 智能复验 +-> 推进下一阶段 +``` + +--- + +## 3. 当前支持的阶段 + +当前脚本内置四个阶段: + +- `N1` Launch Readiness 收尾 +- `N2` 最小主动消息告警闭环 +- `N3` 告警质量与 runtime 硬化 +- `N4` 视觉盯盘增强预埋 + +这些阶段定义在脚本的 `PHASES` 中,包括: + +- `code` +- `title` +- `goal` +- `tasks` +- `files` +- `validation_commands` +- `deliverables` + +--- + +## 4. 运行模式一览 + +通过环境变量控制: + +```text +MONITORING_ALERT_ROLLOUT_MODE +``` + +当前支持: + +- `plan` +- `status` +- `start` +- `advance` +- `block` +- `complete` +- `run-until-blocked` +- `repair-loop` +- `repair-bridge` + +--- + +## 5. 最常用命令 + +## 5.1 查看计划 + +```bash +.venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 查看当前有哪些阶段 +- 查看自动化策略说明 + +--- + +## 5.2 初始化状态 + +```bash +MONITORING_ALERT_ROLLOUT_MODE=start .venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 初始化 `.claude/monitoring_alert_rollout_status.json` +- 标记当前 phase 为首个阶段 + +--- + +## 5.3 查看状态 + +```bash +MONITORING_ALERT_ROLLOUT_MODE=status .venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 查看当前 phase +- 已完成阶段 +- 上次运行结果 +- repair loop 状态 + +--- + +## 5.4 跑当前阶段直到卡住 + +```bash +MONITORING_ALERT_ROLLOUT_MODE=run-until-blocked .venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 执行当前阶段的验证命令 +- 自动判断: + - `passed` + - `needs_work` + - `blocked` +- 如果通过,会自动推进 phase + +这是日常最核心的命令。 + +--- + +## 5.5 生成修复循环上下文 + +当 `run-until-blocked` 返回 `needs_work` 时: + +```bash +MONITORING_ALERT_ROLLOUT_MODE=repair-loop .venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 从上次失败结果生成 repair loop payload +- 生成: + - `failure_class` + - `recommended_focus` + - `phase_patch_targets` + - `repair_prompt_context` + +--- + +## 5.6 桥接外部修复代理 + +```bash +MONITORING_ALERT_AUTO_REPAIR_ENABLED=1 \ +MONITORING_ALERT_REPAIR_BRIDGE_COMMAND="python your_repair_agent.py" \ +MONITORING_ALERT_ROLLOUT_MODE=repair-bridge \ +.venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 把 repair prompt/context 通过 JSON stdin 发给外部修复代理 +- 解析外部修复代理 stdout JSON +- 决定是否自动复验 + +--- + +## 5.7 启用桥接后自动复验 + +```bash +MONITORING_ALERT_AUTO_REPAIR_ENABLED=1 \ +MONITORING_ALERT_AUTO_RERUN_ENABLED=1 \ +MONITORING_ALERT_REPAIR_BRIDGE_COMMAND="python your_repair_agent.py" \ +MONITORING_ALERT_ROLLOUT_MODE=repair-bridge \ +.venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 调 repair agent +- 解析返回 JSON +- 智能选择复验命令 +- 自动回跑 `run-until-blocked` +- 自动判断是否 phase advance + +--- + +## 5.8 JSON 输出模式 + +```bash +MONITORING_ALERT_ROLLOUT_OUTPUT_JSON=1 \ +MONITORING_ALERT_ROLLOUT_MODE=status \ +.venv/bin/python scripts/run_monitoring_alert_rollout.py +``` + +作用: + +- 返回 machine-readable 输出 +- 适合给别的脚本、代理或 IDE 工具消费 + +--- + +## 6. 重要环境变量 + +## 6.1 基础模式 + +- `MONITORING_ALERT_ROLLOUT_MODE` +- `MONITORING_ALERT_ROLLOUT_OUTPUT_JSON=1` + +## 6.2 运行控制 + +- `MONITORING_ALERT_ROLLOUT_MAX_ROUNDS` +- `MONITORING_ALERT_COMMAND_TIMEOUT_SECONDS` + +## 6.3 repair bridge + +- `MONITORING_ALERT_AUTO_REPAIR_ENABLED=1` +- `MONITORING_ALERT_REPAIR_BRIDGE_COMMAND="..."` +- `MONITORING_ALERT_AUTO_RERUN_ENABLED=1` + +## 6.4 手动 blocker + +- `MONITORING_ALERT_BLOCKER` +- `MONITORING_ALERT_BLOCKER_NOTE` + +--- + +## 7. 状态文件与锚点文件 + +## 7.1 状态文件 + +```text +.claude/monitoring_alert_rollout_status.json +``` + +记录内容包括: + +- 当前 phase +- 已完成 phase +- last run +- repair loop 状态 +- patch rounds +- history + +--- + +## 7.2 锚点文件 + +脚本还会维护: + +- `SESSION.md` +- `TASKS.md` + +当前通过固定区块: + +```text +## Automation Status +``` + +做结构化更新,而不是简单重复追加。 + +--- + +## 8. repair loop 的意义 + +当阶段验证失败时,脚本会生成两层信息: + +### 8.1 next-step guidance +给人类直接看: + +- priority tasks +- suggested files +- suggested commands +- reasoning + +### 8.2 repair metadata +给后续自动修复代理使用: + +- `failure_class` +- `recommended_focus` +- `phase_patch_targets` +- `phase_validation_entrypoint` +- `repair_prompt_context` + +--- + +## 9. repair-bridge JSON 协议 + +详见: + +```text +docs/AI_ICT_repair_bridge_protocol_v1.md +``` + +### 9.1 stdin 输入 +脚本会给 repair agent 发一段 JSON,例如: + +```json +{ + "protocol_version": "repair-bridge/v1", + "mode": "repair_loop", + "phase": "N2", + "phase_title": "最小主动消息告警闭环", + "failure_class": "alert_dispatch_gap", + "recommended_focus": "alert_delivery_and_dispatch", + "phase_patch_targets": [ + "src/services/alerts/alert_dispatcher_service.py" + ], + "validation_entrypoint": ".venv/bin/python scripts/run_alert_dispatcher.py", + "repair_prompt_context": { + "prompt_text": "..." + } +} +``` + +### 9.2 stdout 输出 +repair agent 应返回 JSON,例如: + +```json +{ + "protocol_version": "repair-bridge/v1", + "status": "applied", + "summary": "Implemented missing alert dispatcher module and updated runtime hook.", + "changed_files": [ + "src/services/alerts/alert_dispatcher_service.py", + "src/services/runtime/trading_runtime_service.py" + ], + "notes": ["Focused on N2 alert delivery gap"], + "success_guess": true, + "tests_run": [ + ".venv/bin/python scripts/run_alert_dispatcher.py" + ] +} +``` + +--- + +## 10. 当前自动复验规则 + +repair agent 返回值会参与决策。 + +### 自动复验前提 +必须满足: + +1. `MONITORING_ALERT_AUTO_RERUN_ENABLED=1` +2. stdout JSON 可解析 +3. schema 合法 +4. `status in {applied, success, fixed}` +5. `success_guess != false` +6. `changed_files` 非空 + +--- + +## 11. 智能复验调度 + +脚本当前优先级: + +### 优先级 1:有 `tests_run` +如果 repair agent 已经给出 `tests_run`,优先执行这些命令。 + +### 优先级 2:没有 `tests_run` +根据 `changed_files` 自动选 focused rerun: + +- 改动 `src/services/alerts/` 或 `src/api/alerts.py` + - 运行 `scripts/run_alert_dispatcher.py` +- 改动 `src/services/runtime/` 或 `src/api/trading_runtime.py` + - 运行 `scripts/run_trading_runtime.py` +- 改动 `src/services/readiness/` / `src/web/` / `src/config/settings.py` + - 运行 `scripts/run_launch_readiness.py` +- 改动 `src/services/decision/` / `src/services/operator/` + - 运行 `scripts/run_review_packet.py` +- 如果改动全在 `docs/` + - 跳过 focused rerun + +然后再回到 `run-until-blocked` 做阶段判断。 + +--- + +## 12. patch round 历史 + +每次 repair bridge 现在都会记录 patch round,内容包括: + +- 第几轮 repair +- 本轮 `changed_files` +- 本轮 `tests_run` +- 本轮 `success_guess` +- 本轮 `rerun_state` +- 是否 `phase_advanced` + +这些历史会写在状态文件的 `repair_loop.patch_rounds` 中。 + +--- + +## 13. 当前工具的实际定位 + +这套工具现在还不是完全通用的跨项目平台,它是: + +> **为 `ai-exchange` 项目定制演化出来的自动化 orchestrator** + +特点: + +- 阶段定义写在脚本里 +- 验证命令写在脚本里 +- rerun 规则写在脚本里 +- 锚点文件是当前项目结构 + +所以目前最适合: + +- 继续在本项目里使用 +- 一边推进,一边发现 bug 再修 +- 等经过更多实践验证后,再考虑抽通用模板 + +--- + +## 14. 实践建议 + +### 14.1 推荐日常顺序 + +```text +1. run-until-blocked +2. 如果 needs_work -> repair-loop +3. 如果有 repair agent -> repair-bridge +4. 自动复验 +5. 再看 status +``` + +### 14.2 推荐先人工观察几轮 +虽然现在已经有自动修复桥接、自动复验,但仍建议: + +- 先人工观察几轮 +- 看 repair agent 返回 JSON 是否稳定 +- 看 focused rerun 选择是否合理 +- 看 phase advance 是否符合预期 + +### 14.3 外部 repair agent 最好把调试日志写到 stderr +这样 stdout 可以保持纯 JSON,更容易被脚本可靠解析。 + +--- + +## 15. 后续维护建议 + +如果后面发现 bug,优先检查: + +1. `repair-bridge` stdout 是否是合法 JSON +2. `changed_files` 是否真实反映改动 +3. `tests_run` 是否写成了脚本真实执行的命令 +4. 自动 focused rerun 是否选对了命令 +5. `SESSION.md / TASKS.md` 的 anchor block 是否被错误修改 + +--- + +## 16. 总结 + +这套工具当前已经具备: + +- 阶段推进 +- 自动验证 +- run-until-blocked +- repair loop +- repair bridge +- JSON 协议桥接 +- 智能复验 +- patch round 历史 +- 状态锚点写回 + +它已经足够作为: + +> **当前项目内自动化流程的学习与实操工具** + +后续先继续在本项目里实践,遇到 bug 再逐步修正,比现在就做通用模板更稳。 diff --git a/migrations/034_create_alert_deliveries.sql b/migrations/034_create_alert_deliveries.sql new file mode 100644 index 0000000..a7ce4ef --- /dev/null +++ b/migrations/034_create_alert_deliveries.sql @@ -0,0 +1,14 @@ +create table if not exists alert_deliveries ( + id bigserial primary key, + alert_id bigint not null references decision_alerts(id) on delete cascade, + channel text not null, + status text not null, + attempted_ts timestamptz not null, + delivered_ts timestamptz, + external_message_id text, + error text, + payload jsonb not null default '{}'::jsonb +); + +create index if not exists idx_alert_deliveries_alert_attempted_desc +on alert_deliveries (alert_id, attempted_ts desc, id desc); diff --git a/migrations/035_create_visual_watch_tables.sql b/migrations/035_create_visual_watch_tables.sql new file mode 100644 index 0000000..b942196 --- /dev/null +++ b/migrations/035_create_visual_watch_tables.sql @@ -0,0 +1,46 @@ +create table if not exists visual_watch_runs ( + id bigserial primary key, + trigger_type text not null, + source_name text not null, + instrument_id bigint not null references instruments(id) on delete cascade, + candidate_id bigint references trade_plan_candidates(id) on delete set null, + signal_id bigint references trade_signals(id) on delete set null, + decision_packet_id bigint references decision_packets(id) on delete set null, + symbol text not null, + timeframe text not null, + status text not null, + created_ts timestamptz not null default now(), + completed_ts timestamptz, + payload jsonb not null default '{}'::jsonb +); + +create index if not exists idx_visual_watch_runs_lookup +on visual_watch_runs (instrument_id, trigger_type, created_ts desc, id desc); + +create table if not exists visual_snapshots ( + id bigserial primary key, + watch_run_id bigint not null references visual_watch_runs(id) on delete cascade, + source_name text not null, + image_path text not null, + captured_ts timestamptz not null, + status text not null, + payload jsonb not null default '{}'::jsonb +); + +create index if not exists idx_visual_snapshots_watch_captured_desc +on visual_snapshots (watch_run_id, captured_ts desc, id desc); + +create table if not exists visual_reviews ( + id bigserial primary key, + watch_run_id bigint not null references visual_watch_runs(id) on delete cascade, + snapshot_id bigint references visual_snapshots(id) on delete set null, + visual_state text not null, + visual_confidence numeric(6, 4), + visual_notes text not null default '', + ui_anomalies jsonb not null default '[]'::jsonb, + reviewed_ts timestamptz not null, + payload jsonb not null default '{}'::jsonb +); + +create index if not exists idx_visual_reviews_watch_reviewed_desc +on visual_reviews (watch_run_id, reviewed_ts desc, id desc); diff --git a/migrations/036_create_research_council_runs.sql b/migrations/036_create_research_council_runs.sql new file mode 100644 index 0000000..53d8ba0 --- /dev/null +++ b/migrations/036_create_research_council_runs.sql @@ -0,0 +1,20 @@ +create table if not exists research_council_runs ( + id bigserial primary key, + instrument_id bigint not null, + trading_plan_id bigint null, + candidate_id bigint null, + signal_id bigint null, + decision_packet_id bigint null, + status text not null, + timeframe text not null default '1m', + model_code text not null, + committee_confidence double precision not null default 0, + summary_text text not null default '', + payload jsonb not null default '{}'::jsonb, + generated_ts timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_research_council_runs_instrument_generated + on research_council_runs (instrument_id, generated_ts desc, id desc); diff --git a/migrations/037_create_research_council_messages.sql b/migrations/037_create_research_council_messages.sql new file mode 100644 index 0000000..52cd032 --- /dev/null +++ b/migrations/037_create_research_council_messages.sql @@ -0,0 +1,12 @@ +create table if not exists research_council_messages ( + id bigserial primary key, + run_id bigint not null, + agent_role text not null, + message_type text not null, + payload jsonb not null default '{}'::jsonb, + created_ts timestamptz not null, + created_at timestamptz not null default now() +); + +create index if not exists idx_research_council_messages_run + on research_council_messages (run_id, created_ts asc, id asc); diff --git a/migrations/038_create_research_council_links.sql b/migrations/038_create_research_council_links.sql new file mode 100644 index 0000000..aa96202 --- /dev/null +++ b/migrations/038_create_research_council_links.sql @@ -0,0 +1,13 @@ +create table if not exists research_council_links ( + id bigserial primary key, + run_id bigint not null, + trading_plan_id bigint null, + candidate_id bigint null, + signal_id bigint null, + decision_packet_id bigint null, + execution_ticket_id bigint null, + created_ts timestamptz not null +); + +create index if not exists idx_research_council_links_run + on research_council_links (run_id, created_ts desc, id desc); diff --git a/migrations/039_create_research_council_checkpoints.sql b/migrations/039_create_research_council_checkpoints.sql new file mode 100644 index 0000000..527d68a --- /dev/null +++ b/migrations/039_create_research_council_checkpoints.sql @@ -0,0 +1,16 @@ +create table if not exists research_council_checkpoints ( + id bigserial primary key, + run_id bigint null, + instrument_id bigint not null, + checkpoint_key text not null, + status text not null, + stage text not null, + payload jsonb not null default '{}'::jsonb, + created_ts timestamptz not null, + updated_ts timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_research_council_checkpoints_latest + on research_council_checkpoints (instrument_id, checkpoint_key, updated_ts desc, id desc); diff --git a/migrations/040_create_research_council_lessons.sql b/migrations/040_create_research_council_lessons.sql new file mode 100644 index 0000000..413b983 --- /dev/null +++ b/migrations/040_create_research_council_lessons.sql @@ -0,0 +1,24 @@ +create table if not exists research_council_lessons ( + id bigserial primary key, + lesson_key text not null unique, + instrument_id bigint not null, + source_type text not null, + source_ref text not null, + lesson_type text not null, + symbol text null, + session_code text null, + setup_type text null, + model_code text null, + title text not null, + lesson_summary text not null, + tags jsonb not null default '[]'::jsonb, + priority_score double precision not null default 0, + payload jsonb not null default '{}'::jsonb, + created_ts timestamptz not null, + updated_ts timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_research_council_lessons_scope + on research_council_lessons (instrument_id, session_code, setup_type, model_code, updated_ts desc, id desc); diff --git a/migrations/041_create_research_council_optimization_cycles.sql b/migrations/041_create_research_council_optimization_cycles.sql new file mode 100644 index 0000000..1e9849b --- /dev/null +++ b/migrations/041_create_research_council_optimization_cycles.sql @@ -0,0 +1,22 @@ +create table if not exists research_council_optimization_cycles ( + id bigserial primary key, + cycle_key text not null unique, + instrument_id bigint not null, + status text not null, + timeframe text not null default '1m', + model_code text not null, + replay_file text null, + lesson_summary jsonb not null default '{}'::jsonb, + replay_summary jsonb not null default '{}'::jsonb, + eval_summary jsonb not null default '{}'::jsonb, + arena_summary jsonb not null default '{}'::jsonb, + next_actions jsonb not null default '[]'::jsonb, + payload jsonb not null default '{}'::jsonb, + started_ts timestamptz not null, + finished_ts timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_research_council_optimization_cycles_latest + on research_council_optimization_cycles (instrument_id, finished_ts desc, id desc); diff --git a/migrations/042_create_alert_feedback.sql b/migrations/042_create_alert_feedback.sql new file mode 100644 index 0000000..7a06d0a --- /dev/null +++ b/migrations/042_create_alert_feedback.sql @@ -0,0 +1,32 @@ +create table if not exists alert_feedback ( + id bigserial primary key, + subject_type text not null, + subject_id bigint not null, + alert_id bigint references decision_alerts(id) on delete cascade, + visual_review_id bigint references visual_reviews(id) on delete cascade, + label text not null, + note text not null default '', + created_by text not null default 'local_operator', + created_ts timestamptz not null default now(), + payload jsonb not null default '{}'::jsonb, + constraint chk_alert_feedback_subject check ( + ( + subject_type = 'alert' + and alert_id is not null + and visual_review_id is null + and subject_id = alert_id + ) + or ( + subject_type = 'visual_review' + and visual_review_id is not null + and alert_id is null + and subject_id = visual_review_id + ) + ) +); + +create index if not exists idx_alert_feedback_alert_created_desc +on alert_feedback (alert_id, created_ts desc, id desc); + +create index if not exists idx_alert_feedback_visual_review_created_desc +on alert_feedback (visual_review_id, created_ts desc, id desc); diff --git a/scripts/deploy_with_password.py b/scripts/deploy_with_password.py new file mode 100644 index 0000000..96a1a67 --- /dev/null +++ b/scripts/deploy_with_password.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional +import argparse +import os +import tarfile +import time +import urllib.request + +try: + import paramiko +except ImportError as exc: # pragma: no cover - local tooling guard + raise SystemExit("paramiko_missing: install paramiko in the local venv before deploying") from exc + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_HOST = "192.168.3.90" +DEFAULT_USER = "root" +DEFAULT_REMOTE_PATH = "/root/deploy/ai-ict" +DEFAULT_REMOTE_ARCHIVE = "/root/ai-ict-deploy.tgz" +DEFAULT_SMOKE_BASE_URL = "http://192.168.3.90:8000" +DEPLOY_PATHS = [ + "Dockerfile", + ".dockerignore", + "docker-compose.yml", + "pyproject.toml", + "README.md", + ".env.example", + "docs", + "SESSION.md", + "TASKS.md", + "src", + "scripts", + "migrations", + "data", +] + + +def main(argv: Optional[list[str]] = None, env=None) -> int: + env = os.environ if env is None else env + args = _parse_args(argv, env) + if not args.password: + raise SystemExit("missing_password: set AI_ICT_DEPLOY_PASSWORD or pass --password") + + with TemporaryDirectory() as tmp_dir: + archive_path = Path(tmp_dir) / "ai-ict-deploy.tgz" + create_archive(archive_path) + client = connect_ssh(host=args.host, user=args.user, password=args.password, port=args.port) + try: + run_remote(client, f"mkdir -p {args.remote_path}") + upload_file(client, archive_path, args.remote_archive) + run_remote( + client, + build_deploy_command( + remote_path=args.remote_path, + remote_archive=args.remote_archive, + ), + timeout=600, + ) + finally: + client.close() + + smoke = run_http_smoke(args.smoke_base_url, attempts=args.smoke_attempts, sleep_seconds=args.smoke_sleep_seconds) + print(f"deploy_complete host={args.host} remote_path={args.remote_path}") + print(f"smoke_complete endpoints={len(smoke)}") + return 0 + + +def create_archive(archive_path: Path) -> None: + with tarfile.open(archive_path, "w:gz") as archive: + for relative in DEPLOY_PATHS: + path = ROOT / relative + if not path.exists(): + continue + archive.add(path, arcname=relative, filter=_tar_filter) + + +def connect_ssh(*, host: str, user: str, password: str, port: int): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + hostname=host, + port=port, + username=user, + password=password, + look_for_keys=False, + allow_agent=False, + timeout=20, + banner_timeout=20, + auth_timeout=20, + ) + return client + + +def upload_file(client, local_path: Path, remote_path: str) -> None: + with client.open_sftp() as sftp: + sftp.put(str(local_path), remote_path) + + +def run_remote(client, command: str, *, timeout: int = 120) -> str: + stdin, stdout, stderr = client.exec_command(command, timeout=timeout) + exit_status = stdout.channel.recv_exit_status() + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + if exit_status != 0: + raise RuntimeError(f"remote_command_failed:{exit_status}\n{command}\nSTDOUT:\n{out}\nSTDERR:\n{err}") + return out + + +def build_deploy_command(*, remote_path: str, remote_archive: str) -> str: + remote_path_q = _sh_quote(remote_path) + remote_archive_q = _sh_quote(remote_archive) + env_backup = "/root/ai-ict-deploy.env.bak" + return " && ".join( + [ + f"mkdir -p {remote_path_q}", + f"if [ -f {remote_path_q}/.env ]; then cp {remote_path_q}/.env {_sh_quote(env_backup)}; fi", + f"rm -rf {remote_path_q}/*", + f"tar -xzf {remote_archive_q} -C {remote_path_q}", + f"if [ -f {_sh_quote(env_backup)} ]; then mv {_sh_quote(env_backup)} {remote_path_q}/.env; fi", + f"cd {remote_path_q}", + "((docker compose down || docker-compose down) >/dev/null 2>&1 || true)", + "((docker compose up -d --build) || (docker-compose up -d --build))", + "((docker compose ps) || (docker-compose ps))", + ] + ) + + +def run_http_smoke(base_url: str, *, attempts: int = 10, sleep_seconds: float = 3.0) -> list[dict]: + endpoints = [ + "/health", + "/trading/runtime-status", + "/trading/research-council/optimization-cycle/latest?instrument_id=1", + "/trading/research-council/optimization-cycles?instrument_id=1&limit=1", + "/trading/research-council/optimization-review?instrument_id=1", + "/trading/review-packet?instrument_id=1", + ] + results = [] + for endpoint in endpoints: + url = f"{base_url.rstrip('/')}{endpoint}" + last_error = None + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=20) as response: + body = response.read(4096) + results.append({"url": url, "status": response.status, "bytes": len(body), "attempt": attempt}) + break + except Exception as exc: # pragma: no cover - exercised by live deploys + last_error = exc + if attempt < attempts: + time.sleep(sleep_seconds) + else: + raise RuntimeError(f"http_smoke_failed:{url}:{last_error}") + return results + + +def _tar_filter(info: tarfile.TarInfo): + normalized = info.name.replace("\\", "/") + ignored_parts = {".git", ".venv", "__pycache__", ".pytest_cache", "node_modules"} + if any(part in ignored_parts for part in normalized.split("/")): + return None + return info + + +def _parse_args(argv: Optional[list[str]], env) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Deploy ai-exchange over password SSH without expect.") + parser.add_argument("--host", default=env.get("AI_ICT_DEPLOY_HOST", DEFAULT_HOST)) + parser.add_argument("--port", type=int, default=int(env.get("AI_ICT_DEPLOY_PORT", "22"))) + parser.add_argument("--user", default=env.get("AI_ICT_DEPLOY_USER", DEFAULT_USER)) + parser.add_argument("--password", default=env.get("AI_ICT_DEPLOY_PASSWORD", "")) + parser.add_argument("--remote-path", default=env.get("AI_ICT_DEPLOY_REMOTE_PATH", DEFAULT_REMOTE_PATH)) + parser.add_argument("--remote-archive", default=env.get("AI_ICT_DEPLOY_REMOTE_ARCHIVE", DEFAULT_REMOTE_ARCHIVE)) + parser.add_argument("--smoke-base-url", default=env.get("AI_ICT_DEPLOY_SMOKE_BASE_URL", DEFAULT_SMOKE_BASE_URL)) + parser.add_argument("--smoke-attempts", type=int, default=int(env.get("AI_ICT_DEPLOY_SMOKE_ATTEMPTS", "10"))) + parser.add_argument("--smoke-sleep-seconds", type=float, default=float(env.get("AI_ICT_DEPLOY_SMOKE_SLEEP_SECONDS", "3"))) + return parser.parse_args(argv) + + +def _sh_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fix_pg_hba.exp b/scripts/fix_pg_hba.exp index 8b217ef..e6c6b53 100755 --- a/scripts/fix_pg_hba.exp +++ b/scripts/fix_pg_hba.exp @@ -5,6 +5,7 @@ from pathlib import Path hba = Path('/var/lib/pgsql/16/data/pg_hba.conf') lines = [ 'host ai_ict yuqei 172.18.0.0/16 scram-sha-256\n', + 'host ai_ict yuqei 172.19.0.0/16 scram-sha-256\n', 'host ai_ict yuqei 172.20.0.0/16 scram-sha-256\n', 'host ai_ict yuqei 172.21.0.0/16 scram-sha-256\n', 'host ai_ict yuqei 172.22.0.0/16 scram-sha-256\n', diff --git a/scripts/run_alert_dispatcher.py b/scripts/run_alert_dispatcher.py new file mode 100644 index 0000000..e6f1920 --- /dev/null +++ b/scripts/run_alert_dispatcher.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.config.settings import settings +from src.services.alerts import AlertDispatcherService + + +def main(env=None) -> int: + env = os.environ if env is None else env + limit = int(env.get("ALERT_DISPATCH_LIMIT", str(settings.alert_dispatch_max_per_run))) + output_json = _get_bool(env, "ALERT_DISPATCH_OUTPUT_JSON", False) + payload = AlertDispatcherService().dispatch_pending(limit=limit) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Alert dispatch attempted: {payload.get('attempted', 0)}") + items = payload.get("items") or [] + delivered = sum(1 for item in items if item.get("status") == "delivered") + failed = sum(1 for item in items if item.get("status") == "failed") + skipped = sum(1 for item in items if item.get("status") == "skipped") + print(f"Delivered: {delivered} / Failed: {failed} / Skipped: {skipped}") + items = payload.get("items") or [] + return 0 if all(item.get("status") != "failed" for item in items) else 2 + + +def _get_bool(env, key: str, default: bool = False) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_analysis_decision_monitor.py b/scripts/run_analysis_decision_monitor.py new file mode 100644 index 0000000..756cd7d --- /dev/null +++ b/scripts/run_analysis_decision_monitor.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +DEFAULT_BASE_URL = "http://192.168.3.90:8000" +DEFAULT_TIMEOUT_SECONDS = 30 + + +def main(env=None) -> int: + env = os.environ if env is None else env + base_url = str(env.get("ANALYSIS_DECISION_MONITOR_BASE_URL") or DEFAULT_BASE_URL) + instrument_id = int(env.get("ANALYSIS_DECISION_MONITOR_INSTRUMENT_ID", "1")) + timeframe = str(env.get("ANALYSIS_DECISION_MONITOR_TIMEFRAME") or "1m") + timeout_seconds = float(env.get("ANALYSIS_DECISION_MONITOR_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT_SECONDS))) + run_p6_tick = _get_bool(env, "ANALYSIS_DECISION_MONITOR_RUN_P6_TICK", True) + output_json = _get_bool(env, "ANALYSIS_DECISION_MONITOR_OUTPUT_JSON", False) + + try: + payload = run_monitor( + base_url=base_url, + instrument_id=instrument_id, + timeframe=timeframe, + timeout_seconds=timeout_seconds, + run_p6_tick=run_p6_tick, + ) + except Exception as exc: + payload = { + "passed": False, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "base_url": base_url.rstrip("/"), + "blockers": ["analysis_decision_monitor_exception"], + "warnings": [], + "error_type": exc.__class__.__name__, + "error": str(exc), + } + + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Analysis/decision monitor passed: {payload.get('passed')}") + print(f"Blockers: {', '.join(payload.get('blockers') or []) or 'none'}") + print(f"Warnings: {', '.join(payload.get('warnings') or []) or 'none'}") + return 0 if payload.get("passed") else 2 + + +def run_monitor( + *, + base_url: str, + instrument_id: int, + timeframe: str, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + run_p6_tick: bool = True, +) -> dict: + base = base_url.rstrip("/") + payloads = { + "health": _http_get_json(f"{base}/health", timeout_seconds=timeout_seconds), + "launch_readiness": _http_get_json( + f"{base}/trading/launch-readiness?{urllib.parse.urlencode({'instrument_id': instrument_id, 'timeframe': timeframe})}", + timeout_seconds=timeout_seconds, + ), + "broker_state": _http_get_json( + f"{base}/trading/broker-state?{urllib.parse.urlencode({'instrument_id': instrument_id})}", + timeout_seconds=timeout_seconds, + ), + "runtime_status": _http_get_json(f"{base}/trading/runtime-status", timeout_seconds=timeout_seconds), + "decision_packet": _http_get_json( + f"{base}/trading/decision-packet?{urllib.parse.urlencode({'instrument_id': instrument_id, 'timeframe': timeframe})}", + timeout_seconds=timeout_seconds, + ), + "review_packet": _http_get_json( + f"{base}/trading/review-packet?{urllib.parse.urlencode({'instrument_id': instrument_id})}", + timeout_seconds=timeout_seconds, + ), + } + if run_p6_tick: + payloads["p6_governance_tick"] = _http_post_json( + f"{base}/trading/research-council/governance-tick", + { + "instrument_id": instrument_id, + "limit": 5, + "stale_after_hours": 24, + "auto_run_stale_cycle": False, + "force_cycle": False, + "timeframe": timeframe, + }, + timeout_seconds=timeout_seconds, + ) + return evaluate_analysis_decision_monitor_payloads( + payloads=payloads, + base_url=base, + instrument_id=instrument_id, + timeframe=timeframe, + ) + + +def evaluate_analysis_decision_monitor_payloads( + *, + payloads: dict, + base_url: str = DEFAULT_BASE_URL, + instrument_id: int = 1, + timeframe: str = "1m", +) -> dict: + checks: list[dict] = [] + + health = dict(payloads.get("health") or {}) + launch = dict(payloads.get("launch_readiness") or {}) + broker = dict(payloads.get("broker_state") or {}) + runtime = dict(payloads.get("runtime_status") or {}) + decision = dict(payloads.get("decision_packet") or {}) + review = dict(payloads.get("review_packet") or {}) + p6_tick = payloads.get("p6_governance_tick") + p6_tick = dict(p6_tick or {}) if p6_tick is not None else None + + launch_warnings = list(launch.get("warning_reasons") or []) + launch_next_actions = list(launch.get("next_actions") or []) + market_state = _first_market_state(runtime) or dict(launch.get("market_state") or {}) + stream_code = _path(market_state, "stream_state", "code") + freshness_code = _path(market_state, "freshness", "code") + gap_code = _path(market_state, "data_gap_state", "code") + + _add_check(checks, "health_ok", health.get("app") == "ai-ict", detail=f"app={health.get('app')}") + _add_check( + checks, + "launch_no_blockers", + not list(launch.get("blocking_reasons") or []), + detail=f"blocking_reasons={launch.get('blocking_reasons') or []}", + ) + _add_check(checks, "launch_analysis_only_ready", bool(launch.get("analysis_only_ready"))) + _add_check(checks, "launch_restricted_decision_support_ready", bool(launch.get("restricted_decision_support_ready"))) + _add_check(checks, "operator_soft_rollout_ready", bool(launch.get("operator_soft_rollout_ready"))) + _add_check( + checks, + "real_trade_sample_gate_deferred", + launch.get("real_trade_sample_gate_required") is False + and launch.get("real_trade_sample_gate_status") == "deferred_analysis_decision_only", + detail=( + f"required={launch.get('real_trade_sample_gate_required')}, " + f"status={launch.get('real_trade_sample_gate_status')}" + ), + ) + _add_check( + checks, + "real_trade_sample_not_current_action", + "real_trade_sample_verified" not in launch_warnings + and "wait_for_and_verify_real_fill_sample" not in launch_next_actions, + detail=f"warnings={launch_warnings}, next_actions={launch_next_actions}", + ) + _add_check(checks, "broker_state_ok", broker.get("status") == "broker_state_ok", detail=f"status={broker.get('status')}") + _add_check( + checks, + "runtime_health_ok", + _path(runtime, "runtime_health", "level") == "ok" + and not list(runtime.get("current_blockers") or []) + and not bool(_path(runtime, "degraded_mode_state", "is_degraded")), + detail=( + f"health={_path(runtime, 'runtime_health', 'level')}, " + f"blockers={runtime.get('current_blockers') or []}, " + f"degraded={_path(runtime, 'degraded_mode_state', 'is_degraded')}" + ), + ) + _add_check(checks, "market_stream_connected", stream_code == "market_stream_connected", detail=f"stream={stream_code}") + _add_check(checks, "market_data_fresh", freshness_code == "market_data_fresh", detail=f"freshness={freshness_code}") + _add_check(checks, "market_gap_clear", gap_code == "market_data_gap_clear", detail=f"gap={gap_code}") + _add_check( + checks, + "decision_restricted_support", + decision.get("action_recommendation") == "restricted_decision_support", + detail=f"action={decision.get('action_recommendation')}", + ) + _add_check( + checks, + "decision_observe_only", + decision.get("operator_mode") == "observe_only", + detail=f"operator_mode={decision.get('operator_mode')}", + ) + _add_check( + checks, + "decision_execution_not_allowed", + decision.get("execution_allowed") in {None, False} + and "execution_assist_not_allowed" in set(decision.get("why_allowed") or []), + detail=f"execution_allowed={decision.get('execution_allowed')}, why_allowed={decision.get('why_allowed') or []}", + ) + _add_check(checks, "review_packet_present", bool(review.get("decision_packet"))) + _add_check(checks, "review_trust_score_present", bool(review.get("trust_score"))) + _add_check(checks, "review_research_summary_present", bool(review.get("research_council_summary"))) + if p6_tick is not None: + _add_check( + checks, + "p6_governance_tick_noop", + p6_tick.get("status") == "no_op_healthy" + and not bool(p6_tick.get("requires_cycle_run")) + and not bool(p6_tick.get("requires_bounded_slice")), + detail=( + f"status={p6_tick.get('status')}, " + f"requires_cycle_run={p6_tick.get('requires_cycle_run')}, " + f"requires_bounded_slice={p6_tick.get('requires_bounded_slice')}" + ), + ) + + blockers = [item["code"] for item in checks if not item["passed"] and item["severity"] == "blocker"] + warnings = [item["code"] for item in checks if not item["passed"] and item["severity"] == "warning"] + return { + "passed": not blockers, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "instrument_id": instrument_id, + "timeframe": timeframe, + "phase": "analysis_decision_only", + "blockers": blockers, + "warnings": warnings, + "checks": checks, + "observed": { + "broker_status": broker.get("status"), + "broker_fills": len(list(broker.get("fills") or [])), + "runtime_health": _path(runtime, "runtime_health", "level"), + "market_stream": stream_code, + "market_freshness": freshness_code, + "market_gap": gap_code, + "launch_mode": launch.get("operator_soft_rollout_mode"), + "real_trade_sample_gate_required": launch.get("real_trade_sample_gate_required"), + "real_trade_sample_gate_status": launch.get("real_trade_sample_gate_status"), + "decision_action": decision.get("action_recommendation"), + "decision_operator_mode": decision.get("operator_mode"), + "decision_trust_classification": decision.get("trust_classification"), + "p6_governance_tick_status": p6_tick.get("status") if p6_tick else None, + }, + } + + +def _http_get_json(url: str, *, timeout_seconds: float) -> dict: + with urllib.request.urlopen(url, timeout=timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + + +def _http_post_json(url: str, payload: dict, *, timeout_seconds: float) -> dict: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + + +def _add_check(checks: list[dict], code: str, passed: bool, *, detail: str = "", severity: str = "blocker") -> None: + checks.append({"code": code, "passed": bool(passed), "severity": severity, "detail": detail}) + + +def _first_market_state(runtime: dict) -> dict: + latest = dict(runtime.get("latest_market_state_by_symbol") or {}) + for value in latest.values(): + if isinstance(value, dict): + return value + return {} + + +def _path(payload: dict, *parts: str): + current = payload + for part in parts: + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_decision_packet.py b/scripts/run_decision_packet.py index 0f69d82..0415a88 100644 --- a/scripts/run_decision_packet.py +++ b/scripts/run_decision_packet.py @@ -18,7 +18,7 @@ def main(env=None) -> int: output_json = _get_bool(env, "DECISION_PACKET_OUTPUT_JSON", False) payload = DecisionPacketService().build_persist_and_alert(instrument_id=instrument_id) if output_json: - print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) else: print(f"Decision packet: {payload.get('status')} / {payload.get('action_recommendation')}") print(f"Why blocked: {', '.join(payload.get('why_blocked') or []) or 'none'}") diff --git a/scripts/run_launch_readiness.py b/scripts/run_launch_readiness.py index 26b0954..18706cd 100644 --- a/scripts/run_launch_readiness.py +++ b/scripts/run_launch_readiness.py @@ -19,7 +19,7 @@ def main(env=None) -> int: output_json = _get_bool(env, "LAUNCH_READINESS_OUTPUT_JSON", False) payload = LaunchReadinessService().build_summary(instrument_id=instrument_id, timeframe=timeframe) if output_json: - print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) else: print(f"Assisted trading ready: {payload.get('assisted_trading_ready')}") print(f"Real live ready: {payload.get('real_live_ready')}") diff --git a/scripts/run_monitoring_alert_rollout.py b/scripts/run_monitoring_alert_rollout.py new file mode 100644 index 0000000..d07bfa2 --- /dev/null +++ b/scripts/run_monitoring_alert_rollout.py @@ -0,0 +1,1331 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import json +import os +import subprocess +import sys +from typing import Optional +from urllib.parse import quote + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +STATUS_PATH = ROOT / ".claude" / "monitoring_alert_rollout_status.json" +DOC_PATH = ROOT / "docs" / "AI_ICT_下一阶段开发任务清单_v1.md" +SESSION_PATH = ROOT / "SESSION.md" +TASKS_PATH = ROOT / "TASKS.md" +ENV_LOCAL_PATH = ROOT / ".env.local" +ENV_PATH = ROOT / ".env" +ENV_EXAMPLE_PATH = ROOT / ".env.example" +DEFAULT_TIMEOUT_SECONDS = 900 + + +def _venv_python_command() -> str: + windows = ROOT / ".venv" / "Scripts" / "python.exe" + unix = ROOT / ".venv" / "bin" / "python" + if windows.exists(): + return '"/d/ai-exchange/.venv/Scripts/python.exe"' + if unix.exists(): + return ".venv/bin/python" + return "python" + + +PYTHON_CMD = _venv_python_command() + + +def _cmd(script_name: str) -> str: + return f"{PYTHON_CMD} scripts/{script_name}" + + +def _dedupe(items: list[str]) -> list[str]: + seen = set() + out = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _truncate_output(text: str, limit: int = 240) -> str: + cleaned = str(text or "").strip().replace("\r", " ").replace("\n", " ") + return cleaned[:limit] + + +def _get_bool(env, key: str, default: bool = False) -> 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 + try: + return int(str(value).strip()) + except ValueError: + return default + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _read_env_file_value(path: Path, key: str) -> Optional[str]: + if not path.exists(): + return None + prefix = f"{key}=" + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith(prefix): + return stripped[len(prefix):].strip() + return None + + +def _compose_database_url_from_env(env: dict) -> Optional[str]: + explicit = str(env.get("DATABASE_URL") or "").strip() + if explicit: + return explicit + host = str(env.get("DATABASE_HOST") or "").strip() + port = str(env.get("DATABASE_PORT") or "").strip() + name = str(env.get("DATABASE_NAME") or "").strip() + user = str(env.get("DATABASE_USER") or "").strip() + password = str(env.get("DATABASE_PASSWORD") or "") + if not all([host, port, name, user]): + return None + return f"postgresql+psycopg://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{name}" + + +def _merge_env_file_values(env: dict, path: Path, keys: list[str]) -> dict: + merged = dict(env) + for key in keys: + if merged.get(key): + continue + value = _read_env_file_value(path, key) + if value is not None: + merged[key] = value + return merged + + +def _seed_database_env(env: dict) -> dict: + seeded = dict(env or {}) + component_keys = ["DATABASE_HOST", "DATABASE_PORT", "DATABASE_NAME", "DATABASE_USER", "DATABASE_PASSWORD"] + seeded = _merge_env_file_values(seeded, ENV_LOCAL_PATH, component_keys + ["DATABASE_URL"]) + local_composed = _compose_database_url_from_env(seeded) + if local_composed: + seeded["DATABASE_URL"] = local_composed + return seeded + seeded = _merge_env_file_values(seeded, ENV_PATH, component_keys + ["DATABASE_URL"]) + env_composed = _compose_database_url_from_env(seeded) + if env_composed: + seeded["DATABASE_URL"] = env_composed + return seeded + seeded = _merge_env_file_values(seeded, ENV_EXAMPLE_PATH, ["DATABASE_URL"]) + fallback = _compose_database_url_from_env(seeded) + if fallback: + seeded["DATABASE_URL"] = fallback + return seeded + + +N1_VALIDATION_COMMANDS = [ + _cmd("run_launch_readiness.py"), + _cmd("run_live_readiness.py"), + _cmd("run_review_packet.py"), + _cmd("run_trading_runtime.py"), + _cmd("run_offline_checks.py"), +] +N2_VALIDATION_COMMANDS = [ + _cmd("run_decision_packet.py"), + _cmd("run_alert_dispatcher.py"), + _cmd("run_trading_runtime.py"), + _cmd("run_offline_checks.py"), +] +N3_VALIDATION_COMMANDS = [ + _cmd("run_decision_packet.py"), + _cmd("run_trading_runtime.py"), + _cmd("run_review_packet.py"), + _cmd("run_offline_checks.py"), +] +N4_VALIDATION_COMMANDS = [ + _cmd("run_trading_runtime.py"), + _cmd("run_review_packet.py"), + _cmd("run_offline_checks.py"), +] + +PHASES = [ + { + "code": "N1", + "title": "Launch Readiness 收尾", + "goal": "收敛 assisted-trading readiness 剩余 blocker 与 warning,并硬化 readiness 展示。", + "tasks": [ + "N1-1 明确 launch readiness 判定口径", + "N1-2 收敛 trust / live / real-trade 样本 warning", + "N1-3 workbench / API 展示硬化", + ], + "files": [ + "src/services/readiness/launch_readiness_service.py", + "src/config/settings.py", + "src/api/server.py", + "src/api/trading_runtime.py", + "src/web/app.js", + "src/web/index.html", + "SESSION.md", + "TASKS.md", + ], + "validation_commands": N1_VALIDATION_COMMANDS, + "deliverables": [ + "clear assisted-vs-real-live readiness boundary", + "reduced launch blockers and warnings", + "actionable next_actions output", + "hardened launch readiness workbench panel", + ], + }, + { + "code": "N2", + "title": "最小主动消息告警闭环", + "goal": "把当前仅落库的 decision alerts 打通到真实外部消息通道。", + "tasks": [ + "N2-1 新增 alert delivery 持久化", + "N2-2 新增 notifier 基础设施", + "N2-3 新增 AlertDispatcherService", + "N2-4 API / CLI / runtime 接入", + ], + "files": [ + "migrations/*_create_alert_deliveries.sql", + "src/repositories/alert_delivery_repository.py", + "src/services/notifications/__init__.py", + "src/services/notifications/telegram_notifier.py", + "src/services/notifications/webhook_notifier.py", + "src/services/alerts/__init__.py", + "src/services/alerts/alert_dispatcher_service.py", + "src/services/alerts/message_template_service.py", + "src/services/alerts/channel_router_service.py", + "src/services/alerts/delivery_policy_service.py", + "src/api/alerts.py", + "src/api/server.py", + "src/services/runtime/trading_runtime_service.py", + "scripts/run_alert_dispatcher.py", + ], + "validation_commands": N2_VALIDATION_COMMANDS, + "deliverables": [ + "alert_deliveries persistence", + "single-channel notifier", + "queued-to-delivered alert dispatcher", + "dispatch API and CLI", + "runtime alert_dispatch task", + ], + }, + { + "code": "N3", + "title": "告警质量与 runtime 硬化", + "goal": "在外发闭环接通后治理噪声、分级状态并统一决策包展示。", + "tasks": [ + "N3-1 告警节流与重复抑制", + "N3-2 runtime blockers 分级", + "N3-3 决策包展示一致性硬化", + ], + "files": [ + "src/services/alerts/delivery_policy_service.py", + "src/services/decision/decision_packet_service.py", + "src/services/runtime/trading_runtime_service.py", + "src/services/readiness/launch_readiness_service.py", + "src/api/trading_runtime.py", + "src/web/app.js", + ], + "validation_commands": N3_VALIDATION_COMMANDS, + "deliverables": [ + "deduped and throttled alerts", + "graded runtime status semantics", + "consistent packet wording across API, UI, notifier", + ], + }, + { + "code": "N4", + "title": "视觉盯盘增强预埋", + "goal": "只固化视觉层模型与扩展位,不全面实现截图与视觉复核主线。", + "tasks": [ + "N4-1 固化视觉数据模型设计", + "N4-2 仅做代码预留点", + ], + "files": [ + "docs/AI_ICT_视觉盯盘与主动告警增强方案_v1.md", + "docs/AI_ICT_盯盘告警技术设计稿_v1.md", + "docs/AI_ICT_盯盘告警开发任务清单_v1.md", + "src/services/decision/decision_packet_service.py", + "src/services/runtime/trading_runtime_service.py", + ], + "validation_commands": N4_VALIDATION_COMMANDS, + "deliverables": [ + "stable visual trigger taxonomy", + "reserved visual payload fields", + "reserved runtime extension points", + ], + }, +] + +MAJOR_BLOCKERS = { + "runtime_env_missing": [ + "validationerror", + "field required", + "database_url", + "database url", + "input_value={}" + ], + "missing_credentials": ["missing_credentials", "缺少凭据", "token", "chat_id", "webhook"], + "external_channel_auth_required": ["401", "403", "forbidden", "unauthorized", "auth"], + "unsafe_migration_failure": ["migration", "sql", "alembic"], + "vendor_or_network_validation_blocked": ["network", "timed out", "connection", "proxy", "dns", "429", "vendor"], + "live_trading_boundary_unclear": ["live trading", "real live", "submit strategy", "boundary"], +} + + +def build_rollout_plan() -> dict: + return { + "generated_ts": _now_iso(), + "doc_path": str(DOC_PATH), + "major_blockers": list(MAJOR_BLOCKERS.keys()), + "phases": PHASES, + "automation_policy": { + "default_behavior": "run validations and advance when phase passes", + "pause_only_for": list(MAJOR_BLOCKERS.keys()), + "run_mode": "run-until-blocked", + "repair_mode": "repair-loop", + "bridge_mode": "repair-bridge", + }, + } + + +def read_status() -> dict: + if not STATUS_PATH.exists(): + return { + "state": "not_started", + "updated_ts": _now_iso(), + "current_phase": PHASES[0]["code"], + "completed_phases": [], + "blocked_reason": None, + "note": "Rollout has not started yet.", + "doc_path": str(DOC_PATH), + "phases": PHASES, + "history": [], + "last_run": None, + "repair_loop": None, + } + payload = json.loads(STATUS_PATH.read_text(encoding="utf-8")) + payload.setdefault("doc_path", str(DOC_PATH)) + payload.setdefault("phases", PHASES) + payload.setdefault("history", []) + payload.setdefault("last_run", None) + payload.setdefault("repair_loop", None) + return payload + + +def write_status(payload: dict) -> None: + STATUS_PATH.parent.mkdir(parents=True, exist_ok=True) + STATUS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def start_rollout() -> dict: + payload = { + "state": "running", + "started_ts": _now_iso(), + "updated_ts": _now_iso(), + "current_phase": PHASES[0]["code"], + "completed_phases": [], + "blocked_reason": None, + "note": "Autonomous rollout started. Continue until a major blocker is recorded.", + "phases": PHASES, + "doc_path": str(DOC_PATH), + "history": [], + "last_run": None, + "repair_loop": None, + } + write_status(payload) + return payload + + +def _phase_by_code(code: str) -> Optional[dict]: + for phase in PHASES: + if phase["code"] == code: + return phase + return None + + +def _next_phase_code(completed: list[str]) -> Optional[str]: + for phase in PHASES: + if phase["code"] not in completed: + return phase["code"] + return None + + +def advance_rollout() -> dict: + status = read_status() + current = status.get("current_phase") + if current is None: + return status + completed = list(status.get("completed_phases") or []) + if current not in completed: + completed.append(current) + next_phase = _next_phase_code(completed) + history = list(status.get("history") or []) + history.append({"ts": _now_iso(), "event": "advance", "from": current, "to": next_phase}) + status["completed_phases"] = completed + status["current_phase"] = next_phase + status["updated_ts"] = _now_iso() + status["state"] = "completed" if next_phase is None else "running" + status["blocked_reason"] = None + status["history"] = history[-100:] + status["note"] = "All rollout phases are completed." if next_phase is None else f"Advanced to {next_phase}." + write_status(status) + return status + + +def block_rollout(*, blocker: str, note: str) -> dict: + status = read_status() + history = list(status.get("history") or []) + history.append({"ts": _now_iso(), "event": "blocked", "phase": status.get("current_phase"), "blocker": blocker, "note": note}) + status["state"] = "blocked" + status["blocked_reason"] = blocker + status["updated_ts"] = _now_iso() + status["note"] = note or "Rollout paused because a major blocker was recorded." + status["history"] = history[-100:] + write_status(status) + return status + + +def complete_rollout() -> dict: + status = read_status() + history = list(status.get("history") or []) + history.append({"ts": _now_iso(), "event": "complete"}) + status["state"] = "completed" + status["current_phase"] = None + status["completed_phases"] = [phase["code"] for phase in PHASES] + status["blocked_reason"] = None + status["updated_ts"] = _now_iso() + status["note"] = "Rollout marked completed." + status["history"] = history[-100:] + write_status(status) + return status + + +def _run_command(*, command: str, timeout_seconds: int, env: Optional[dict] = None) -> dict: + started = _now_iso() + runtime_env = dict(os.environ) + if env is not None: + runtime_env.update(env) + try: + completed = subprocess.run( + ["bash", "-lc", command], + cwd=str(ROOT), + env=runtime_env, + capture_output=True, + text=True, + timeout=timeout_seconds, + encoding="utf-8", + errors="replace", + ) + return { + "command": command, + "started_ts": started, + "finished_ts": _now_iso(), + "returncode": int(completed.returncode), + "stdout": str(completed.stdout or "")[-8000:], + "stderr": str(completed.stderr or "")[-8000:], + } + except subprocess.TimeoutExpired as exc: + return { + "command": command, + "started_ts": started, + "finished_ts": _now_iso(), + "returncode": 124, + "stdout": str(exc.stdout or "")[-8000:], + "stderr": str(exc.stderr or "")[-8000:], + } + + +def _combined_output(run: dict) -> str: + return f"{run.get('stdout', '')}\n{run.get('stderr', '')}" + + +def _detect_major_blocker(run: dict) -> Optional[str]: + haystack = _combined_output(run).lower() + if "adapter_real_live_not_ready" in haystack or "live readiness: false" in haystack: + return None + if "validationerror" in haystack and "database_url" in haystack and "field required" in haystack: + return "runtime_env_missing" + for code, patterns in MAJOR_BLOCKERS.items(): + if any(pattern.lower() in haystack for pattern in patterns): + return code + return None + + +def _seed_rollout_env(env: dict) -> dict: + seeded = dict(env or {}) + override_pairs = [ + ("MONITORING_ALERT_DATABASE_URL", "DATABASE_URL"), + ("MONITORING_ALERT_DATABASE_HOST", "DATABASE_HOST"), + ("MONITORING_ALERT_DATABASE_PORT", "DATABASE_PORT"), + ("MONITORING_ALERT_DATABASE_NAME", "DATABASE_NAME"), + ("MONITORING_ALERT_DATABASE_USER", "DATABASE_USER"), + ("MONITORING_ALERT_DATABASE_PASSWORD", "DATABASE_PASSWORD"), + ("MONITORING_ALERT_BROKER_READ_PROXY_URL", "BROKER_READ_PROXY_URL"), + ("MONITORING_ALERT_OKX_API_KEY", "OKX_API_KEY"), + ("MONITORING_ALERT_OKX_API_SECRET", "OKX_API_SECRET"), + ("MONITORING_ALERT_OKX_API_PASSPHRASE", "OKX_API_PASSPHRASE"), + ] + for source_key, target_key in override_pairs: + if seeded.get(source_key): + seeded[target_key] = seeded[source_key] + seeded = _seed_database_env(seeded) + return seeded + + +def _resolve_rollout_env(env: Optional[dict]) -> dict: + base = dict(os.environ) + if env is not None: + base.update(env) + return _seed_rollout_env(base) + + +def _is_runtime_env_missing_result(phase_result: dict) -> bool: + return str(phase_result.get("blocker") or "") == "runtime_env_missing" + + +def _retry_phase_with_seeded_env(*, phase: dict, phase_result: dict, timeout_seconds: int, env: dict) -> dict: + if not _is_runtime_env_missing_result(phase_result): + return phase_result + seeded_env = _seed_rollout_env(env) + if seeded_env == env: + return phase_result + retried = _execute_phase(phase=phase, timeout_seconds=timeout_seconds, env=seeded_env) + retried["summary"] = f"{retried.get('summary')} (retried with seeded runtime env)" + return retried + + +def _classify_runtime_exit_as_needs_work(phase_result: dict) -> dict: + commands = list(phase_result.get("commands") or []) + if not commands: + return phase_result + failing = next((item for item in commands if int(item.get("returncode") or 0) != 0), None) + if failing is None: + return phase_result + output = _combined_output(failing).lower() + if "trading runtime:" in output and "blockers:" in output: + adjusted = dict(phase_result) + adjusted["result"] = "needs_work" + adjusted["blocker"] = None + adjusted["summary"] = f"Validation failed for phase {phase_result.get('phase')} on command: {phase_result.get('failed_command')}" + return adjusted + return phase_result + + +def _finalize_phase_result(*, phase: dict, phase_result: dict) -> dict: + result = _classify_runtime_exit_as_needs_work(phase_result) + guidance = _build_next_step_guidance( + phase=phase, + result=result["result"], + blocker=result.get("blocker"), + failed_command=result.get("failed_command"), + command_results=list(result.get("commands") or []), + ) + repair_metadata = _build_repair_metadata( + phase=phase, + result=result["result"], + blocker=result.get("blocker"), + failed_command=result.get("failed_command"), + command_results=list(result.get("commands") or []), + guidance=guidance, + ) + result["next_step_guidance"] = guidance + result["repair_metadata"] = repair_metadata + return result + + +def _prioritize_files_for_failed_command(phase: dict, failed_command: Optional[str]) -> list[str]: + files = list(phase.get("files") or []) + if not failed_command: + return files[:6] + command = failed_command.lower() + ranked = [] + for file_path in files: + score = 0 + lower_path = file_path.lower() + if "launch_readiness" in command and "readiness" in lower_path: + score += 3 + if "alert_dispatcher" in command and any(token in lower_path for token in ["alerts", "notifications", "runtime"]): + score += 3 + if "trading_runtime" in command and "runtime" in lower_path: + score += 3 + if "review_packet" in command and any(token in lower_path for token in ["decision", "operator", "api"]): + score += 3 + ranked.append((score, file_path)) + ranked.sort(key=lambda item: (-item[0], item[1])) + return [item[1] for item in ranked[:6]] + + +def _summarize_command_output(command: dict) -> str: + for field in ("stderr", "stdout"): + text = str(command.get(field) or "").strip() + if text: + return _truncate_output(text.splitlines()[0].strip()) + return "" + + +def _infer_fix_hints(*, phase: dict, failed_command: Optional[str], output: str) -> list[str]: + hints = [] + haystack = output.lower() + command = str(failed_command or "").lower() + if "modulenotfounderror" in haystack or "importerror" in haystack: + hints.append("Start with missing imports or missing modules in the phase's core files.") + if "no such file or directory" in haystack: + hints.append("Check whether the referenced script, file, or generated artifact exists at the expected path.") + if "assertionerror" in haystack or "failed" in haystack: + hints.append("Read the failing assertion/test output and adjust phase logic before broad re-runs.") + if "not implemented" in haystack: + hints.append("Implement the missing phase behavior in the listed service files before re-running validations.") + if "run_launch_readiness.py" in command: + hints.append("Prioritize readiness service, settings, and workbench readiness display files.") + if "run_alert_dispatcher.py" in command: + hints.append("Prioritize alert delivery repository, notifier, dispatcher service, and runtime dispatch hook.") + if "run_trading_runtime.py" in command: + hints.append("Prioritize runtime task state, scheduling flow, and API/runtime status alignment.") + if "run_review_packet.py" in command: + hints.append("Prioritize decision packet wording consistency and review packet aggregation paths.") + if not hints: + hints.append(f"Focus on the active phase files first: {', '.join((phase.get('files') or [])[:4])}.") + return _dedupe(hints) + + +def _should_treat_as_informational_failure(*, phase: dict, command: str, run: dict) -> bool: + if phase.get("code") != "N1": + return False + if "run_live_readiness.py" not in str(command or ""): + return False + output = _combined_output(run) + return "adapter_real_live_not_ready" in output or "Live readiness: False" in output + + +def _build_next_step_guidance(*, phase: dict, result: str, blocker: Optional[str], failed_command: Optional[str], command_results: list[dict]) -> dict: + guidance = { + "priority_tasks": list(phase.get("tasks") or [])[:3], + "suggested_files": _prioritize_files_for_failed_command(phase, failed_command), + "suggested_commands": [], + "reasoning": [], + } + if result == "passed": + guidance["reasoning"].append("Current phase validations passed; advance to the next phase.") + return guidance + if blocker is not None: + guidance["reasoning"].append(f"Major blocker detected: {blocker}.") + guidance["reasoning"].append("Pause implementation and resolve the external or boundary issue first.") + if failed_command: + guidance["suggested_commands"].append(failed_command) + return guidance + failed_run = next((item for item in command_results if item.get("returncode") != 0), None) + if failed_command: + guidance["suggested_commands"].append(failed_command) + if failed_run is not None: + guidance["reasoning"].append("Inspect the first failing validation command and fix the underlying code issue before re-running the phase.") + guidance["reasoning"].extend(_infer_fix_hints(phase=phase, failed_command=failed_command, output=_combined_output(failed_run))) + preview = _summarize_command_output(failed_run) + if preview: + guidance["reasoning"].append(f"First failing signal: {preview}") + if not guidance["suggested_commands"]: + guidance["suggested_commands"] = list(phase.get("validation_commands") or [])[:2] + return guidance + + +def _build_repair_prompt_context(*, phase: dict, result: str, failed_command: Optional[str], failed_run: Optional[dict], guidance: dict, failure_class: str, recommended_focus: str, phase_patch_targets: list[str]) -> dict: + command_preview = _summarize_command_output(failed_run or {}) if failed_run is not None else "" + prompt_lines = [ + f"Active phase: {phase.get('code')} {phase.get('title')}", + f"Goal: {phase.get('goal')}", + f"Failure class: {failure_class}", + f"Recommended focus: {recommended_focus}", + f"Validation entrypoint: {failed_command or 'none'}", + ] + if command_preview: + prompt_lines.append(f"First failing signal: {command_preview}") + if phase_patch_targets: + prompt_lines.append(f"Patch targets: {', '.join(phase_patch_targets)}") + if guidance.get("reasoning"): + prompt_lines.append("Repair guidance:") + prompt_lines.extend([f"- {item}" for item in guidance.get("reasoning") or []]) + return { + "mode": "repair_loop", + "phase": phase.get("code"), + "phase_title": phase.get("title"), + "result": result, + "failure_class": failure_class, + "recommended_focus": recommended_focus, + "patch_targets": list(phase_patch_targets), + "validation_entrypoint": failed_command, + "phase_tasks": list(phase.get("tasks") or []), + "phase_files": list(phase.get("files") or []), + "suggested_files": list(guidance.get("suggested_files") or []), + "suggested_commands": list(guidance.get("suggested_commands") or []), + "reasoning": list(guidance.get("reasoning") or []), + "failing_command_preview": command_preview, + "failing_stdout_tail": _truncate_output(str((failed_run or {}).get("stdout") or ""), 1200), + "failing_stderr_tail": _truncate_output(str((failed_run or {}).get("stderr") or ""), 1200), + "prompt_text": "\n".join(prompt_lines), + } + + +def _classify_failure(*, result: str, blocker: Optional[str], failed_command: Optional[str], output: str) -> str: + if result == "passed": + return "none" + if blocker is not None: + return f"blocker:{blocker}" + haystack = output.lower() + command = str(failed_command or "").lower() + if "modulenotfounderror" in haystack or "importerror" in haystack: + return "missing_dependency_or_import" + if "no such file or directory" in haystack: + return "missing_file_or_artifact" + if "assertionerror" in haystack or "failed" in haystack: + return "validation_failure" + if "not implemented" in haystack: + return "missing_implementation" + if "run_launch_readiness.py" in command: + return "readiness_gap" + if "run_alert_dispatcher.py" in command: + return "alert_dispatch_gap" + if "run_trading_runtime.py" in command: + return "runtime_gap" + if "run_review_packet.py" in command: + return "decision_packet_gap" + return "general_phase_failure" + + +def _recommend_focus_area(*, phase: dict, failed_command: Optional[str], failure_class: str) -> str: + command = str(failed_command or "").lower() + if failure_class.startswith("blocker:"): + return "resolve_external_blocker" + if "launch_readiness" in command or failure_class == "readiness_gap": + return "readiness_and_boundary_hardening" + if "alert_dispatcher" in command or failure_class == "alert_dispatch_gap": + return "alert_delivery_and_dispatch" + if "trading_runtime" in command or failure_class == "runtime_gap": + return "runtime_scheduler_and_status" + if "review_packet" in command or failure_class == "decision_packet_gap": + return "decision_packet_and_operator_view" + return f"phase:{phase.get('code', 'unknown').lower()}" + + +def _build_phase_patch_targets(*, phase: dict, failed_command: Optional[str]) -> list[str]: + return _prioritize_files_for_failed_command(phase, failed_command)[:4] + + +def _build_repair_metadata(*, phase: dict, result: str, blocker: Optional[str], failed_command: Optional[str], command_results: list[dict], guidance: dict) -> dict: + failed_run = next((item for item in command_results if item.get("returncode") != 0), None) + combined = _combined_output(failed_run) if failed_run is not None else "" + failure_class = _classify_failure(result=result, blocker=blocker, failed_command=failed_command, output=combined) + recommended_focus = _recommend_focus_area(phase=phase, failed_command=failed_command, failure_class=failure_class) + phase_patch_targets = _build_phase_patch_targets(phase=phase, failed_command=failed_command) + return { + "failure_class": failure_class, + "recommended_focus": recommended_focus, + "phase_patch_targets": phase_patch_targets, + "phase_validation_entrypoint": failed_command, + "repair_loop_ready": result in {"needs_work", "blocked"}, + "guidance_version": "v1", + "suggested_files": list(guidance.get("suggested_files") or []), + "repair_prompt_context": _build_repair_prompt_context( + phase=phase, + result=result, + failed_command=failed_command, + failed_run=failed_run, + guidance=guidance, + failure_class=failure_class, + recommended_focus=recommended_focus, + phase_patch_targets=phase_patch_targets, + ), + } + + +def _execute_phase(*, phase: dict, timeout_seconds: int, env: Optional[dict] = None) -> dict: + started_ts = _now_iso() + runtime_env = _resolve_rollout_env(env) + command_results = [] + informational_failures = [] + result = "passed" + blocker = None + failed_command = None + summary = f"Phase {phase['code']} passed all validation commands." + for command in phase.get("validation_commands") or []: + run = _run_command(command=command, timeout_seconds=timeout_seconds, env=runtime_env) + command_results.append(run) + if run["returncode"] == 0: + continue + if _should_treat_as_informational_failure(phase=phase, command=command, run=run): + informational_failures.append(command) + continue + failed_command = command + detected_blocker = _detect_major_blocker(run) + if detected_blocker is not None: + result = "blocked" + blocker = detected_blocker + summary = f"Major blocker detected while running: {command}" + break + result = "needs_work" + summary = f"Validation failed for phase {phase['code']} on command: {command}" + break + phase_result = { + "phase": phase["code"], + "title": phase["title"], + "goal": phase["goal"], + "started_ts": started_ts, + "finished_ts": _now_iso(), + "result": result, + "blocker": blocker, + "summary": summary, + "tasks": list(phase.get("tasks") or []), + "files": list(phase.get("files") or []), + "commands": command_results, + "deliverables": list(phase.get("deliverables") or []), + "failed_command": failed_command, + } + if phase_result["result"] == "passed" and informational_failures: + phase_result["summary"] = f"Phase {phase['code']} passed core validations with informational failures: {', '.join(informational_failures)}" + phase_result = _retry_phase_with_seeded_env(phase=phase, phase_result=phase_result, timeout_seconds=timeout_seconds, env=runtime_env) + return _finalize_phase_result(phase=phase, phase_result=phase_result) + +def run_until_blocked(env=None) -> dict: + env = dict(os.environ if env is None else env) + status = read_status() + if status.get("state") == "not_started": + status = start_rollout() + max_rounds = max(_get_int(env, "MONITORING_ALERT_ROLLOUT_MAX_ROUNDS", 10), 1) + timeout_seconds = max(_get_int(env, "MONITORING_ALERT_COMMAND_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS), 30) + for _ in range(max_rounds): + current = status.get("current_phase") + if current is None: + status["state"] = "completed" + status["note"] = "All phases completed." + write_status(status) + return status + phase = _phase_by_code(current) + if phase is None: + raise RuntimeError(f"Unknown current phase: {current}") + phase_result = _execute_phase(phase=phase, timeout_seconds=timeout_seconds, env=env) + status["last_run"] = phase_result + history = list(status.get("history") or []) + history.append({"ts": _now_iso(), "event": "phase_run", "phase": current, "result": phase_result["result"], "blocker": phase_result.get("blocker")}) + status["history"] = history[-100:] + status["updated_ts"] = _now_iso() + if phase_result["result"] == "passed": + status = advance_rollout() + status["last_run"] = phase_result + write_status(status) + return status + if phase_result["result"] == "blocked": + status["state"] = "blocked" + status["blocked_reason"] = phase_result.get("blocker") + status["note"] = phase_result.get("summary") or "Major blocker detected." + status["last_run"] = phase_result + write_status(status) + return status + status["state"] = "needs_work" + status["blocked_reason"] = None + status["note"] = phase_result.get("summary") or "Phase validation failed and needs implementation work." + status["last_run"] = phase_result + write_status(status) + return status + status["state"] = "needs_work" + status["note"] = f"Stopped after reaching MONITORING_ALERT_ROLLOUT_MAX_ROUNDS={max_rounds}." + write_status(status) + return status + + +def _build_bridge_input(loop_payload: dict) -> dict: + context = dict(loop_payload.get("repair_prompt_context") or {}) + return { + "protocol_version": "repair-bridge/v1", + "mode": "repair_loop", + "phase": loop_payload.get("phase"), + "phase_title": loop_payload.get("phase_title"), + "failure_class": loop_payload.get("failure_class"), + "recommended_focus": loop_payload.get("recommended_focus"), + "phase_patch_targets": list(loop_payload.get("phase_patch_targets") or []), + "validation_entrypoint": loop_payload.get("validation_entrypoint"), + "repair_prompt_context": context, + } + + +def _parse_bridge_output(run: dict) -> dict: + stdout = str(run.get("stdout") or "").strip() + if not stdout: + return {"protocol_version": "repair-bridge/v1", "parsed": False, "error": "empty_stdout"} + try: + payload = json.loads(stdout) + return {"protocol_version": payload.get("protocol_version", "repair-bridge/v1"), "parsed": True, "payload": payload} + except json.JSONDecodeError: + return {"protocol_version": "repair-bridge/v1", "parsed": False, "error": "invalid_json_stdout", "stdout_preview": _truncate_output(stdout, 800)} + + +def _validate_bridge_output_payload(payload: dict) -> list[str]: + errors = [] + for key in ["status", "summary", "changed_files"]: + if key not in payload: + errors.append(f"missing_field:{key}") + if "changed_files" in payload and not isinstance(payload.get("changed_files"), list): + errors.append("invalid_field:changed_files") + return errors + + +def _bridge_output_summary(bridge_output: dict) -> str: + if not bridge_output.get("parsed"): + return f"bridge output parse failed: {bridge_output.get('error')}" + payload = dict(bridge_output.get("payload") or {}) + validation_errors = _validate_bridge_output_payload(payload) + if validation_errors: + return f"bridge output schema issues: {', '.join(validation_errors)}" + return str(payload.get("summary") or "bridge output parsed") + + +def _bridge_changed_files(bridge_output: dict) -> list[str]: + if not bridge_output.get("parsed"): + return [] + payload = dict(bridge_output.get("payload") or {}) + changed_files = payload.get("changed_files") + if not isinstance(changed_files, list): + return [] + return [str(item) for item in changed_files] + + +def _bridge_tests_run(bridge_output: dict) -> list[str]: + if not bridge_output.get("parsed"): + return [] + payload = dict(bridge_output.get("payload") or {}) + tests_run = payload.get("tests_run") + if not isinstance(tests_run, list): + return [] + return [str(item) for item in tests_run] + + +def _bridge_success_guess(bridge_output: dict) -> bool: + if not bridge_output.get("parsed"): + return False + payload = dict(bridge_output.get("payload") or {}) + if _validate_bridge_output_payload(payload): + return False + return str(payload.get("status") or "").lower() in {"applied", "success", "fixed"} + + +def _should_rerun_after_bridge(bridge_output: dict, *, env: dict) -> bool: + if not _get_bool(env, "MONITORING_ALERT_AUTO_RERUN_ENABLED", True): + return False + if not bridge_output.get("parsed"): + return False + payload = dict(bridge_output.get("payload") or {}) + if _validate_bridge_output_payload(payload): + return False + status = str(payload.get("status") or "").lower() + success_guess = payload.get("success_guess") + changed_files = _bridge_changed_files(bridge_output) + if status not in {"applied", "success", "fixed"}: + return False + if success_guess is False: + return False + return bool(changed_files) + + +def _select_rerun_commands(bridge_output: dict) -> list[str]: + tests_run = _bridge_tests_run(bridge_output) + if tests_run: + return tests_run + changed_files = _bridge_changed_files(bridge_output) + commands = [] + if any("src/services/alerts/" in item or "src/api/alerts.py" in item for item in changed_files): + commands.append(_cmd("run_alert_dispatcher.py")) + if any("src/services/runtime/" in item or "src/api/trading_runtime.py" in item for item in changed_files): + commands.append(_cmd("run_trading_runtime.py")) + if any("src/services/readiness/" in item or "src/web/" in item or "src/config/settings.py" in item for item in changed_files): + commands.append(_cmd("run_launch_readiness.py")) + if any("src/services/decision/" in item or "src/services/operator/" in item for item in changed_files): + commands.append(_cmd("run_review_packet.py")) + if changed_files and all(item.startswith("docs/") for item in changed_files): + return [] + return _dedupe(commands) + + +def _run_focused_rerun(*, commands: list[str]) -> list[dict]: + return [_run_command(command=command, timeout_seconds=DEFAULT_TIMEOUT_SECONDS) for command in commands] + + +def _bridge_result_passed(payload: dict) -> bool: + result = dict(payload.get("bridge_result") or {}) + return int(result.get("returncode") or 1) == 0 + + +def _run_post_repair_validation(*, env: dict, bridge_output: dict) -> dict: + rerun_commands = _select_rerun_commands(bridge_output) + focused_payload = None + if rerun_commands: + focused = _run_focused_rerun(commands=rerun_commands) + focused_payload = {"state": "focused_rerun_completed", "commands": focused, "selected_commands": rerun_commands} + if any(item.get("returncode") != 0 for item in focused): + focused_payload["state"] = "needs_work" + focused_payload["note"] = "Focused rerun failed after repair." + return focused_payload + rerun_env = dict(env) + rerun_env["MONITORING_ALERT_ROLLOUT_MODE"] = "run-until-blocked" + rerun_payload = run_until_blocked(env=rerun_env) + if focused_payload: + rerun_payload = {**rerun_payload, "focused_rerun": focused_payload} + return rerun_payload + + +def _should_advance_after_rerun(payload: dict) -> bool: + rerun_state = dict(payload.get("rerun_state_snapshot") or {}) + return rerun_state.get("state") in {"running", "completed"} + + +def _build_repair_bridge_protocol_doc() -> dict: + return { + "protocol_version": "repair-bridge/v1", + "stdin": {"required_fields": ["protocol_version", "mode", "phase", "phase_title", "failure_class", "recommended_focus", "phase_patch_targets", "validation_entrypoint", "repair_prompt_context"]}, + "stdout": {"required_fields": ["status", "summary", "changed_files"], "optional_fields": ["notes", "success_guess", "tests_run"]}, + } + + +def _build_repair_bridge_example_output() -> dict: + return { + "protocol_version": "repair-bridge/v1", + "status": "applied", + "summary": "Implemented missing alert dispatcher module and updated runtime hook.", + "changed_files": ["src/services/alerts/alert_dispatcher_service.py", "src/services/runtime/trading_runtime_service.py"], + "notes": ["Focused on N2 alert delivery gap"], + "success_guess": True, + "tests_run": [_cmd("run_alert_dispatcher.py")], + } + + +def _bridge_repair_signal(payload: dict) -> dict: + bridge_output = dict(payload.get("bridge_output") or {}) + return { + "summary": _bridge_output_summary(bridge_output), + "success_guess": _bridge_success_guess(bridge_output), + "changed_files": _bridge_changed_files(bridge_output), + "tests_run": _bridge_tests_run(bridge_output), + } + + +def _bridge_agent_prompt(loop_payload: dict) -> str: + return json.dumps(_build_bridge_input(loop_payload), ensure_ascii=False, indent=2) + + +def _run_bridge_command(*, command: str, bridge_input: dict) -> dict: + started = _now_iso() + stdin_text = json.dumps(bridge_input, ensure_ascii=False) + try: + completed = subprocess.run( + ["bash", "-lc", command], + cwd=str(ROOT), + input=stdin_text, + capture_output=True, + text=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + encoding="utf-8", + errors="replace", + ) + return { + "command": command, + "started_ts": started, + "finished_ts": _now_iso(), + "returncode": int(completed.returncode), + "stdin": _truncate_output(stdin_text, 2000), + "stdout": str(completed.stdout or "")[-8000:], + "stderr": str(completed.stderr or "")[-8000:], + } + except subprocess.TimeoutExpired as exc: + return { + "command": command, + "started_ts": started, + "finished_ts": _now_iso(), + "returncode": 124, + "stdin": _truncate_output(stdin_text, 2000), + "stdout": str(exc.stdout or "")[-8000:], + "stderr": str(exc.stderr or "")[-8000:], + } + + +def _build_repair_agent_prompt(loop_payload: dict) -> str: + return _bridge_agent_prompt(loop_payload) + + +def _rerun_hint_payload(loop_payload: dict) -> dict: + return { + "phase": loop_payload.get("phase"), + "validation_entrypoint": loop_payload.get("validation_entrypoint"), + "suggested_commands": list((loop_payload.get("repair_prompt_context") or {}).get("suggested_commands") or []), + } + + +def _repair_bridge_summary(payload: dict) -> str: + if not payload.get("bridge_invoked"): + return "repair bridge ready but not invoked" + result = dict(payload.get("bridge_result") or {}) + summary = f"repair bridge invoked with returncode={result.get('returncode')}" + bridge_output = dict(payload.get("bridge_output") or {}) + if bridge_output: + summary += f"; {_bridge_output_summary(bridge_output)}" + if payload.get("post_repair_rerun") is not None: + summary += f"; post-repair rerun state={dict(payload.get('post_repair_rerun') or {}).get('state')}" + return summary + + +def _rerun_state_snapshot(payload: dict) -> dict: + rerun = dict(payload.get("post_repair_rerun") or {}) + return {"state": rerun.get("state"), "current_phase": rerun.get("current_phase"), "note": rerun.get("note")} + + +def _enrich_bridge_payload(payload: dict) -> dict: + loop_payload = dict(payload) + enriched = { + **payload, + "repair_agent_prompt": _build_repair_agent_prompt(loop_payload), + "rerun_hint": _rerun_hint_payload(loop_payload), + "summary": _repair_bridge_summary(payload), + "bridge_protocol": _build_repair_bridge_protocol_doc(), + } + if payload.get("bridge_invoked"): + enriched["bridge_passed"] = _bridge_result_passed(payload) + enriched["bridge_output_signal"] = _bridge_repair_signal(payload) + if payload.get("post_repair_rerun") is not None: + enriched["rerun_summary"] = _repair_bridge_summary(payload) + enriched["rerun_state_snapshot"] = _rerun_state_snapshot(payload) + return enriched + + +def run_repair_loop(env=None) -> dict: + env = dict(os.environ if env is None else env) + status = read_status() + last_run = dict(status.get("last_run") or {}) + repair_metadata = dict(last_run.get("repair_metadata") or {}) + prompt_context = dict(repair_metadata.get("repair_prompt_context") or {}) + if status.get("state") != "needs_work" or not prompt_context: + return {"state": "repair_loop_unavailable", "updated_ts": _now_iso(), "reason": "No needs_work phase with repair prompt/context is available.", "current_phase": status.get("current_phase")} + iteration = int(((status.get("repair_loop") or {}).get("iteration") or 0)) + 1 + payload = { + "state": "repair_loop_ready", + "updated_ts": _now_iso(), + "iteration": iteration, + "phase": last_run.get("phase"), + "phase_title": last_run.get("title"), + "failure_class": repair_metadata.get("failure_class"), + "recommended_focus": repair_metadata.get("recommended_focus"), + "phase_patch_targets": list(repair_metadata.get("phase_patch_targets") or []), + "validation_entrypoint": repair_metadata.get("phase_validation_entrypoint"), + "repair_prompt_context": prompt_context, + "auto_repair_enabled": _get_bool(env, "MONITORING_ALERT_AUTO_REPAIR_ENABLED", False), + "next_action": "hand_off_to_repair_agent", + } + _write_repair_loop_state(payload) + return payload + + +def run_repair_bridge(env=None) -> dict: + env = dict(os.environ if env is None else env) + loop_payload = run_repair_loop(env=env) + if loop_payload.get("state") != "repair_loop_ready": + return loop_payload + bridge_command = str(env.get("MONITORING_ALERT_REPAIR_BRIDGE_COMMAND") or "").strip() + auto_repair_enabled = _get_bool(env, "MONITORING_ALERT_AUTO_REPAIR_ENABLED", False) + if not auto_repair_enabled or not bridge_command: + payload = _enrich_bridge_payload({**loop_payload, "state": "repair_bridge_ready", "bridge_invoked": False, "next_action": "invoke_external_repair_agent"}) + _write_repair_loop_state(payload) + return payload + bridge_input = _build_bridge_input(loop_payload) + run = _run_bridge_command(command=bridge_command, bridge_input=bridge_input) + bridge_output = _parse_bridge_output(run) + payload = { + **loop_payload, + "state": "repair_bridge_invoked", + "bridge_invoked": True, + "bridge_command": bridge_command, + "bridge_input": bridge_input, + "bridge_result": run, + "bridge_output": bridge_output, + "next_action": "rerun_validation_after_repair", + } + if _should_rerun_after_bridge(bridge_output, env=env): + payload["post_repair_rerun"] = _run_post_repair_validation(env=env, bridge_output=bridge_output) + payload = _enrich_bridge_payload(payload) + if payload.get("post_repair_rerun") is not None: + payload["next_action"] = "phase_advanced" if _should_advance_after_rerun(payload) else "inspect_rerun_result" + _write_repair_loop_state(payload) + return payload + + +def _upsert_anchor_block(content: str, header: str, body: str) -> str: + start = content.find(header) + block = f"{header}\n{body.rstrip()}\n" + if start == -1: + return content.rstrip() + "\n\n" + block + next_index = content.find("\n## ", start + len(header)) + if next_index == -1: + return content[:start] + block + return content[:start] + block + content[next_index + 1:] + + +def _write_session_anchor(status: dict) -> None: + if not SESSION_PATH.exists(): + return + current_phase = status.get("current_phase") or "none" + repair_loop = dict(status.get("repair_loop") or {}) + body = [f"- state: {status.get('state')}", f"- current_phase: {current_phase}", f"- updated_ts: {status.get('updated_ts')}"] + if repair_loop: + body.append(f"- repair_iteration: {repair_loop.get('iteration')}") + body.append(f"- repair_focus: {repair_loop.get('recommended_focus')}") + body.append(f"- repair_next_action: {repair_loop.get('next_action')}") + content = SESSION_PATH.read_text(encoding="utf-8") + SESSION_PATH.write_text(_upsert_anchor_block(content, "## Automation Status", "\n".join(body)), encoding="utf-8") + + +def _write_tasks_anchor(status: dict) -> None: + if not TASKS_PATH.exists(): + return + current_phase = status.get("current_phase") or "none" + repair_loop = dict(status.get("repair_loop") or {}) + body = [f"- [ ] state: {status.get('state')}", f"- [ ] current_phase: {current_phase}", f"- [ ] updated_ts: {status.get('updated_ts')}"] + if repair_loop: + body.append(f"- [ ] repair_iteration: {repair_loop.get('iteration')}") + body.append(f"- [ ] repair_focus: {repair_loop.get('recommended_focus')}") + body.append(f"- [ ] repair_next_action: {repair_loop.get('next_action')}") + content = TASKS_PATH.read_text(encoding="utf-8") + TASKS_PATH.write_text(_upsert_anchor_block(content, "## Automation Status", "\n".join(body)), encoding="utf-8") + + +def _write_status_anchors(status: dict) -> None: + _write_session_anchor(status) + _write_tasks_anchor(status) + + +def _append_patch_round(status: dict, payload: dict) -> None: + repair_loop = dict(status.get("repair_loop") or {}) + rounds = list(repair_loop.get("patch_rounds") or []) + bridge_output = dict(payload.get("bridge_output") or {}) + bridge_payload = dict(bridge_output.get("payload") or {}) if bridge_output.get("parsed") else {} + rounds.append({ + "ts": _now_iso(), + "iteration": payload.get("iteration"), + "changed_files": _bridge_changed_files(bridge_output), + "tests_run": _bridge_tests_run(bridge_output), + "success_guess": bridge_payload.get("success_guess"), + "rerun_state": dict(payload.get("rerun_state_snapshot") or {}).get("state"), + "phase_advanced": payload.get("next_action") == "phase_advanced", + }) + repair_loop["patch_rounds"] = rounds[-20:] + status["repair_loop"] = repair_loop + + +def _sync_patch_rounds_to_payload(status: dict, payload: dict) -> dict: + repair_loop = dict(status.get("repair_loop") or {}) + if repair_loop.get("patch_rounds"): + payload["patch_rounds"] = list(repair_loop.get("patch_rounds") or []) + return payload + + +def _write_repair_loop_state(payload: dict) -> None: + status = read_status() + status["repair_loop"] = payload + if payload.get("bridge_invoked"): + _append_patch_round(status, payload) + payload = _sync_patch_rounds_to_payload(status, payload) + status["repair_loop"] = payload + history = list(status.get("history") or []) + history.append({"ts": _now_iso(), "event": "repair_bridge" if payload.get("bridge_invoked") else "repair_loop_ready", "phase": payload.get("phase"), "iteration": payload.get("iteration")}) + status["history"] = history[-100:] + status["updated_ts"] = _now_iso() + write_status(status) + _write_status_anchors(status) + + +def render_text(payload: dict) -> str: + lines = [f"Monitoring alert rollout state: {payload.get('state', 'unknown')}"] + if payload.get("current_phase"): + lines.append(f"Current phase: {payload['current_phase']}") + completed = payload.get("completed_phases") or [] + lines.append(f"Completed phases: {', '.join(completed) if completed else 'none'}") + if payload.get("blocked_reason"): + lines.append(f"Blocked reason: {payload['blocked_reason']}") + if payload.get("note"): + lines.append(f"Note: {payload['note']}") + lines.append(f"Task doc: {payload.get('doc_path', str(DOC_PATH))}") + last_run = payload.get("last_run") or {} + if last_run: + lines.append("Last run:") + lines.append(f"- Phase: {last_run.get('phase')} {last_run.get('title', '')}") + lines.append(f"- Result: {last_run.get('result')}") + lines.append(f"- Summary: {last_run.get('summary')}") + for command in last_run.get("commands") or []: + preview = _summarize_command_output(command) + suffix = f" :: {preview}" if preview else "" + lines.append(f" - [{command.get('returncode')}] {command.get('command')}{suffix}") + guidance = last_run.get("next_step_guidance") or {} + if guidance: + lines.append("Next-step guidance:") + if guidance.get("priority_tasks"): + lines.append(f"- Priority tasks: {', '.join(guidance['priority_tasks'])}") + if guidance.get("suggested_files"): + lines.append(f"- Suggested files: {', '.join(guidance['suggested_files'])}") + if guidance.get("suggested_commands"): + lines.append(f"- Suggested commands: {', '.join(guidance['suggested_commands'])}") + for item in guidance.get("reasoning") or []: + lines.append(f"- {item}") + repair_loop = payload.get("repair_loop") or {} + if repair_loop: + lines.append("Repair loop state:") + lines.append(f"- Iteration: {repair_loop.get('iteration')}") + lines.append(f"- Phase: {repair_loop.get('phase')} {repair_loop.get('phase_title', '')}") + lines.append(f"- Failure class: {repair_loop.get('failure_class')}") + lines.append(f"- Recommended focus: {repair_loop.get('recommended_focus')}") + if repair_loop.get("summary"): + lines.append(f"- Bridge summary: {repair_loop.get('summary')}") + return "\n".join(lines) + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "MONITORING_ALERT_ROLLOUT_OUTPUT_JSON", False) + mode = str(env.get("MONITORING_ALERT_ROLLOUT_MODE", "plan")).strip().lower() + if mode == "plan": + payload = build_rollout_plan() + elif mode == "status": + payload = read_status() + elif mode == "start": + payload = start_rollout() + elif mode == "advance": + payload = advance_rollout() + elif mode == "block": + blocker = str(env.get("MONITORING_ALERT_BLOCKER", "manual_blocker")).strip() or "manual_blocker" + note = str(env.get("MONITORING_ALERT_BLOCKER_NOTE", "")).strip() + payload = block_rollout(blocker=blocker, note=note) + elif mode == "complete": + payload = complete_rollout() + elif mode == "run-until-blocked": + payload = run_until_blocked(env=env) + elif mode == "repair-loop": + payload = run_repair_loop(env=env) + elif mode == "repair-bridge": + payload = run_repair_bridge(env=env) + else: + raise SystemExit(f"Unsupported MONITORING_ALERT_ROLLOUT_MODE: {mode}") + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(render_text(payload)) + return 0 if payload.get("state") not in {"blocked", "failed"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council.py b/scripts/run_research_council.py new file mode 100644 index 0000000..2962ea0 --- /dev/null +++ b/scripts/run_research_council.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from sqlalchemy.exc import SQLAlchemyError + +from src.services.research_council import ResearchCouncilService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + timeframe = env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m") + model_code = env.get("RESEARCH_COUNCIL_MODEL_CODE", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE") + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + checkpoint_status = _get_bool(env, "RESEARCH_COUNCIL_CHECKPOINT_STATUS", False) + try: + service = ResearchCouncilService() + if checkpoint_status: + payload = service.get_resume_state(instrument_id=instrument_id) + else: + payload = service.build_and_persist( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + ) + except SQLAlchemyError as exc: + failure = { + "passed": False, + "instrument_id": instrument_id, + "timeframe": timeframe, + "model_code": model_code, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + _print_payload(failure, output_json=output_json) + return 2 + payload["passed"] = True + _print_payload(payload, output_json=output_json) + return 0 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + if payload.get("can_resume") is not None: + print(f"Research council resume state for instrument {payload.get('instrument_id')}") + print(f"Can resume: {payload.get('can_resume')}") + return + print(f"Research council run completed for instrument {payload.get('instrument_id')}") + print(f"Run id: {payload.get('id', 'n/a')}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_arena.py b/scripts/run_research_council_arena.py new file mode 100644 index 0000000..305b76f --- /dev/null +++ b/scripts/run_research_council_arena.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import ResearchCouncilService +from src.services.trust import ModelTrustService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + timeframe = env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m") + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + model_codes = _resolve_model_codes(env=env, instrument_id=instrument_id) + service = ResearchCouncilService() + rows = [] + for model_code in model_codes: + preview = service.build_preview(instrument_id=instrument_id, timeframe=timeframe, model_code=model_code) + decision = dict(preview.get("decision") or {}) + context = dict(preview.get("context") or {}) + rows.append( + { + "model_code": model_code, + "overall_stance": decision.get("overall_stance"), + "committee_confidence": decision.get("committee_confidence"), + "follow_up_check_count": len(list(decision.get("follow_up_checks") or [])), + "disagreement_count": len(list(decision.get("disagreement_points") or [])), + "lesson_count": len(list(context.get("compact_lessons") or [])), + } + ) + payload = { + "instrument_id": instrument_id, + "timeframe": timeframe, + "rows": rows, + "passed": bool(rows), + } + _print_payload(payload, output_json=output_json) + return 0 if rows else 2 + + +def _resolve_model_codes(*, env, instrument_id: int) -> list[str]: + explicit = [item.strip() for item in str(env.get("RESEARCH_COUNCIL_ARENA_MODEL_CODES") or "").split(",") if item.strip()] + if explicit: + return explicit + trust_summary = ModelTrustService().build_summary(instrument_id=instrument_id) + models = [str(item.get("model_code") or "").strip() for item in list(trust_summary.get("models") or []) if str(item.get("model_code") or "").strip()] + if models: + return models[:3] + return ["OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"] + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Research council arena for instrument {payload.get('instrument_id')}") + print(f"Rows: {len(payload.get('rows') or [])}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_backfill_lessons.py b/scripts/run_research_council_backfill_lessons.py new file mode 100644 index 0000000..bb16990 --- /dev/null +++ b/scripts/run_research_council_backfill_lessons.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import ResearchCouncilService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + limit = int(env.get("RESEARCH_COUNCIL_LESSON_LIMIT", "100")) + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + payload = ResearchCouncilService().backfill_lessons(instrument_id=instrument_id, limit=limit) + payload["passed"] = True + _print_payload(payload, output_json=output_json) + return 0 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + totals = dict(payload.get("totals") or {}) + print(f"Research council lessons backfill completed for instrument {payload.get('instrument_id')}") + print( + f"Processed={totals.get('requested', 0)} created={totals.get('created', 0)} updated={totals.get('updated', 0)}" + ) + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_eval.py b/scripts/run_research_council_eval.py new file mode 100644 index 0000000..0577267 --- /dev/null +++ b/scripts/run_research_council_eval.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import ResearchCouncilService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + limit = int(env.get("RESEARCH_COUNCIL_EVAL_LIMIT", "20")) + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + runs = ResearchCouncilService().get_recent_runs(instrument_id=instrument_id, limit=limit) + payload = build_eval_summary(instrument_id=instrument_id, runs=runs) + payload["passed"] = bool(payload.get("run_count")) + _print_payload(payload, output_json=output_json) + return 0 if payload["passed"] else 2 + + +def build_eval_summary(*, instrument_id: int, runs: list[dict]) -> dict: + if not runs: + return { + "instrument_id": instrument_id, + "run_count": 0, + "error": "no_research_council_runs", + } + baseline_counts = [] + follow_up_counts = [] + question_counts = [] + disagreement_count = 0 + lesson_usage_count = 0 + session_scores: dict[str, list[float]] = {} + setup_scores: dict[str, list[float]] = {} + + for row in runs: + payload = dict(row.get("payload") or {}) + context = dict(payload.get("context") or {}) + decision = dict(payload.get("decision") or {}) + candidate = dict(context.get("latest_candidate") or {}) + trust_policy = dict(context.get("trust_policy") or {}) + risk_result = dict(context.get("risk_result") or {}) + baseline = len(list(candidate.get("missing_confirmations") or [])) + len(list(trust_policy.get("blocking_reasons") or [])) + len(list(risk_result.get("reasons") or [])) + baseline_counts.append(baseline) + follow_up_counts.append(len(list(decision.get("follow_up_checks") or []))) + question_counts.append(len(list(decision.get("operator_questions") or []))) + disagreement_count += 1 if list(decision.get("disagreement_points") or []) else 0 + lessons = list(context.get("compact_lessons") or []) + if lessons: + lesson_usage_count += 1 + session_code = str(candidate.get("session_code") or "UNKNOWN") + setup_type = str(candidate.get("model_code") or "UNKNOWN") + session_scores.setdefault(session_code, []).append(float(decision.get("committee_confidence") or 0.0)) + setup_scores.setdefault(setup_type, []).append(float(decision.get("committee_confidence") or 0.0)) + + return { + "instrument_id": instrument_id, + "run_count": len(runs), + "avg_baseline_issue_count": _avg(baseline_counts), + "avg_follow_up_check_count": _avg(follow_up_counts), + "avg_operator_question_count": _avg(question_counts), + "guidance_delta": round(_avg(follow_up_counts) - _avg(baseline_counts), 4), + "disagreement_hint_rate": round(disagreement_count / len(runs), 4), + "lesson_usage_rate": round(lesson_usage_count / len(runs), 4), + "session_confidence": {key: round(_avg(values), 4) for key, values in session_scores.items()}, + "setup_confidence": {key: round(_avg(values), 4) for key, values in setup_scores.items()}, + } + + +def _avg(values: list[float]) -> float: + return round(sum(values) / len(values), 4) if values else 0.0 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Research council eval for instrument {payload.get('instrument_id')}") + print(f"Runs: {payload.get('run_count')}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_governance_tick.py b/scripts/run_research_council_governance_tick.py new file mode 100644 index 0000000..f6fba5c --- /dev/null +++ b/scripts/run_research_council_governance_tick.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import CouncilGovernanceTickService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + limit = int(env.get("RESEARCH_COUNCIL_REVIEW_LIMIT", "5")) + stale_after_hours = float(env.get("RESEARCH_COUNCIL_STALE_AFTER_HOURS", "24")) + auto_run_stale_cycle = _get_bool(env, "RESEARCH_COUNCIL_GOVERNANCE_AUTO_RUN_STALE", False) + force_cycle = _get_bool(env, "RESEARCH_COUNCIL_GOVERNANCE_FORCE_CYCLE", False) + timeframe = env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m") + model_code = env.get("RESEARCH_COUNCIL_MODEL_CODE", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE") + replay_file = str(env.get("RESEARCH_COUNCIL_REPLAY_FILE") or "").strip() or None + eval_limit = int(env.get("RESEARCH_COUNCIL_EVAL_LIMIT", "20")) + lesson_limit = int(env.get("RESEARCH_COUNCIL_LESSON_LIMIT", "100")) + cycle_key = str(env.get("RESEARCH_COUNCIL_OPTIMIZATION_CYCLE_KEY") or "").strip() or None + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + payload = CouncilGovernanceTickService().run_tick( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + auto_run_stale_cycle=auto_run_stale_cycle, + force_cycle=force_cycle, + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + eval_limit=eval_limit, + lesson_limit=lesson_limit, + cycle_key=cycle_key, + ) + _print_payload(payload, output_json=output_json) + return 0 if payload.get("passed") else 2 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Research council governance tick {payload.get('status')} for instrument {payload.get('instrument_id')}") + print(f"Health: {payload.get('effective_health_state')}") + print(f"Actions: {', '.join(payload.get('recommended_actions') or [])}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_optimization_loop.py b/scripts/run_research_council_optimization_loop.py new file mode 100644 index 0000000..9f90ea5 --- /dev/null +++ b/scripts/run_research_council_optimization_loop.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import CouncilOptimizationService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + timeframe = env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m") + model_code = env.get("RESEARCH_COUNCIL_MODEL_CODE", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE") + replay_file = str(env.get("RESEARCH_COUNCIL_REPLAY_FILE") or "").strip() or None + eval_limit = int(env.get("RESEARCH_COUNCIL_EVAL_LIMIT", "20")) + lesson_limit = int(env.get("RESEARCH_COUNCIL_LESSON_LIMIT", "100")) + cycle_key = str(env.get("RESEARCH_COUNCIL_OPTIMIZATION_CYCLE_KEY") or "").strip() or None + arena_model_codes = [ + item.strip() + for item in str(env.get("RESEARCH_COUNCIL_ARENA_MODEL_CODES") or "").split(",") + if item.strip() + ] + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + + payload = CouncilOptimizationService().run_cycle( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + eval_limit=eval_limit, + lesson_limit=lesson_limit, + arena_model_codes=arena_model_codes or None, + cycle_key=cycle_key, + ) + _print_payload(payload, output_json=output_json) + return 0 if payload.get("passed") else 2 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Research council optimization cycle {payload.get('status')} for instrument {payload.get('instrument_id')}") + print(f"Cycle: {payload.get('cycle_key')}") + print(f"Next actions: {', '.join(payload.get('next_actions') or [])}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_optimization_review.py b/scripts/run_research_council_optimization_review.py new file mode 100644 index 0000000..4e1ad4e --- /dev/null +++ b/scripts/run_research_council_optimization_review.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.research_council import CouncilOptimizationReviewService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + limit = int(env.get("RESEARCH_COUNCIL_REVIEW_LIMIT", "5")) + stale_after_hours = float(env.get("RESEARCH_COUNCIL_STALE_AFTER_HOURS", "24")) + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + payload = CouncilOptimizationReviewService().build_review( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + ) + payload["passed"] = bool(payload.get("present")) and payload.get("health_state") != "missing" + _print_payload(payload, output_json=output_json) + return 0 if payload["passed"] else 2 + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Research council optimization review for instrument {payload.get('instrument_id')}") + print(f"Health: {payload.get('health_state')}") + print(f"Summary: {payload.get('operator_summary')}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_research_council_replay.py b/scripts/run_research_council_replay.py new file mode 100644 index 0000000..b85d8b0 --- /dev/null +++ b/scripts/run_research_council_replay.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.backtest import detect_confirmed_swings_for_replay, iter_historical_replay, load_replay_candles +from src.services.research_council import ResearchCouncilService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")) + timeframe = env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m") + model_code = env.get("RESEARCH_COUNCIL_MODEL_CODE", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE") + replay_file = str(env.get("RESEARCH_COUNCIL_REPLAY_FILE") or "").strip() + output_json = _get_bool(env, "RESEARCH_COUNCIL_OUTPUT_JSON", False) + service = ResearchCouncilService() + payload = { + "instrument_id": instrument_id, + "timeframe": timeframe, + "model_code": model_code, + "resume_state": service.get_resume_state(instrument_id=instrument_id), + "preview": service.build_preview(instrument_id=instrument_id, timeframe=timeframe, model_code=model_code), + "replay": None, + } + if replay_file: + payload["replay"] = build_replay_summary(replay_file) + payload["passed"] = True + _print_payload(payload, output_json=output_json) + return 0 + + +def build_replay_summary(path: str) -> dict: + candles = load_replay_candles(path) + frames = list(iter_historical_replay(candles)) + swings = detect_confirmed_swings_for_replay(candles) + return { + "path": path, + "candle_count": len(candles), + "frame_count": len(frames), + "confirmed_swing_count": len(swings), + "first_ts": candles[0].get("ts_open") if candles else None, + "last_ts": candles[-1].get("ts_close") if candles else None, + } + + +def _print_payload(payload: dict, *, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + return + preview = dict(payload.get("preview") or {}) + decision = dict(preview.get("decision") or {}) + print(f"Research council replay summary for instrument {payload.get('instrument_id')}") + print(f"Overall stance: {decision.get('overall_stance')}") + + +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"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_until_blocked.py b/scripts/run_until_blocked.py index 29d6f33..886bc98 100644 --- a/scripts/run_until_blocked.py +++ b/scripts/run_until_blocked.py @@ -15,10 +15,20 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from scripts.run_trading_delivery_rehearsal import run_trading_delivery_rehearsal +from scripts import run_analysis_decision_monitor +from src.services.research_council import CouncilGovernanceTickService SESSION_PATH = ROOT / "SESSION.md" TASKS_PATH = ROOT / "TASKS.md" RUNNER_STATUS_PATH = ROOT / ".claude" / "automation_status.json" +ANALYSIS_DECISION_MONITOR_HISTORY_PATH = ROOT / ".claude" / "analysis_decision_monitor_history.jsonl" +ANALYSIS_DECISION_MONITOR_TREND_PATH = ROOT / ".claude" / "analysis_decision_monitor_trend.json" +ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH = ROOT / ".claude" / "analysis_decision_monitor_quality_gate.json" +ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH = ROOT / ".claude" / "analysis_decision_monitor_maturity_rollup.json" +ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH = ROOT / ".claude" / "analysis_decision_monitor_remote_drift_check.json" +ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH = ROOT / ".claude" / "analysis_decision_support_quality_review.json" +ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH = ROOT / ".claude" / "analysis_decision_monitor_p7_slice_selector.json" +ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT = 3 RUNNER_RESUME_WATCH_PATHS = [ ROOT / "src", ROOT / "scripts", @@ -79,6 +89,58 @@ STAGE_GOALS = { ], } DEFAULT_VALIDATION_COMMANDS = ["npm run lint", "npm run build", "npm run visual:snapshots"] +AUTOMATION_EXECUTION_MARKERS = { + "automation_flow_p0_p6_review_closed", + "automation_execution_layer_p0_governance_gate_closed", + "Automation Flow Ready For Execution-Layer Development", + "Automation Execution-Layer Development", +} +ANALYSIS_DECISION_MONITOR_MARKERS = { + "analysis_decision_monitor_runner_deployed", + "analysis_decision_monitor_runner_automation_wired", + "run_analysis_decision_monitor_periodically_without_fill_gate", + "Restricted Decision-Support Soft Rollout", +} +ANALYSIS_DECISION_MONITOR_STAGE = "analysis_decision_monitor:periodic" +ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE = "automation_execution_layer_p7_monitor_maturity_rollup" +ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE = "automation_execution_layer_p7_remote_drift_check" +ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE = "automation_execution_layer_p7_alert_decision_support_quality_review" +ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE = "automation_execution_layer_p7_bounded_automation_slice_selector" +ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE = "repair_analysis_decision_monitor_remote_drift" +ANALYSIS_DECISION_SUPPORT_QUALITY_IMPROVE_SLICE = "improve_analysis_decision_support_quality" +ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE = "continue_periodic_analysis_decision_monitoring_with_maturity_rollup" +AUTOMATION_EXECUTION_GATE_STAGE = "automation_execution_layer:P6_governance_tick" +AUTOMATION_EXECUTION_GATE_CLOSED_STATE = "automation_execution_layer_p0_governance_gate_closed" +AUTOMATION_EXECUTION_NEXT_SLICE = "automation_execution_layer_p1_anchor_slice_selector" +AUTOMATION_REQUIRED_DOC_PATHS = ( + ROOT / "docs" / "AI_ICT_全自动开发执行流程_v1.md", + ROOT / "docs" / "AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md", +) +AUTOMATION_EXECUTION_SLICE_SEQUENCE = [ + ("automation_execution_layer_p1_anchor_slice_selector", "Anchor-driven smallest-slice selector"), + ("automation_execution_layer_p2_validation_quality_router", "Focused validation and quality-gate router"), + ("automation_execution_layer_p3_deploy_smoke_router", "Deploy and smoke routing policy"), + ("automation_execution_layer_p4_state_writeback_renderer", "State writeback renderer"), +] +AUTOMATION_EXECUTION_CLOSED_STATUS_SEQUENCE = [ + ("automation_execution_layer_p4_state_writeback_renderer", "automation_execution_layer_p4_state_writeback_renderer_closed", "Automation Execution-Layer P4 State Writeback Renderer"), + ("automation_execution_layer_p3_deploy_smoke_router", "automation_execution_layer_p3_deploy_smoke_router_closed", "Automation Execution-Layer P3 Deploy Smoke Router"), + ("automation_execution_layer_p2_validation_quality_router", "automation_execution_layer_p2_validation_quality_router_closed", "Automation Execution-Layer P2 Validation Quality Router"), + ("automation_execution_layer_p1_anchor_slice_selector", "automation_execution_layer_p1_anchor_slice_selector_closed", "Automation Execution-Layer P1 Anchor Slice Selector"), + ("automation_execution_layer_p0_governance_gate", AUTOMATION_EXECUTION_GATE_CLOSED_STATE, "Automation Execution-Layer P0 Governance Gate"), +] +PROJECT_PYTHON = r"D:\ai-exchange\.venv\Scripts\python.exe" +DEPLOY_SMOKE_FILE_PREFIXES = ( + "migrations/", + "src/api/", + "src/services/runtime/", + "src/services/market_state/", + "src/services/readiness/", + "src/services/decision/", + "src/services/operator/", + "src/services/trust/", + "src/web/", +) BLOCKED_MARKERS = [ "缺凭据", "权限不足", @@ -95,8 +157,23 @@ def run_until_blocked(env=None, runner=None, sleeper=None) -> dict: 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) + session_text = SESSION_PATH.read_text(encoding="utf-8") + tasks_text = TASKS_PATH.read_text(encoding="utf-8") + if should_use_analysis_decision_monitor_p7_selector(session_text=session_text, tasks_text=tasks_text): + return run_analysis_decision_monitor_p7_selector(env=env, output_json=output_json) + if should_use_analysis_decision_support_quality_review(session_text=session_text, tasks_text=tasks_text): + return run_analysis_decision_support_quality_review(env=env, output_json=output_json) + if should_use_analysis_decision_monitor_remote_drift_check(session_text=session_text, tasks_text=tasks_text): + return run_analysis_decision_monitor_remote_drift_check(env=env, output_json=output_json) + if should_use_analysis_decision_monitor_maturity_rollup(session_text=session_text, tasks_text=tasks_text): + return run_analysis_decision_monitor_maturity_rollup(env=env, output_json=output_json) + if should_use_analysis_decision_monitor(session_text=session_text, tasks_text=tasks_text): + return run_analysis_decision_monitor_gate(env=env, output_json=output_json) + if should_use_automation_execution_layer(session_text=session_text, tasks_text=tasks_text): + return run_automation_execution_layer_gate(env=env, output_json=output_json) + + completed_stages = parse_done_stages(tasks_text) + current_stage = detect_current_stage(session_text, completed_stages) idle_rounds = 0 rounds = [] blocked_reason = None @@ -223,6 +300,2906 @@ def run_until_blocked(env=None, runner=None, sleeper=None) -> dict: return summary +def should_use_automation_execution_layer(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return any(marker in anchor_text for marker in AUTOMATION_EXECUTION_MARKERS) + + +def should_use_analysis_decision_monitor(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return any(marker in anchor_text for marker in ANALYSIS_DECISION_MONITOR_MARKERS) + + +def should_use_analysis_decision_monitor_maturity_rollup(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return _anchor_has_active_slice(anchor_text=anchor_text, slice_name=ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE) + + +def should_use_analysis_decision_monitor_remote_drift_check(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return ( + _anchor_has_active_slice( + anchor_text=anchor_text, + slice_name=ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE, + ) + or _anchor_has_active_slice( + anchor_text=anchor_text, + slice_name=ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE, + ) + ) + + +def should_use_analysis_decision_support_quality_review(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return _anchor_has_active_slice( + anchor_text=anchor_text, + slice_name=ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + ) + + +def should_use_analysis_decision_monitor_p7_selector(*, session_text: str, tasks_text: str) -> bool: + anchor_text = f"{session_text}\n{tasks_text}" + return _anchor_has_active_slice( + anchor_text=anchor_text, + slice_name=ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, + ) + + +def _anchor_has_active_slice(*, anchor_text: str, slice_name: str) -> bool: + active_markers = [ + f"### {slice_name}", + f"- [ ] `{slice_name}`", + f"- [ ] {slice_name}", + f"- repair_next_action: {slice_name}", + f"repair_next_action: {slice_name}", + f"- next_slice: {slice_name}", + f"next_slice: {slice_name}", + ] + return any(marker in anchor_text for marker in active_markers) + + +def run_analysis_decision_support_quality_review(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "analysis_decision_support_quality_review", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + base_url = str(env.get("ANALYSIS_DECISION_MONITOR_BASE_URL") or run_analysis_decision_monitor.DEFAULT_BASE_URL) + instrument_id = int(env.get("ANALYSIS_DECISION_MONITOR_INSTRUMENT_ID", "1")) + timeframe = str(env.get("ANALYSIS_DECISION_MONITOR_TIMEFRAME") or "1m") + timeout_seconds = float( + env.get( + "ANALYSIS_DECISION_MONITOR_TIMEOUT_SECONDS", + str(run_analysis_decision_monitor.DEFAULT_TIMEOUT_SECONDS), + ) + ) + try: + monitor_payload = run_analysis_decision_monitor.run_monitor( + base_url=base_url, + instrument_id=instrument_id, + timeframe=timeframe, + timeout_seconds=timeout_seconds, + run_p6_tick=True, + ) + except Exception as exc: + monitor_payload = { + "passed": False, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "instrument_id": instrument_id, + "timeframe": timeframe, + "phase": "analysis_decision_only", + "blockers": ["analysis_decision_support_quality_review_exception"], + "warnings": [], + "error_type": exc.__class__.__name__, + "error": str(exc), + "observed": {}, + "checks": [], + } + quality_review = build_analysis_decision_support_quality_review(monitor_payload=monitor_payload) + ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.write_text( + json.dumps(quality_review, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": quality_review.get("status"), + "mode": "analysis_decision_support_quality_review", + "current_stage": ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + "blocked_reason": quality_review.get("blocked_reason"), + "next_slice": quality_review.get("next_slice"), + "quality_review_path": _relative_path_label(ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH), + "quality_review": quality_review, + "no_new_phase_created": True, + "guards_unchanged": list(quality_review.get("guards_unchanged") or []), + "state_writeback_plan": { + "files_to_write": [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + _relative_path_label(ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH), + ], + "state": quality_review.get("state"), + "current_phase": "Restricted Decision-Support Soft Rollout", + "next_slice": quality_review.get("next_slice"), + "status": quality_review.get("status"), + "blocked_reason": quality_review.get("blocked_reason"), + "no_new_phase_created": True, + "guards_unchanged": list(quality_review.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "analysis_decision_support_quality_review_state_writeback", + }, + } + write_analysis_decision_support_quality_review_anchor_files(summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "analysis_decision_support_quality_review", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "next_slice": summary.get("next_slice"), + "quality_review_path": summary.get("quality_review_path"), + "rounds_completed": 1, + "no_new_phase_created": True, + } + ) + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"P7 output quality review status: {summary['status']}") + print(f"Quality status: {quality_review.get('quality_status')}") + print(f"Next slice: {summary.get('next_slice')}") + if summary.get("blocked_reason"): + print(f"Blocked reason: {summary.get('blocked_reason')}") + return summary + + +def run_analysis_decision_monitor_p7_selector(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "analysis_decision_monitor_p7_selector", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + trend = load_or_build_analysis_decision_monitor_trend() + quality_gate = load_or_build_analysis_decision_monitor_quality_gate(trend=trend) + maturity_rollup = load_analysis_decision_monitor_maturity_rollup() + remote_drift_check = load_analysis_decision_monitor_remote_drift_check() + quality_review = load_analysis_decision_support_quality_review() + selector = build_analysis_decision_monitor_p7_slice_selector( + trend=trend, + quality_gate=quality_gate, + maturity_rollup=maturity_rollup, + remote_drift_check=remote_drift_check, + quality_review=quality_review, + ) + ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH.write_text( + json.dumps(selector, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": selector.get("status"), + "mode": "analysis_decision_monitor_p7_selector", + "current_stage": ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, + "blocked_reason": selector.get("blocked_reason"), + "next_slice": selector.get("next_slice"), + "selector_path": _relative_path_label(ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH), + "selector": selector, + "no_new_phase_created": True, + "guards_unchanged": list(selector.get("guards_unchanged") or []), + "state_writeback_plan": { + "files_to_write": [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + _relative_path_label(ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH), + ], + "state": selector.get("state"), + "current_phase": "Restricted Decision-Support Soft Rollout", + "next_slice": selector.get("next_slice"), + "status": selector.get("status"), + "blocked_reason": selector.get("blocked_reason"), + "no_new_phase_created": True, + "guards_unchanged": list(selector.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "analysis_decision_monitor_p7_selector_state_writeback", + }, + } + write_analysis_decision_monitor_p7_selector_anchor_files(summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "analysis_decision_monitor_p7_selector", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "next_slice": summary.get("next_slice"), + "selector_path": summary.get("selector_path"), + "rounds_completed": 1, + "no_new_phase_created": True, + } + ) + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"P7 bounded selector status: {summary['status']}") + print(f"Selected next slice: {summary.get('next_slice')}") + if summary.get("blocked_reason"): + print(f"Blocked reason: {summary.get('blocked_reason')}") + return summary + + +def run_analysis_decision_monitor_remote_drift_check(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "analysis_decision_monitor_remote_drift_check", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + local_rollup = load_analysis_decision_monitor_maturity_rollup() + try: + remote_state = fetch_remote_analysis_decision_monitor_state(env=env) + except Exception as exc: + remote_state = { + "status": "unavailable", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + drift_check = build_analysis_decision_monitor_remote_drift_check( + local_rollup=local_rollup, + remote_state=remote_state, + ) + ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.write_text( + json.dumps(drift_check, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": drift_check.get("status"), + "mode": "analysis_decision_monitor_remote_drift_check", + "current_stage": ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE, + "blocked_reason": drift_check.get("blocked_reason"), + "next_slice": drift_check.get("next_slice"), + "drift_check_path": _relative_path_label(ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH), + "drift_check": drift_check, + "no_new_phase_created": True, + "guards_unchanged": list(drift_check.get("guards_unchanged") or []), + "state_writeback_plan": { + "files_to_write": [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + _relative_path_label(ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH), + ], + "state": drift_check.get("state"), + "current_phase": "Restricted Decision-Support Soft Rollout", + "next_slice": drift_check.get("next_slice"), + "status": drift_check.get("status"), + "blocked_reason": drift_check.get("blocked_reason"), + "no_new_phase_created": True, + "guards_unchanged": list(drift_check.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "analysis_decision_monitor_remote_drift_check_state_writeback", + }, + } + write_analysis_decision_monitor_remote_drift_anchor_files(summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "analysis_decision_monitor_remote_drift_check", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "next_slice": summary.get("next_slice"), + "drift_check_path": summary.get("drift_check_path"), + "rounds_completed": 1, + "no_new_phase_created": True, + } + ) + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"P7 remote drift check status: {summary['status']}") + print(f"Drift status: {drift_check.get('drift_status')}") + print(f"Next slice: {summary.get('next_slice')}") + if summary.get("blocked_reason"): + print(f"Blocked reason: {summary.get('blocked_reason')}") + return summary + + +def run_analysis_decision_monitor_maturity_rollup(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "analysis_decision_monitor_maturity_rollup", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + trend = load_or_build_analysis_decision_monitor_trend() + quality_gate = load_or_build_analysis_decision_monitor_quality_gate(trend=trend) + rollup = build_analysis_decision_monitor_maturity_rollup( + trend=trend, + quality_gate=quality_gate, + ) + ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.write_text( + json.dumps(rollup, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": rollup.get("status"), + "mode": "analysis_decision_monitor_maturity_rollup", + "current_stage": ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "blocked_reason": rollup.get("blocked_reason"), + "next_slice": rollup.get("next_slice"), + "rollup_path": _relative_path_label(ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH), + "rollup": rollup, + "no_new_phase_created": True, + "guards_unchanged": list(rollup.get("guards_unchanged") or []), + "state_writeback_plan": { + "files_to_write": [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + _relative_path_label(ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH), + ], + "state": rollup.get("state"), + "current_phase": "Restricted Decision-Support Soft Rollout", + "next_slice": rollup.get("next_slice"), + "status": rollup.get("status"), + "blocked_reason": rollup.get("blocked_reason"), + "no_new_phase_created": True, + "guards_unchanged": list(rollup.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "analysis_decision_monitor_maturity_rollup_state_writeback", + }, + } + write_analysis_decision_monitor_maturity_rollup_anchor_files(summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "analysis_decision_monitor_maturity_rollup", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "next_slice": summary.get("next_slice"), + "rollup_path": summary.get("rollup_path"), + "rounds_completed": 1, + "no_new_phase_created": True, + } + ) + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"P7 monitor maturity rollup status: {summary['status']}") + print(f"Maturity status: {rollup.get('maturity_status')}") + print(f"Next slice: {summary.get('next_slice')}") + if summary.get("blocked_reason"): + print(f"Blocked reason: {summary.get('blocked_reason')}") + return summary + + +def run_analysis_decision_monitor_gate(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "analysis_decision_monitor", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": ANALYSIS_DECISION_MONITOR_STAGE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + + base_url = str(env.get("ANALYSIS_DECISION_MONITOR_BASE_URL") or run_analysis_decision_monitor.DEFAULT_BASE_URL) + instrument_id = int(env.get("ANALYSIS_DECISION_MONITOR_INSTRUMENT_ID", "1")) + timeframe = str(env.get("ANALYSIS_DECISION_MONITOR_TIMEFRAME") or "1m") + timeout_seconds = float( + env.get( + "ANALYSIS_DECISION_MONITOR_TIMEOUT_SECONDS", + str(run_analysis_decision_monitor.DEFAULT_TIMEOUT_SECONDS), + ) + ) + run_p6_tick = _get_bool(env, "ANALYSIS_DECISION_MONITOR_RUN_P6_TICK", True) + + try: + monitor_payload = run_analysis_decision_monitor.run_monitor( + base_url=base_url, + instrument_id=instrument_id, + timeframe=timeframe, + timeout_seconds=timeout_seconds, + run_p6_tick=run_p6_tick, + ) + except Exception as exc: + monitor_payload = { + "passed": False, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "instrument_id": instrument_id, + "timeframe": timeframe, + "phase": "analysis_decision_only", + "blockers": ["analysis_decision_monitor_exception"], + "warnings": [], + "error_type": exc.__class__.__name__, + "error": str(exc), + "observed": {}, + } + + summary = build_analysis_decision_monitor_summary( + started_ts=started_ts, + monitor_payload=monitor_payload, + ) + summary["next_slice"] = resolve_analysis_decision_monitor_next_slice(summary=summary) + summary["monitor_history"] = append_analysis_decision_monitor_history(summary=summary) + summary["state_writeback_plan"] = build_analysis_decision_monitor_writeback_plan(summary=summary) + write_analysis_decision_monitor_anchor_files(summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "analysis_decision_monitor", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "rounds_completed": len(summary.get("rounds") or []), + "monitor_passed": bool(monitor_payload.get("passed")), + "monitor_generated_ts": monitor_payload.get("generated_ts"), + "monitor_blockers": list(monitor_payload.get("blockers") or []), + "monitor_warnings": list(monitor_payload.get("warnings") or []), + "next_slice": summary.get("next_slice"), + "no_new_phase_created": True, + } + ) + + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Analysis/decision monitor status: {summary['status']}") + print(f"Monitor passed: {monitor_payload.get('passed')}") + print(f"Blockers: {', '.join(monitor_payload.get('blockers') or []) or 'none'}") + print(f"Warnings: {', '.join(monitor_payload.get('warnings') or []) or 'none'}") + print(f"Next slice: {summary.get('next_slice')}") + return summary + + +def build_analysis_decision_monitor_summary(*, started_ts: str, monitor_payload: dict) -> dict: + blockers = list(monitor_payload.get("blockers") or []) + warnings = list(monitor_payload.get("warnings") or []) + passed = bool(monitor_payload.get("passed")) and not blockers + blocked_reason = None if passed else f"analysis_decision_monitor_blockers:{','.join(blockers) or 'monitor_failed'}" + next_slice = "continue_periodic_analysis_decision_monitoring" if passed else "repair_analysis_decision_monitor_blockers" + status = "completed" if passed else "blocked" + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": status, + "mode": "analysis_decision_monitor", + "active_gate": "analysis_decision_only_restricted_decision_support_monitor", + "phase": monitor_payload.get("phase") or "analysis_decision_only", + "monitor_passed": passed, + "monitor_generated_ts": monitor_payload.get("generated_ts"), + "monitor_blockers": blockers, + "monitor_warnings": warnings, + "observed": dict(monitor_payload.get("observed") or {}), + "rounds": [ + { + "round": 1, + "stage": ANALYSIS_DECISION_MONITOR_STAGE, + "passed": passed, + "blockers": blockers, + "warnings": warnings, + } + ], + "current_stage": ANALYSIS_DECISION_MONITOR_STAGE, + "blocked_reason": blocked_reason, + "next_slice": next_slice, + "no_new_phase_created": True, + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + "monitor_payload": monitor_payload, + } + summary["state_writeback_plan"] = build_analysis_decision_monitor_writeback_plan(summary=summary) + return summary + + +def resolve_analysis_decision_monitor_next_slice(*, summary: dict) -> str: + if not summary.get("monitor_passed"): + return "repair_analysis_decision_monitor_blockers" + gate = preview_analysis_decision_monitor_quality_gate(summary=summary) + if gate.get("passed"): + return ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE + return "continue_periodic_analysis_decision_monitoring" + + +def build_analysis_decision_monitor_writeback_plan(*, summary: dict) -> dict: + files_to_write = [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + ] + history_path = (summary.get("monitor_history") or {}).get("path") + if history_path: + files_to_write.append(history_path) + trend_path = (summary.get("monitor_history") or {}).get("trend_path") + if trend_path: + files_to_write.append(trend_path) + quality_gate_path = (summary.get("monitor_history") or {}).get("quality_gate_path") + if quality_gate_path: + files_to_write.append(quality_gate_path) + return { + "files_to_write": files_to_write, + "state": "analysis_decision_monitor_runner_automation_wired", + "current_phase": "Restricted Decision-Support Soft Rollout", + "next_slice": summary.get("next_slice"), + "status": summary.get("status"), + "blocked_reason": summary.get("blocked_reason"), + "no_new_phase_created": bool(summary.get("no_new_phase_created")), + "guards_unchanged": list(summary.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "analysis_decision_monitor_state_writeback", + } + + +def append_analysis_decision_monitor_history(*, summary: dict) -> dict: + record = build_analysis_decision_monitor_history_record(summary=summary) + ANALYSIS_DECISION_MONITOR_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True) + with ANALYSIS_DECISION_MONITOR_HISTORY_PATH.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True, default=str)) + handle.write("\n") + records = read_analysis_decision_monitor_history_records(ANALYSIS_DECISION_MONITOR_HISTORY_PATH) + trend = build_analysis_decision_monitor_trend(records=records) + quality_gate = build_analysis_decision_monitor_quality_gate(trend=trend) + ANALYSIS_DECISION_MONITOR_TREND_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_MONITOR_TREND_PATH.write_text( + json.dumps(trend, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.write_text( + json.dumps(quality_gate, ensure_ascii=False, indent=2, sort_keys=True, default=str), + encoding="utf-8", + ) + return { + "path": _relative_path_label(ANALYSIS_DECISION_MONITOR_HISTORY_PATH), + "trend_path": _relative_path_label(ANALYSIS_DECISION_MONITOR_TREND_PATH), + "quality_gate_path": _relative_path_label(ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH), + "latest_recorded_ts": record["recorded_ts"], + "record_count": len(records), + "trend": trend, + "quality_gate": quality_gate, + } + + +def build_analysis_decision_monitor_history_record(*, summary: dict) -> dict: + return { + "recorded_ts": datetime.now(timezone.utc).isoformat(), + "monitor_generated_ts": summary.get("monitor_generated_ts"), + "status": summary.get("status"), + "monitor_passed": bool(summary.get("monitor_passed")), + "blockers": list(summary.get("monitor_blockers") or []), + "warnings": list(summary.get("monitor_warnings") or []), + "observed": dict(summary.get("observed") or {}), + "next_slice": summary.get("next_slice"), + "no_new_phase_created": bool(summary.get("no_new_phase_created")), + "guards_unchanged": list(summary.get("guards_unchanged") or []), + } + + +def preview_analysis_decision_monitor_quality_gate(*, summary: dict) -> dict: + records = read_analysis_decision_monitor_history_records(ANALYSIS_DECISION_MONITOR_HISTORY_PATH) + records.append(build_analysis_decision_monitor_history_record(summary=summary)) + trend = build_analysis_decision_monitor_trend(records=records) + return build_analysis_decision_monitor_quality_gate(trend=trend) + + +def load_or_build_analysis_decision_monitor_trend() -> dict: + if ANALYSIS_DECISION_MONITOR_TREND_PATH.exists(): + return json.loads(ANALYSIS_DECISION_MONITOR_TREND_PATH.read_text(encoding="utf-8")) + records = read_analysis_decision_monitor_history_records(ANALYSIS_DECISION_MONITOR_HISTORY_PATH) + return build_analysis_decision_monitor_trend(records=records) + + +def load_or_build_analysis_decision_monitor_quality_gate(*, trend: dict) -> dict: + if ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.exists(): + return json.loads(ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.read_text(encoding="utf-8")) + return build_analysis_decision_monitor_quality_gate(trend=trend) + + +def load_analysis_decision_monitor_maturity_rollup() -> dict: + if ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.exists(): + return json.loads(ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.read_text(encoding="utf-8")) + trend = load_or_build_analysis_decision_monitor_trend() + quality_gate = load_or_build_analysis_decision_monitor_quality_gate(trend=trend) + return build_analysis_decision_monitor_maturity_rollup(trend=trend, quality_gate=quality_gate) + + +def build_analysis_decision_monitor_maturity_rollup(*, trend: dict, quality_gate: dict) -> dict: + observed = dict(quality_gate.get("latest_observed") or trend.get("latest_observed") or {}) + gate_passed = bool(quality_gate.get("passed")) + failure_reasons = list(quality_gate.get("failure_reasons") or []) + blocked_reason = None if gate_passed else f"analysis_decision_monitor_maturity_not_ready:{','.join(failure_reasons) or 'quality_gate_not_passed'}" + next_slice = ( + ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE + if gate_passed + else quality_gate.get("next_slice") or trend.get("next_slice") or "continue_periodic_analysis_decision_monitoring" + ) + guards_unchanged = list( + quality_gate.get("guards_unchanged") + or trend.get("guards_unchanged") + or [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ] + ) + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "state": ( + "automation_execution_layer_p7_monitor_maturity_rollup_closed" + if gate_passed + else "automation_execution_layer_p7_monitor_maturity_rollup_blocked" + ), + "status": "completed" if gate_passed else "blocked", + "maturity_status": "mature" if gate_passed else "attention_required", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "next_slice": next_slice, + "blocked_reason": blocked_reason, + "local": { + "record_count": quality_gate.get("record_count") or trend.get("record_count"), + "consecutive_pass_count": quality_gate.get("consecutive_pass_count") or trend.get("consecutive_pass_count"), + "stable_sample_threshold": trend.get("stable_sample_threshold"), + "stable_monitoring_window": bool(quality_gate.get("stable_monitoring_window")), + "quality_gate_status": quality_gate.get("status"), + "quality_gate_passed": gate_passed, + "quality_gate_failure_reasons": failure_reasons, + "latest_monitor_generated_ts": quality_gate.get("latest_monitor_generated_ts") or trend.get("latest_monitor_generated_ts"), + "latest_blockers": list(trend.get("latest_blockers") or []), + "latest_warnings": list(trend.get("latest_warnings") or []), + }, + "latest_observed": observed, + "stable_window_conclusion": ( + "stable_monitoring_window_passed" + if gate_passed + else "stable_monitoring_window_not_ready" + ), + "current_allowed_lane": "restricted_decision_support_observe_only", + "prohibited_lanes": [ + "real_live_auto_trading", + "execution_allowed_enablement", + "real_live_ready_enablement", + "risk_governor_override", + "trust_bucket_change", + "operator_confirmation_bypass", + ], + "guards_unchanged": guards_unchanged, + "remote_drift_check_status": "pending", + "no_research_council_phase_created": True, + "notes": [ + "automation_execution_layer_p7_not_research_council_p7", + "analysis_decision_only_phase", + "okx_fills_zero_not_current_phase_blocker", + "real_trade_sample_deferred", + ], + } + + +def fetch_remote_analysis_decision_monitor_state(*, env: dict) -> dict: + password = str(env.get("AI_ICT_DEPLOY_PASSWORD") or "") + if not password: + raise RuntimeError("missing_remote_password: set AI_ICT_DEPLOY_PASSWORD") + from scripts.deploy_with_password import ( + DEFAULT_HOST, + DEFAULT_REMOTE_PATH, + DEFAULT_USER, + _sh_quote, + connect_ssh, + run_remote, + ) + + host = str(env.get("AI_ICT_DEPLOY_HOST") or DEFAULT_HOST) + user = str(env.get("AI_ICT_DEPLOY_USER") or DEFAULT_USER) + port = int(env.get("AI_ICT_DEPLOY_PORT") or "22") + remote_path = str(env.get("AI_ICT_DEPLOY_REMOTE_PATH") or DEFAULT_REMOTE_PATH) + client = connect_ssh(host=host, user=user, password=password, port=port) + try: + container_quality = json.loads( + run_remote( + client, + "docker exec -i ai-ict sh -lc 'cat .claude/analysis_decision_monitor_quality_gate.json'", + timeout=180, + ) + ) + container_trend = json.loads( + run_remote( + client, + "docker exec -i ai-ict sh -lc 'cat .claude/analysis_decision_monitor_trend.json'", + timeout=180, + ) + ) + host_quality = json.loads( + run_remote( + client, + "sh -lc " + + _sh_quote(f"cat {remote_path}/.claude/analysis_decision_monitor_quality_gate.json"), + timeout=180, + ) + ) + finally: + client.close() + return { + "status": "available", + "host": host, + "container_quality_gate": container_quality, + "container_trend": container_trend, + "host_quality_gate": host_quality, + } + + +def build_analysis_decision_monitor_remote_drift_check(*, local_rollup: dict, remote_state: dict) -> dict: + local = dict(local_rollup.get("local") or {}) + local_observed = dict(local_rollup.get("latest_observed") or {}) + container_quality = dict(remote_state.get("container_quality_gate") or {}) + host_quality = dict(remote_state.get("host_quality_gate") or {}) + container_trend = dict(remote_state.get("container_trend") or {}) + remote_observed = dict(container_quality.get("latest_observed") or container_trend.get("latest_observed") or {}) + failure_reasons = [] + if local_rollup.get("maturity_status") != "mature": + failure_reasons.append("local_maturity_not_mature") + if local.get("quality_gate_status") != "passed": + failure_reasons.append("local_quality_gate_not_passed") + if remote_state.get("status") != "available": + failure_reasons.append("remote_state_unavailable") + if container_quality.get("status") != "passed": + failure_reasons.append("remote_container_quality_gate_not_passed") + if host_quality.get("status") != "passed": + failure_reasons.append("remote_host_quality_gate_not_passed") + if not bool(container_quality.get("stable_monitoring_window")): + failure_reasons.append("remote_stable_window_not_passed") + remote_consecutive = container_quality.get("consecutive_pass_count") or container_trend.get("consecutive_pass_count") + remote_threshold = container_trend.get("stable_sample_threshold") or ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT + if int(remote_consecutive or 0) < int(remote_threshold or ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT): + failure_reasons.append("remote_consecutive_passes_below_threshold") + compare_fields = [ + "broker_status", + "runtime_health", + "market_gap", + "decision_action", + "decision_operator_mode", + "real_trade_sample_gate_status", + "p6_governance_tick_status", + ] + observed_diffs = {} + for field in compare_fields: + if local_observed.get(field) != remote_observed.get(field): + observed_diffs[field] = { + "local": local_observed.get(field), + "remote": remote_observed.get(field), + } + if observed_diffs: + failure_reasons.append("local_remote_observed_fields_drifted") + local_guards = set(local_rollup.get("guards_unchanged") or []) + remote_guards = set(container_quality.get("guards_unchanged") or []) + if local_guards and remote_guards and local_guards != remote_guards: + failure_reasons.append("guard_set_drifted") + passed = not failure_reasons + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "state": ( + "automation_execution_layer_p7_remote_drift_check_closed" + if passed + else "automation_execution_layer_p7_remote_drift_check_blocked" + ), + "status": "completed" if passed else "blocked", + "drift_status": "clear" if passed else "drift_detected", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE, + "next_slice": ( + ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE + if passed + else ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE + ), + "blocked_reason": ( + None + if passed + else f"analysis_decision_monitor_remote_drift:{','.join(failure_reasons)}" + ), + "failure_reasons": failure_reasons, + "local": { + "maturity_status": local_rollup.get("maturity_status"), + "quality_gate_status": local.get("quality_gate_status"), + "record_count": local.get("record_count"), + "consecutive_pass_count": local.get("consecutive_pass_count"), + "stable_monitoring_window": local.get("stable_monitoring_window"), + }, + "remote": { + "status": remote_state.get("status"), + "host": remote_state.get("host"), + "container_quality_gate_status": container_quality.get("status"), + "container_record_count": container_quality.get("record_count"), + "container_consecutive_pass_count": container_quality.get("consecutive_pass_count"), + "container_stable_monitoring_window": container_quality.get("stable_monitoring_window"), + "host_quality_gate_status": host_quality.get("status"), + "host_record_count": host_quality.get("record_count"), + "host_consecutive_pass_count": host_quality.get("consecutive_pass_count"), + "host_stable_monitoring_window": host_quality.get("stable_monitoring_window"), + "error_type": remote_state.get("error_type"), + "error": remote_state.get("error"), + }, + "observed_diffs": observed_diffs, + "latest_observed": { + "local": local_observed, + "remote": remote_observed, + }, + "guards_unchanged": list(local_rollup.get("guards_unchanged") or container_quality.get("guards_unchanged") or []), + "no_research_council_phase_created": True, + "notes": [ + "automation_execution_layer_p7_not_research_council_p7", + "remote_drift_check_only_reads_monitor_state", + "okx_fills_zero_not_current_phase_blocker", + ], + } + + +def load_analysis_decision_monitor_remote_drift_check() -> dict: + if ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.exists(): + return json.loads(ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.read_text(encoding="utf-8")) + local_rollup = load_analysis_decision_monitor_maturity_rollup() + remote_state = { + "status": "unavailable", + "error_type": "artifact_missing", + "error": "analysis_decision_monitor_remote_drift_check_missing", + "container_quality_gate": {}, + "container_trend": {}, + "host_quality_gate": {}, + } + return build_analysis_decision_monitor_remote_drift_check(local_rollup=local_rollup, remote_state=remote_state) + + +def load_analysis_decision_support_quality_review() -> dict: + if ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.exists(): + return json.loads(ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.read_text(encoding="utf-8")) + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "state": "automation_execution_layer_p7_alert_decision_support_quality_review_blocked", + "status": "blocked", + "quality_status": "needs_attention", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + "next_slice": ANALYSIS_DECISION_SUPPORT_QUALITY_IMPROVE_SLICE, + "blocked_reason": "analysis_decision_support_quality:quality_review_artifact_missing", + "failure_reasons": ["quality_review_artifact_missing"], + "required_check_codes": [ + "decision_restricted_support", + "decision_observe_only", + "decision_execution_not_allowed", + "review_packet_present", + "review_trust_score_present", + "review_research_summary_present", + "real_trade_sample_gate_deferred", + ], + "missing_required_checks": [ + "decision_restricted_support", + "decision_observe_only", + "decision_execution_not_allowed", + "review_packet_present", + "review_trust_score_present", + "review_research_summary_present", + "real_trade_sample_gate_deferred", + ], + "failed_required_checks": [], + "monitor_blockers": [], + "monitor_warnings": [], + "latest_observed": {}, + "operator_mode": None, + "decision_action": None, + "trust_classification": None, + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + "no_research_council_phase_created": True, + "notes": [ + "automation_execution_layer_p7_not_research_council_p7", + "quality_review_artifact_missing", + ], + } + + +def build_analysis_decision_support_quality_review(*, monitor_payload: dict) -> dict: + checks = {str(check.get("code")): dict(check) for check in monitor_payload.get("checks") or []} + observed = dict(monitor_payload.get("observed") or {}) + required_check_codes = [ + "decision_restricted_support", + "decision_observe_only", + "decision_execution_not_allowed", + "review_packet_present", + "review_trust_score_present", + "review_research_summary_present", + "real_trade_sample_gate_deferred", + ] + missing_required_checks = [code for code in required_check_codes if code not in checks] + failed_required_checks = [ + code + for code in required_check_codes + if code in checks and not bool(checks[code].get("passed")) + ] + monitor_blockers = list(monitor_payload.get("blockers") or []) + monitor_warnings = list(monitor_payload.get("warnings") or []) + failure_reasons = [] + if not bool(monitor_payload.get("passed")): + failure_reasons.append("monitor_not_passed") + if monitor_blockers: + failure_reasons.append("monitor_has_blockers") + if monitor_warnings: + failure_reasons.append("monitor_has_warnings") + if missing_required_checks: + failure_reasons.append("required_quality_checks_missing") + if failed_required_checks: + failure_reasons.append("required_quality_checks_failed") + if observed.get("decision_action") != "restricted_decision_support": + failure_reasons.append("decision_action_not_restricted_support") + if observed.get("decision_operator_mode") != "observe_only": + failure_reasons.append("operator_mode_not_observe_only") + if observed.get("real_trade_sample_gate_status") != "deferred_analysis_decision_only": + failure_reasons.append("real_trade_sample_gate_not_deferred") + quality_passed = not failure_reasons + next_slice = ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "state": ( + "automation_execution_layer_p7_alert_decision_support_quality_review_closed" + if quality_passed + else "automation_execution_layer_p7_alert_decision_support_quality_review_blocked" + ), + "status": "completed" if quality_passed else "blocked", + "quality_status": "healthy" if quality_passed else "needs_attention", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + "next_slice": next_slice, + "blocked_reason": ( + None + if quality_passed + else f"analysis_decision_support_quality:{','.join(failure_reasons)}" + ), + "failure_reasons": failure_reasons, + "required_check_codes": required_check_codes, + "missing_required_checks": missing_required_checks, + "failed_required_checks": failed_required_checks, + "monitor_blockers": monitor_blockers, + "monitor_warnings": monitor_warnings, + "latest_observed": observed, + "operator_mode": observed.get("decision_operator_mode"), + "decision_action": observed.get("decision_action"), + "trust_classification": observed.get("decision_trust_classification"), + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + "no_research_council_phase_created": True, + "notes": [ + "automation_execution_layer_p7_not_research_council_p7", + "quality_review_only_reads_decision_support_outputs", + "okx_fills_zero_not_current_phase_blocker", + ], + } + + +def build_analysis_decision_monitor_p7_slice_selector( + *, + trend: dict, + quality_gate: dict, + maturity_rollup: dict, + remote_drift_check: dict, + quality_review: dict, +) -> dict: + trend_record_count = int(trend.get("record_count") or 0) + trend_pass_count = int(trend.get("consecutive_pass_count") or 0) + stable_sample_threshold = int(trend.get("stable_sample_threshold") or ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT) + stable_window = bool(quality_gate.get("stable_monitoring_window")) and bool(trend.get("stable_monitoring_window")) + monitor_blockers = list(quality_gate.get("failure_reasons") or trend.get("latest_blockers") or []) + remote_drift_status = str(remote_drift_check.get("drift_status") or "unknown") + remote_failure_reasons = list(remote_drift_check.get("failure_reasons") or []) + quality_status = str(quality_review.get("quality_status") or "unknown") + quality_failure_reasons = list(quality_review.get("failure_reasons") or []) + maturity_status = str(maturity_rollup.get("maturity_status") or "unknown") + selection_reasons = [] + if monitor_blockers: + next_slice = "repair_analysis_decision_monitor_blockers" + selection_reasons.append("monitor_blockers_present") + elif remote_drift_status != "clear" or remote_failure_reasons: + next_slice = ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE + selection_reasons.append("remote_drift_detected") + elif quality_status != "healthy" or quality_failure_reasons: + next_slice = ANALYSIS_DECISION_SUPPORT_QUALITY_IMPROVE_SLICE + selection_reasons.append("decision_support_quality_needs_attention") + elif maturity_status != "mature" or not stable_window or trend_pass_count < stable_sample_threshold: + next_slice = "continue_periodic_analysis_decision_monitoring" + selection_reasons.append("monitor_not_stable") + else: + next_slice = ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE + selection_reasons.append("all_monitoring_artifacts_healthy") + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "state": "automation_execution_layer_p7_bounded_automation_slice_selector_closed", + "status": "completed", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, + "next_slice": next_slice, + "selected_next_slice": next_slice, + "selection_reason": selection_reasons[0] if selection_reasons else "unspecified", + "selection_reasons": selection_reasons, + "selection_priority": ( + "monitor_blocker" + if next_slice == "repair_analysis_decision_monitor_blockers" + else "remote_drift" + if next_slice == ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE + else "output_quality" + if next_slice == ANALYSIS_DECISION_SUPPORT_QUALITY_IMPROVE_SLICE + else "monitor_not_stable" + if next_slice == "continue_periodic_analysis_decision_monitoring" + else "healthy_rollforward" + ), + "trend": { + "record_count": trend_record_count, + "consecutive_pass_count": trend_pass_count, + "stable_sample_threshold": stable_sample_threshold, + "stable_monitoring_window": bool(trend.get("stable_monitoring_window")), + "latest_monitor_passed": bool(trend.get("latest_monitor_passed")), + }, + "quality_gate": { + "status": quality_gate.get("status"), + "passed": bool(quality_gate.get("passed")), + "failure_reasons": list(quality_gate.get("failure_reasons") or []), + "stable_monitoring_window": bool(quality_gate.get("stable_monitoring_window")), + }, + "maturity_rollup": { + "state": maturity_rollup.get("state"), + "maturity_status": maturity_status, + "blocked_reason": maturity_rollup.get("blocked_reason"), + "remote_drift_check_status": maturity_rollup.get("remote_drift_check_status"), + }, + "remote_drift_check": { + "state": remote_drift_check.get("state"), + "drift_status": remote_drift_status, + "failure_reasons": remote_failure_reasons, + "blocked_reason": remote_drift_check.get("blocked_reason"), + }, + "quality_review": { + "state": quality_review.get("state"), + "quality_status": quality_status, + "failure_reasons": quality_failure_reasons, + "blocked_reason": quality_review.get("blocked_reason"), + }, + "selection_summary": { + "trend_record_count": trend_record_count, + "consecutive_pass_count": trend_pass_count, + "stable_monitoring_window": stable_window, + "maturity_status": maturity_status, + "remote_drift_status": remote_drift_status, + "quality_status": quality_status, + }, + "guards_unchanged": list( + quality_gate.get("guards_unchanged") + or trend.get("guards_unchanged") + or maturity_rollup.get("guards_unchanged") + or remote_drift_check.get("guards_unchanged") + or quality_review.get("guards_unchanged") + or [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ] + ), + "no_research_council_phase_created": True, + "notes": [ + "automation_execution_layer_p7_not_research_council_p7", + "bounded_selector_only_chooses_one_next_slice", + "okx_fills_zero_not_current_phase_blocker", + ], + } + + +def read_analysis_decision_monitor_history_records(path: Path) -> list[dict]: + if not path.exists(): + return [] + records = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + records.append(json.loads(line)) + return records + + +def build_analysis_decision_monitor_trend(*, records: list[dict]) -> dict: + latest = dict(records[-1]) if records else {} + consecutive_pass_count = 0 + for record in reversed(records): + if not bool(record.get("monitor_passed")): + break + if list(record.get("blockers") or []): + break + consecutive_pass_count += 1 + blocker_counts: dict[str, int] = {} + warning_counts: dict[str, int] = {} + for record in records: + for blocker in record.get("blockers") or []: + blocker_counts[str(blocker)] = blocker_counts.get(str(blocker), 0) + 1 + for warning in record.get("warnings") or []: + warning_counts[str(warning)] = warning_counts.get(str(warning), 0) + 1 + + stable_monitoring_window = ( + len(records) >= ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT + and consecutive_pass_count >= ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT + ) + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "record_count": len(records), + "stable_sample_threshold": ANALYSIS_DECISION_MONITOR_STABLE_SAMPLE_COUNT, + "stable_monitoring_window": stable_monitoring_window, + "consecutive_pass_count": consecutive_pass_count, + "latest_monitor_generated_ts": latest.get("monitor_generated_ts"), + "latest_recorded_ts": latest.get("recorded_ts"), + "latest_status": latest.get("status"), + "latest_monitor_passed": bool(latest.get("monitor_passed")) if latest else False, + "latest_blockers": list(latest.get("blockers") or []), + "latest_warnings": list(latest.get("warnings") or []), + "latest_observed": dict(latest.get("observed") or {}), + "blocker_counts": blocker_counts, + "warning_counts": warning_counts, + "next_slice": latest.get("next_slice"), + "guards_unchanged": list(latest.get("guards_unchanged") or []), + } + + +def build_analysis_decision_monitor_quality_gate(*, trend: dict) -> dict: + observed = dict(trend.get("latest_observed") or {}) + failure_reasons = [] + if not bool(trend.get("latest_monitor_passed")): + failure_reasons.append("latest_monitor_not_passed") + if not bool(trend.get("stable_monitoring_window")): + failure_reasons.append("stable_monitoring_window_not_reached") + if list(trend.get("latest_blockers") or []): + failure_reasons.append("latest_monitor_has_blockers") + if list(trend.get("latest_warnings") or []): + failure_reasons.append("latest_monitor_has_warnings") + if observed.get("decision_action") != "restricted_decision_support": + failure_reasons.append("decision_action_not_restricted_support") + if observed.get("decision_operator_mode") != "observe_only": + failure_reasons.append("operator_mode_not_observe_only") + if observed.get("real_trade_sample_gate_required") is not False: + failure_reasons.append("real_trade_sample_gate_not_deferred") + if observed.get("real_trade_sample_gate_status") != "deferred_analysis_decision_only": + failure_reasons.append("real_trade_sample_gate_status_not_deferred") + if observed.get("runtime_health") != "ok": + failure_reasons.append("runtime_health_not_ok") + if observed.get("market_gap") != "market_data_gap_clear": + failure_reasons.append("market_gap_not_clear") + if observed.get("p6_governance_tick_status") != "no_op_healthy": + failure_reasons.append("p6_governance_tick_not_noop") + + gate_passed = not failure_reasons + return { + "generated_ts": datetime.now(timezone.utc).isoformat(), + "gate": "analysis_decision_monitor_quality_gate", + "status": "passed" if gate_passed else "attention_required", + "passed": gate_passed, + "failure_reasons": failure_reasons, + "record_count": trend.get("record_count"), + "consecutive_pass_count": trend.get("consecutive_pass_count"), + "stable_monitoring_window": bool(trend.get("stable_monitoring_window")), + "latest_monitor_generated_ts": trend.get("latest_monitor_generated_ts"), + "latest_observed": observed, + "next_slice": trend.get("next_slice"), + "guards_unchanged": list(trend.get("guards_unchanged") or []), + "notes": [ + "analysis_decision_only_phase", + "execution_gate_unchanged", + "real_live_ready_unchanged", + "real_trade_sample_deferred", + ], + } + + +def run_automation_execution_layer_gate(*, env: dict, output_json: bool) -> dict: + started_ts = datetime.now(timezone.utc).isoformat() + write_runner_status( + { + "state": "running", + "mode": "automation_execution_layer", + "started_ts": started_ts, + "heartbeat_ts": started_ts, + "current_stage": AUTOMATION_EXECUTION_GATE_STAGE, + "blocked_reason": None, + "rounds_completed": 0, + } + ) + try: + anchor_state = read_automation_anchor_state() + tick_payload = run_research_council_governance_tick_from_env(env) + except Exception: + write_runner_status( + { + "state": "failed", + "mode": "automation_execution_layer", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": AUTOMATION_EXECUTION_GATE_STAGE, + "blocked_reason": "p6_governance_tick_exception", + "rounds_completed": 0, + } + ) + raise + + tick_status = str(tick_payload.get("status") or "unknown") + changed_files = collect_declared_changed_files(env) + summary = build_automation_execution_summary( + started_ts=started_ts, + tick_payload=tick_payload, + anchor_state=anchor_state, + changed_files=changed_files, + ) + write_automation_execution_anchor_files(tick_payload=tick_payload, summary=summary) + write_runner_status( + { + "state": summary["status"], + "mode": "automation_execution_layer", + "started_ts": started_ts, + "heartbeat_ts": datetime.now(timezone.utc).isoformat(), + "current_stage": summary.get("current_stage"), + "blocked_reason": summary.get("blocked_reason"), + "rounds_completed": len(summary.get("rounds") or []), + "tick_status": tick_status, + "effective_health_state": tick_payload.get("effective_health_state"), + "safe_to_auto_continue": tick_payload.get("safe_to_auto_continue"), + "next_slice": summary.get("next_slice"), + "no_new_phase_created": True, + } + ) + + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Automation execution-layer gate status: {summary['status']}") + print(f"P6 governance tick: {tick_status}") + print(f"Effective health: {tick_payload.get('effective_health_state')}") + print(f"Next slice: {summary.get('next_slice')}") + if summary.get("blocked_reason"): + print(f"Blocked reason: {summary['blocked_reason']}") + return summary + + +def run_research_council_governance_tick_from_env(env: dict) -> dict: + return CouncilGovernanceTickService().run_tick( + instrument_id=int(env.get("RESEARCH_COUNCIL_INSTRUMENT_ID", "1")), + limit=int(env.get("RESEARCH_COUNCIL_REVIEW_LIMIT", "5")), + stale_after_hours=float(env.get("RESEARCH_COUNCIL_STALE_AFTER_HOURS", "24")), + auto_run_stale_cycle=_get_bool(env, "RESEARCH_COUNCIL_GOVERNANCE_AUTO_RUN_STALE", False), + force_cycle=_get_bool(env, "RESEARCH_COUNCIL_GOVERNANCE_FORCE_CYCLE", False), + timeframe=env.get("RESEARCH_COUNCIL_TIMEFRAME", "1m"), + model_code=env.get("RESEARCH_COUNCIL_MODEL_CODE", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"), + replay_file=str(env.get("RESEARCH_COUNCIL_REPLAY_FILE") or "").strip() or None, + eval_limit=int(env.get("RESEARCH_COUNCIL_EVAL_LIMIT", "20")), + lesson_limit=int(env.get("RESEARCH_COUNCIL_LESSON_LIMIT", "100")), + cycle_key=str(env.get("RESEARCH_COUNCIL_OPTIMIZATION_CYCLE_KEY") or "").strip() or None, + ) + + +def build_automation_execution_summary(*, started_ts: str, tick_payload: dict, anchor_state: dict, changed_files: Optional[list[str]] = None) -> dict: + tick_status = str(tick_payload.get("status") or "unknown") + selection = select_automation_execution_slice(anchor_state=anchor_state, tick_payload=tick_payload) + validation_plan = build_validation_plan(selected_slice=selection, changed_files=changed_files or []) + blocked_reason = None + status = "completed" + current_stage = None + next_slice = selection.get("slice") or AUTOMATION_EXECUTION_NEXT_SLICE + if selection.get("status") == "blocked": + status = "blocked" + current_stage = selection.get("slice") + blocked_reason = selection.get("blocked_reason") or "automation_slice_selection_blocked" + elif tick_status == "cycle_required": + status = "blocked" + current_stage = "automation_execution_layer:P6_cycle_required" + blocked_reason = "optimization_cycle_requires_explicit_authorization" + next_slice = "authorized_research_council_optimization_cycle" + elif tick_status == "bounded_slice_required": + status = "stopped" + current_stage = "automation_execution_layer:P6_bounded_slice_required" + next_slice = "smallest_research_council_bounded_slice" + elif tick_status not in {"no_op_healthy", "cycle_run"}: + status = "blocked" + current_stage = AUTOMATION_EXECUTION_GATE_STAGE + blocked_reason = f"unexpected_p6_governance_tick_status:{tick_status}" + next_slice = "inspect_p6_governance_tick_payload" + + summary = { + "started_ts": started_ts, + "finished_ts": datetime.now(timezone.utc).isoformat(), + "status": status, + "mode": "automation_execution_layer", + "active_gate": "research_council_p6_governance_tick", + "tick_status": tick_status, + "effective_health_state": tick_payload.get("effective_health_state"), + "safe_to_auto_continue": bool(tick_payload.get("safe_to_auto_continue")), + "requires_cycle_run": bool(tick_payload.get("requires_cycle_run")), + "requires_bounded_slice": bool(tick_payload.get("requires_bounded_slice")), + "recommended_actions": list(tick_payload.get("recommended_actions") or []), + "boundary_notes": list(tick_payload.get("boundary_notes") or []), + "anchor_state": anchor_state, + "selected_slice": selection, + "validation_plan": validation_plan, + "rounds": [ + { + "round": 1, + "stage": AUTOMATION_EXECUTION_GATE_STAGE, + "tick_status": tick_status, + "effective_health_state": tick_payload.get("effective_health_state"), + "recommended_actions": list(tick_payload.get("recommended_actions") or []), + } + ], + "current_stage": current_stage, + "blocked_reason": blocked_reason, + "next_slice": next_slice, + "no_new_phase_created": True, + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + } + summary["state_writeback_plan"] = build_state_writeback_plan(summary=summary) + return summary + + +def build_state_writeback_plan(*, summary: dict) -> dict: + anchor_status = derive_automation_execution_anchor_status(summary.get("anchor_state") or {}) + return { + "files_to_write": [ + ".claude/automation_status.json", + "SESSION.md", + "TASKS.md", + ], + "state": anchor_status["state"], + "current_phase": anchor_status["current_phase"], + "next_slice": summary.get("next_slice"), + "status": summary.get("status"), + "blocked_reason": summary.get("blocked_reason"), + "no_new_phase_created": bool(summary.get("no_new_phase_created")), + "guards_unchanged": list(summary.get("guards_unchanged") or []), + "docs_update_required": False, + "docs_update_reason": "execution_layer_bootstrap_state_only", + } + + +def collect_declared_changed_files(env: dict) -> list[str]: + raw_value = ( + env.get("RUN_UNTIL_BLOCKED_CHANGED_FILES") + or env.get("RUN_UNTIL_BLOCKED_TOUCHED_FILES") + or "" + ) + if not str(raw_value).strip(): + return [] + files = [ + item.strip().replace("\\", "/") + for item in re.split(r"[\n;,]+", str(raw_value)) + if item.strip() + ] + return sorted(dict.fromkeys(files)) + + +def build_validation_plan(*, selected_slice: dict, changed_files: list[str]) -> dict: + normalized_files = sorted(dict.fromkeys(path.replace("\\", "/") for path in changed_files if path)) + focused_commands = [] + quality_gates = [] + notes = [] + + selected_code = str(selected_slice.get("slice") or "") + if selected_code.startswith("automation_execution_layer_"): + focused_commands.append(f"{PROJECT_PYTHON} -m unittest tests.unit.test_run_until_blocked") + quality_gates.append(f"{PROJECT_PYTHON} scripts/run_script_static_checks.py") + + if touches_any(normalized_files, ("scripts/",)): + focused_commands.append(f"{PROJECT_PYTHON} -m unittest tests.unit.test_run_until_blocked") + quality_gates.append(f"{PROJECT_PYTHON} scripts/run_script_static_checks.py") + if touches_any(normalized_files, ("docs/", "SESSION.md", "TASKS.md")): + focused_commands.append(f"{PROJECT_PYTHON} -m unittest tests.unit.test_documentation_consistency") + if touches_any(normalized_files, ("src/services/research_council/", "src/api/research_council.py")): + focused_commands.append(f"{PROJECT_PYTHON} -m unittest discover -s tests/unit -p \"test_research_council*.py\"") + if touches_any(normalized_files, ("migrations/",)): + quality_gates.append(f"{PROJECT_PYTHON} scripts/run_migration_static_checks.py") + + deploy_required = touches_any(normalized_files, DEPLOY_SMOKE_FILE_PREFIXES) + if deploy_required: + notes.append("deploy_smoke_required_by_declared_runtime_api_db_or_workbench_change") + elif normalized_files: + notes.append("deploy_smoke_not_required_for_declared_files") + else: + notes.append("no_declared_changed_files; using selected-slice default validation only") + + if selected_slice.get("requires_code") and "npm run lint" not in quality_gates: + quality_gates.append("npm run lint") + + return { + "declared_changed_files": normalized_files, + "focused_commands": unique_list(focused_commands), + "quality_gates": unique_list(quality_gates), + "deploy_required": deploy_required, + "deploy_reason": "declared_runtime_api_db_or_workbench_change" if deploy_required else None, + "deploy_smoke_plan": build_deploy_smoke_plan(changed_files=normalized_files, deploy_required=deploy_required), + "notes": notes, + } + + +def build_deploy_smoke_plan(*, changed_files: list[str], deploy_required: bool) -> dict: + normalized_files = sorted(dict.fromkeys(path.replace("\\", "/") for path in changed_files if path)) + if not deploy_required: + return { + "deploy_required": False, + "reason": "local_only_slice_or_no_declared_runtime_api_db_workbench_change", + "deploy_steps": [], + "smoke_targets": [], + } + + smoke_targets = ["/health"] + deploy_steps = [ + "deploy_current_head_to_remote", + "run_remote_migrations_if_needed", + "wait_for_remote_container_healthy", + ] + if touches_any(normalized_files, ("migrations/",)): + smoke_targets.append("remote_migration_state") + if touches_any(normalized_files, ("src/api/research_council.py", "src/services/research_council/")): + smoke_targets.extend( + [ + "POST /trading/research-council/governance-tick", + "GET /trading/research-council/optimization-review", + ] + ) + if touches_any(normalized_files, ("src/api/trading_runtime.py", "src/services/runtime/")): + smoke_targets.extend(["GET /trading/runtime-status", "POST /trading/runtime/run-once"]) + if touches_any(normalized_files, ("src/services/readiness/",)): + smoke_targets.extend(["GET /trading/launch-readiness", "GET /trading/live-readiness"]) + if touches_any(normalized_files, ("src/services/decision/",)): + smoke_targets.append("GET /trading/decision-packet") + if touches_any(normalized_files, ("src/services/operator/",)): + smoke_targets.extend(["GET /trading/review-packet", "GET /trading/session-view"]) + if touches_any(normalized_files, ("src/services/trust/",)): + smoke_targets.append("GET /trading/trust-score") + if touches_any(normalized_files, ("src/web/",)): + smoke_targets.append("browser smoke: trading workbench") + + return { + "deploy_required": True, + "reason": "declared_runtime_api_db_or_workbench_change", + "deploy_steps": deploy_steps, + "smoke_targets": unique_list(smoke_targets), + } + + +def touches_any(paths: list[str], prefixes: tuple[str, ...]) -> bool: + for path in paths: + for prefix in prefixes: + if path == prefix.rstrip("/") or path.startswith(prefix): + return True + return False + + +def unique_list(items: list[str]) -> list[str]: + return list(dict.fromkeys(items)) + + +def get_automation_required_anchor_paths() -> tuple[Path, ...]: + return (*AUTOMATION_REQUIRED_DOC_PATHS, SESSION_PATH, TASKS_PATH) + + +def read_automation_anchor_state() -> dict: + anchors = [] + missing_paths = [] + combined_text_parts = [] + for path in get_automation_required_anchor_paths(): + path_label = _relative_path_label(path) + if not path.exists(): + missing_paths.append(path_label) + anchors.append({"path": path_label, "present": False}) + continue + text = path.read_text(encoding="utf-8") + combined_text_parts.append(text) + anchors.append( + { + "path": path_label, + "present": True, + "bytes": path.stat().st_size, + } + ) + + session_text = SESSION_PATH.read_text(encoding="utf-8") if SESSION_PATH.exists() else "" + tasks_text = TASKS_PATH.read_text(encoding="utf-8") if TASKS_PATH.exists() else "" + return { + "anchors": anchors, + "missing_required_anchors": missing_paths, + "state": extract_anchor_field(session_text, "state") or extract_checked_anchor_field(tasks_text, "state"), + "current_phase": extract_anchor_field(session_text, "current_phase") or extract_checked_anchor_field(tasks_text, "current_phase"), + "repair_next_action": extract_anchor_field(session_text, "repair_next_action"), + "completed_execution_slices": parse_completed_automation_execution_slices(tasks_text), + "in_progress_execution_slices": parse_in_progress_automation_execution_slices(tasks_text), + "has_p0_p6_freeze": "P0-P6" in "\n".join(combined_text_parts) + and "no_op_healthy" in "\n".join(combined_text_parts), + } + + +def select_automation_execution_slice(*, anchor_state: dict, tick_payload: dict) -> dict: + missing = list(anchor_state.get("missing_required_anchors") or []) + if missing: + return { + "slice": "repair_missing_automation_anchors", + "title": "Repair missing automation anchors", + "status": "blocked", + "blocked_reason": f"missing_required_anchors:{','.join(missing)}", + "requires_code": False, + } + + tick_status = str(tick_payload.get("status") or "unknown") + if tick_status == "cycle_required": + return { + "slice": "authorized_research_council_optimization_cycle", + "title": "Authorized Research Council optimization cycle", + "status": "blocked", + "blocked_reason": "optimization_cycle_requires_explicit_authorization", + "requires_code": False, + } + if tick_status == "bounded_slice_required": + return { + "slice": smallest_bounded_slice_name(tick_payload), + "title": "Smallest Research Council bounded slice", + "status": "ready", + "blocked_reason": None, + "requires_code": True, + } + if tick_status not in {"no_op_healthy", "cycle_run"}: + return { + "slice": "inspect_p6_governance_tick_payload", + "title": "Inspect unexpected P6 governance tick payload", + "status": "blocked", + "blocked_reason": f"unexpected_p6_governance_tick_status:{tick_status}", + "requires_code": False, + } + + completed = set(anchor_state.get("completed_execution_slices") or []) + in_progress = list(anchor_state.get("in_progress_execution_slices") or []) + for code, title in AUTOMATION_EXECUTION_SLICE_SEQUENCE: + if code in completed: + continue + return { + "slice": code, + "title": title, + "status": "ready", + "blocked_reason": None, + "requires_code": True, + "reason": "first_unfinished_execution_layer_slice", + "already_in_progress": code in in_progress, + } + return { + "slice": "automation_execution_layer_steady_state_tick", + "title": "Automation execution-layer steady-state tick", + "status": "ready", + "blocked_reason": None, + "requires_code": False, + "reason": "all_execution_layer_bootstrap_slices_completed", + } + + +def smallest_bounded_slice_name(tick_payload: dict) -> str: + actions = {str(action) for action in tick_payload.get("recommended_actions") or []} + if "add_replay_fixture_with_confirmed_swings" in actions: + return "bounded_research_council_confirmed_swing_replay_fixture" + if actions: + first_action = sorted(actions)[0].replace("-", "_").replace(" ", "_").lower() + return f"bounded_research_council_{first_action}" + return "smallest_research_council_bounded_slice" + + +def extract_anchor_field(text: str, key: str) -> Optional[str]: + match = re.search(rf"^- {re.escape(key)}:\s*(.+)$", text, flags=re.MULTILINE) + return match.group(1).strip() if match else None + + +def extract_checked_anchor_field(text: str, key: str) -> Optional[str]: + match = re.search(rf"^- \[x\] {re.escape(key)}:\s*(.+)$", text, flags=re.MULTILINE) + return match.group(1).strip() if match else None + + +def parse_completed_automation_execution_slices(tasks_text: str) -> list[str]: + slices = re.findall(r"- \[x\] `([^`]*automation_execution_layer[^`]*)`", tasks_text) + if "Automation Execution-Layer P0" in tasks_text or AUTOMATION_EXECUTION_GATE_CLOSED_STATE in tasks_text: + slices.insert(0, "automation_execution_layer_p0_governance_gate") + return list(dict.fromkeys(slices)) + + +def parse_in_progress_automation_execution_slices(tasks_text: str) -> list[str]: + return re.findall(r"- \[ \] `([^`]*automation_execution_layer[^`]*)`", tasks_text) + + +def _relative_path_label(path: Path) -> str: + try: + return str(path.relative_to(ROOT)).replace("\\", "/") + except ValueError: + return str(path) + + +def write_automation_execution_anchor_files(*, tick_payload: dict, summary: dict) -> None: + SESSION_PATH.write_text(render_automation_execution_session(tick_payload=tick_payload, summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_automation_execution_tasks(tick_payload=tick_payload, summary=summary), encoding="utf-8") + + +def render_automation_execution_session(*, tick_payload: dict, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + anchor_status = derive_automation_execution_anchor_status(summary.get("anchor_state") or {}) + goal_text = render_automation_execution_goal_text(summary) + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + goal_text, + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + f"- state: {anchor_status['state']}", + f"- current_phase: {anchor_status['current_phase']}", + f"- updated_ts: {updated_ts}", + f"- latest_p6_tick_status: {summary.get('tick_status')}", + f"- latest_p6_effective_health_state: {summary.get('effective_health_state')}", + f"- latest_safe_to_auto_continue: {str(summary.get('safe_to_auto_continue')).lower()}", + f"- repair_next_action: continue with {summary.get('next_slice')}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Ran the P6 Research Council governance tick.", + "- Routed the current `automation_flow_p0_p6_review_closed` state through `scripts/run_until_blocked.py`.", + "- Preserved P0-P6 freeze semantics and did not create P7.", + "", + "## Current Working State", + "", + "- P0-P6 Research Council automation phases remain complete and frozen.", + "- `scripts/run_until_blocked.py` now treats P6 governance tick as the default execution-layer health gate when the anchors are in automation execution mode.", + "- A `no_op_healthy` tick records health and stops Research Council phase expansion.", + "- A `cycle_required` tick requires explicit optimization-cycle authorization before running the cycle.", + "- A `bounded_slice_required` tick opens only the smallest bounded implementation slice, then returns to P6.", + "- This slice did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains incomplete.", + "- `real_trade_sample_verified` is deferred until a future real-trading validation phase.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "- Large backlog drain remains sensitive to remote PostgreSQL connection timeouts.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {summary.get('next_slice')}", + "", + "Build the next automation execution-layer slice around anchor reading, smallest-slice selection, focused validation, quality-gate selection, optional deploy/smoke routing, and state writeback.", + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_research_council_governance_tick_service tests.unit.test_runtime_script_json tests.unit.test_documentation_consistency", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + "npm run lint", + "```", + "", + ] + ) + + +def render_automation_execution_goal_text(summary: dict) -> str: + if summary.get("next_slice") == "automation_execution_layer_steady_state_tick": + return "Automation execution-layer bootstrap is complete after the P0-P6 Research Council phase freeze. The default loop is now a steady-state P6 governance tick: healthy no-op ticks stop without code changes, while cycle or bounded-slice statuses route to the smallest allowed action." + return "Automation execution-layer development has started after the P0-P6 Research Council phase freeze. The default Research Council entrypoint is P6 governance tick, and a healthy no-op tick must stop Research Council expansion instead of creating P7." + + +def render_automation_execution_tasks(*, tick_payload: dict, summary: dict) -> str: + next_slice = summary.get("next_slice") + anchor_status = derive_automation_execution_anchor_status(summary.get("anchor_state") or {}) + completed_slice_lines = render_completed_execution_slice_lines(summary.get("anchor_state") or {}) + blocked_line = ( + f"- [ ] {summary.get('blocked_reason')}" + if summary.get("blocked_reason") + else "- [ ] `decision_support_ready`" + ) + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` is a stop condition and must not create P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Implement anchor-driven smallest-slice selection", + "- [ ] Add validation and quality-gate routing for the selected slice", + "- [ ] Add deploy/smoke routing only when runtime/API/workbench changes require it", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + "", + "## Automation Status", + "", + f"- [x] state: {anchor_status['state']}", + f"- [x] current_phase: {anchor_status['current_phase']}", + f"- [x] latest_p6_tick_status: {summary.get('tick_status')}", + f"- [x] latest_p6_effective_health_state: {summary.get('effective_health_state')}", + f"- [x] no_new_phase_created: {str(summary.get('no_new_phase_created')).lower()}", + "", + "## Newly Done", + "", + "- [x] Read required automation anchors", + "- [x] Ran and classified P6 governance tick", + "- [x] Wired P6 governance tick into `scripts/run_until_blocked.py` for automation execution-layer mode", + "- [x] Added focused coverage for no-op healthy execution-layer routing", + "- [x] Preserved legacy trading-stage run-until-blocked behavior", + "", + "## Blocked", + "", + blocked_line, + "- [ ] Large backlog drain remains sensitive to remote PostgreSQL connection timeouts", + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Major Done Milestones", + "", + "- [x] `P0-1` Real-time market truth layer", + "- [x] `P0-2` Real Account And Order Read Sync", + "- [x] `P0-3` Runtime Scheduling And Supervision", + "- [x] `P0-4` Decision Packet And Proactive Alerting", + "- [x] `P0-5` Real Trade Mapping And Post-Trade Loop", + "- [x] `P1-1` Trust Scoring And Model Whitelists", + "- [x] `P1-2` Operator Experience And Workflow Tools", + "- [x] `P1-3` Live Adapter Readiness Prep", + "- [x] `Research Council P0` local gate", + "- [x] `Research Council P1` local gate", + "- [x] `Research Council P2` remote deploy and smoke gate", + "- [x] `Research Council P3` remote deploy and smoke gate", + "- [x] `Research Council P4` remote deploy and smoke gate", + "- [x] `Research Council P5` remote deploy, remote cycle, and review smoke gate", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + *completed_slice_lines, + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def write_analysis_decision_monitor_anchor_files(*, summary: dict) -> None: + SESSION_PATH.write_text(render_analysis_decision_monitor_session(summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_analysis_decision_monitor_tasks(summary=summary), encoding="utf-8") + + +def write_analysis_decision_monitor_maturity_rollup_anchor_files(*, summary: dict) -> None: + SESSION_PATH.write_text(render_analysis_decision_monitor_maturity_rollup_session(summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_analysis_decision_monitor_maturity_rollup_tasks(summary=summary), encoding="utf-8") + + +def write_analysis_decision_monitor_remote_drift_anchor_files(*, summary: dict) -> None: + SESSION_PATH.write_text(render_analysis_decision_monitor_remote_drift_session(summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_analysis_decision_monitor_remote_drift_tasks(summary=summary), encoding="utf-8") + + +def write_analysis_decision_support_quality_review_anchor_files(*, summary: dict) -> None: + SESSION_PATH.write_text(render_analysis_decision_support_quality_review_session(summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_analysis_decision_support_quality_review_tasks(summary=summary), encoding="utf-8") + + +def write_analysis_decision_monitor_p7_selector_anchor_files(*, summary: dict) -> None: + SESSION_PATH.write_text(render_analysis_decision_monitor_p7_selector_session(summary=summary), encoding="utf-8") + TASKS_PATH.write_text(render_analysis_decision_monitor_p7_selector_tasks(summary=summary), encoding="utf-8") + + +def render_analysis_decision_monitor_p7_selector_session(*, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + selector = dict(summary.get("selector") or {}) + next_slice = str(selector.get("next_slice") or summary.get("next_slice") or "") + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + "Automation execution-layer P7 bounded slice selector is active. The system is still analysis/decision-only, and this stage only chooses the single next bounded slice after the matured monitor review.", + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + f"- state: {selector.get('state')}", + "- current_phase: Restricted Decision-Support Soft Rollout", + f"- updated_ts: {updated_ts}", + "- latest_p6_tick_status: no_op_healthy", + f"- latest_p7_selector_choice: {selector.get('selected_next_slice')}", + f"- latest_p7_selector_reason: {selector.get('selection_reason')}", + f"- latest_p7_selector_path: {summary.get('selector_path')}", + f"- repair_next_action: {next_slice}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Loaded the monitor maturity, drift, and quality review artifacts.", + "- Selected exactly one bounded next slice for the analysis/decision automation loop.", + "- Generated `.claude/analysis_decision_monitor_p7_slice_selector.json`.", + "- Preserved P0-P6 Research Council freeze semantics and did not create Research Council P7.", + "- Did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Working State", + "", + f"- P7-4 selector choice: {selector.get('selected_next_slice')}.", + f"- Selector reason: {selector.get('selection_reason')}.", + f"- Selector priority: {selector.get('selection_priority')}.", + f"- Monitor stable window: {selector.get('selection_summary', {}).get('stable_monitoring_window')}.", + f"- Monitor maturity status: {selector.get('maturity_rollup', {}).get('maturity_status')}.", + f"- Remote drift status: {selector.get('remote_drift_check', {}).get('drift_status')}.", + f"- Output quality status: {selector.get('quality_review', {}).get('quality_status')}.", + "", + "## P7 Blockers", + "", + "- none", + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains a strict unrestricted lane and must not be force-enabled.", + "- The recovered OKX path depends on the local v2rayN/xray process staying up on `192.168.3.73:10808`.", + "- `real_trade_sample_verified` is deferred and not required while the system is analysis/decision-only.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {next_slice}", + "", + "Run the bounded slice chosen by the selector, then continue the mature periodic monitoring loop.", + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_analysis_decision_monitor tests.unit.test_runtime_script_json", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_until_blocked.py", + "```", + "", + ] + ) + + +def render_analysis_decision_monitor_p7_selector_tasks(*, summary: dict) -> str: + selector = dict(summary.get("selector") or {}) + next_slice = str(selector.get("next_slice") or summary.get("next_slice") or "") + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` must not create Research Council P7", + "- [x] Stable monitor quality may enter automation execution-layer P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Continue bounded analysis-decision automation", + "- [ ] Keep local v2rayN/xray running on `192.168.3.73:10808`", + "- [ ] Treat OKX fills remaining `0` as acceptable in the current analysis/decision-only phase", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + "", + "## Automation Status", + "", + f"- [x] state: {selector.get('state')}", + "- [x] current_phase: Restricted Decision-Support Soft Rollout", + "- [x] latest_p6_tick_status: no_op_healthy", + "- [x] no_new_research_council_phase_created: true", + f"- [x] latest_p7_selector_choice: {selector.get('selected_next_slice')}", + f"- [x] latest_p7_selector_reason: {selector.get('selection_reason')}", + f"- [x] latest_p7_selector_path: {summary.get('selector_path')}", + "", + "## Newly Done", + "", + "- [x] Completed P7-4 bounded automation slice selector", + "- [x] Chose a single next bounded slice from monitor maturity inputs", + "- [x] Wrote bounded selector artifact", + "- [x] Kept OKX fills out of the current-phase blocker set", + "- [x] Preserved execution, live readiness, risk, trust, and operator confirmation gates", + "", + "## Blocked", + "", + "- [x] none", + "", + "## Standing Guardrails", + "", + "- [x] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled", + "- [x] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready", + "- [x] Continue periodic analysis-decision monitoring with maturity rollup", + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Operational Dependencies", + "", + "- [ ] Keep `D:\\v2rayN-windows-64\\v2rayN.exe` / `xray.exe` running on the local Windows host", + "- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`", + "", + "## Major Done Milestones", + "", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + "- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor", + "- [x] `automation_execution_layer_p7_monitor_maturity_rollup` P7-1 maturity rollup", + "- [x] `automation_execution_layer_p7_remote_drift_check` P7-2 remote drift check", + "- [x] `automation_execution_layer_p7_alert_decision_support_quality_review` P7-3 quality review", + "- [x] `automation_execution_layer_p7_bounded_automation_slice_selector` P7-4 bounded automation slice selector", + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def render_analysis_decision_support_quality_review_session(*, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + review = dict(summary.get("quality_review") or {}) + observed = dict(review.get("latest_observed") or {}) + next_slice = str(review.get("next_slice") or summary.get("next_slice") or "") + failure_reasons = list(review.get("failure_reasons") or []) + blocker_line = f"- {review.get('blocked_reason')}" if review.get("blocked_reason") else "- none" + failure_lines = [f"- {reason}" for reason in failure_reasons] or ["- none"] + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + "Automation execution-layer P7 alert and decision-support quality review is active after local and remote monitor maturity converged. The system remains analysis/decision-only and real fill verification is deferred because this phase does not do live trading.", + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + f"- state: {review.get('state')}", + "- current_phase: Restricted Decision-Support Soft Rollout", + f"- updated_ts: {updated_ts}", + "- latest_p6_tick_status: no_op_healthy", + f"- latest_p7_quality_status: {review.get('quality_status')}", + f"- latest_p7_quality_review_path: {summary.get('quality_review_path')}", + f"- latest_decision_action: {review.get('decision_action')}", + f"- latest_operator_mode: {review.get('operator_mode')}", + f"- latest_trust_classification: {review.get('trust_classification')}", + f"- repair_next_action: {next_slice}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Reviewed restricted decision-support output quality from the current monitor payload.", + "- Verified the output is observe-only and does not enable execution.", + "- Generated `.claude/analysis_decision_support_quality_review.json`.", + "- Preserved P0-P6 Research Council freeze semantics and did not create Research Council P7.", + "- Did not wait on OKX fills because real fill verification is deferred to a future real-trading validation phase.", + "- Did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Working State", + "", + f"- P7-3 quality status: {review.get('quality_status')}.", + f"- Decision action: {review.get('decision_action')}.", + f"- Operator mode: {review.get('operator_mode')}.", + f"- Trust classification: {review.get('trust_classification')}.", + f"- Broker fills observed: {observed.get('broker_fills')}.", + f"- Real trade sample gate: {observed.get('real_trade_sample_gate_status')}.", + "", + "## P7 Blockers", + "", + blocker_line, + "", + "## P7 Quality Failure Reasons", + "", + *failure_lines, + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains a strict unrestricted lane and must not be force-enabled.", + "- The recovered OKX path depends on the local v2rayN/xray process staying up on `192.168.3.73:10808`.", + "- `real_trade_sample_verified` is deferred and not required while the system is analysis/decision-only.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {next_slice}", + "", + "Run the bounded P7-4 selector next. It will choose the single bounded slice that follows this quality review.", + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_analysis_decision_monitor tests.unit.test_runtime_script_json", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_until_blocked.py", + "```", + "", + ] + ) + + +def render_analysis_decision_support_quality_review_tasks(*, summary: dict) -> str: + review = dict(summary.get("quality_review") or {}) + next_slice = str(review.get("next_slice") or summary.get("next_slice") or "") + failure_reasons = list(review.get("failure_reasons") or []) + blocker_lines = ( + [f"- [ ] {review.get('blocked_reason')}"] + if review.get("blocked_reason") + else ["- [x] none"] + ) + standing_guardrail_lines = [ + "- [x] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled", + "- [x] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready", + "- [x] Continue periodic analysis-decision monitoring with maturity rollup", + ] + failure_lines = [f"- [ ] {reason}" for reason in failure_reasons] or ["- [x] none"] + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` must not create Research Council P7", + "- [x] Stable monitor quality may enter automation execution-layer P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Run P7-4 bounded automation slice selector", + "- [ ] Keep local v2rayN/xray running on `192.168.3.73:10808`", + "- [ ] Treat OKX fills remaining `0` as acceptable in the current analysis/decision-only phase", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + "", + "## Automation Status", + "", + f"- [x] state: {review.get('state')}", + "- [x] current_phase: Restricted Decision-Support Soft Rollout", + "- [x] latest_p6_tick_status: no_op_healthy", + "- [x] no_new_research_council_phase_created: true", + f"- [x] latest_p7_quality_status: {review.get('quality_status')}", + f"- [x] latest_p7_quality_review_path: {summary.get('quality_review_path')}", + f"- [x] latest_decision_action: {review.get('decision_action')}", + f"- [x] latest_operator_mode: {review.get('operator_mode')}", + "", + "## Newly Done", + "", + "- [x] Completed P7-1 monitor maturity rollup", + "- [x] Completed P7-2 remote drift check", + "- [x] Reviewed restricted decision-support output quality", + "- [x] Generated analysis decision support quality review", + "- [x] Closed P7-3 without adding a new Research Council phase", + "- [x] Kept OKX fills out of the current-phase blocker set", + "- [x] Preserved execution, live readiness, risk, trust, and operator confirmation gates", + "", + "## Blocked", + "", + *blocker_lines, + "", + "## Standing Guardrails", + "", + *standing_guardrail_lines, + "", + "## Quality Failure Reasons", + "", + *failure_lines, + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Operational Dependencies", + "", + "- [ ] Keep `D:\\v2rayN-windows-64\\v2rayN.exe` / `xray.exe` running on the local Windows host", + "- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`", + "", + "## Major Done Milestones", + "", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + "- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor", + "- [x] `automation_execution_layer_p7_monitor_maturity_rollup` P7-1 maturity rollup", + "- [x] `automation_execution_layer_p7_remote_drift_check` P7-2 remote drift check", + "- [x] `automation_execution_layer_p7_alert_decision_support_quality_review` P7-3 quality review", + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def render_analysis_decision_monitor_remote_drift_session(*, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + drift = dict(summary.get("drift_check") or {}) + local = dict(drift.get("local") or {}) + remote = dict(drift.get("remote") or {}) + next_slice = str(drift.get("next_slice") or summary.get("next_slice") or "") + blocker_line = f"- {drift.get('blocked_reason')}" if drift.get("blocked_reason") else "- none" + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + "Automation execution-layer P7 remote drift check is active after the monitor maturity rollup. The system remains analysis/decision-only and real fill verification is deferred because this phase does not do live trading.", + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + f"- state: {drift.get('state')}", + "- current_phase: Restricted Decision-Support Soft Rollout", + f"- updated_ts: {updated_ts}", + "- latest_p6_tick_status: no_op_healthy", + f"- latest_p7_remote_drift_status: {drift.get('drift_status')}", + f"- latest_p7_remote_drift_check_path: {summary.get('drift_check_path')}", + f"- latest_local_quality_gate_status: {local.get('quality_gate_status')}", + f"- latest_local_consecutive_passes: {local.get('consecutive_pass_count')}", + f"- latest_remote_container_quality_gate_status: {remote.get('container_quality_gate_status')}", + f"- latest_remote_container_consecutive_passes: {remote.get('container_consecutive_pass_count')}", + f"- repair_next_action: {next_slice}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Compared local monitor maturity rollup with remote container and host persisted quality gates.", + "- Generated `.claude/analysis_decision_monitor_remote_drift_check.json`.", + "- Preserved P0-P6 Research Council freeze semantics and did not create Research Council P7.", + "- Did not wait on OKX fills because real fill verification is deferred to a future real-trading validation phase.", + "- Did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Working State", + "", + f"- P7-2 remote drift status: {drift.get('drift_status')}.", + f"- Local maturity status: {local.get('maturity_status')}.", + f"- Local monitor records: {local.get('record_count')}.", + f"- Local monitor consecutive passes: {local.get('consecutive_pass_count')}.", + f"- Remote host: {remote.get('host')}.", + f"- Remote container quality gate: {remote.get('container_quality_gate_status')}.", + f"- Remote container records: {remote.get('container_record_count')}.", + f"- Remote container consecutive passes: {remote.get('container_consecutive_pass_count')}.", + f"- Remote host persisted quality gate: {remote.get('host_quality_gate_status')}.", + "", + "## P7 Blockers", + "", + blocker_line, + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains a strict unrestricted lane and must not be force-enabled.", + "- The recovered OKX path depends on the local v2rayN/xray process staying up on `192.168.3.73:10808`.", + "- `real_trade_sample_verified` is deferred and not required while the system is analysis/decision-only.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "- P7-3 must review alert and decision-support output quality before returning to long-running monitoring.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {next_slice}", + "", + "Review restricted decision-support and alert output quality. If quality is insufficient, open `improve_analysis_decision_support_quality`; if healthy, return to long-running monitoring with maturity rollup.", + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_analysis_decision_monitor tests.unit.test_runtime_script_json", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_until_blocked.py", + "```", + "", + ] + ) + + +def render_analysis_decision_monitor_remote_drift_tasks(*, summary: dict) -> str: + drift = dict(summary.get("drift_check") or {}) + local = dict(drift.get("local") or {}) + remote = dict(drift.get("remote") or {}) + next_slice = str(drift.get("next_slice") or summary.get("next_slice") or "") + blocker_lines = ( + [f"- [ ] {drift.get('blocked_reason')}"] + if drift.get("blocked_reason") + else [ + "- [ ] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled", + "- [ ] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready", + "- [ ] P7-3 alert and decision-support quality review is still pending", + ] + ) + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` must not create Research Council P7", + "- [x] Stable monitor quality may enter automation execution-layer P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Run P7-3 alert and decision-support quality review", + "- [ ] Keep local v2rayN/xray running on `192.168.3.73:10808`", + "- [ ] Monitor restricted decision-support output and alert quality", + "- [ ] Treat OKX fills remaining `0` as acceptable in the current analysis/decision-only phase", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + "", + "## Automation Status", + "", + f"- [x] state: {drift.get('state')}", + "- [x] current_phase: Restricted Decision-Support Soft Rollout", + "- [x] latest_p6_tick_status: no_op_healthy", + "- [x] no_new_research_council_phase_created: true", + f"- [x] latest_p7_remote_drift_status: {drift.get('drift_status')}", + f"- [x] latest_p7_remote_drift_check_path: {summary.get('drift_check_path')}", + f"- [x] latest_local_quality_gate_status: {local.get('quality_gate_status')}", + f"- [x] latest_remote_container_quality_gate_status: {remote.get('container_quality_gate_status')}", + "", + "## Newly Done", + "", + "- [x] Completed P7-1 monitor maturity rollup", + "- [x] Compared local and remote monitor quality gate state", + "- [x] Generated analysis decision monitor remote drift check", + "- [x] Closed P7-2 without adding a new Research Council phase", + "- [x] Kept OKX fills out of the current-phase blocker set", + "- [x] Preserved execution, live readiness, risk, trust, and operator confirmation gates", + "", + "## Blocked", + "", + *blocker_lines, + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Operational Dependencies", + "", + "- [ ] Keep `D:\\v2rayN-windows-64\\v2rayN.exe` / `xray.exe` running on the local Windows host", + "- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`", + "", + "## Major Done Milestones", + "", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + "- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor", + "- [x] `automation_execution_layer_p7_monitor_maturity_rollup` P7-1 maturity rollup", + "- [x] `automation_execution_layer_p7_remote_drift_check` P7-2 remote drift check", + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def render_analysis_decision_monitor_maturity_rollup_session(*, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + rollup = dict(summary.get("rollup") or {}) + local = dict(rollup.get("local") or {}) + observed = dict(rollup.get("latest_observed") or {}) + next_slice = str(rollup.get("next_slice") or summary.get("next_slice") or "") + blocker_line = f"- {rollup.get('blocked_reason')}" if rollup.get("blocked_reason") else "- none" + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + "Automation execution-layer P7 monitor maturity rollup is active after the P0-P6 Research Council phase freeze. The system remains analysis/decision-only and real fill verification is deferred because this phase does not do live trading.", + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + f"- state: {rollup.get('state')}", + "- current_phase: Restricted Decision-Support Soft Rollout", + f"- updated_ts: {updated_ts}", + "- latest_p6_tick_status: no_op_healthy", + "- latest_safe_to_auto_continue: true", + f"- latest_p7_maturity_status: {rollup.get('maturity_status')}", + f"- latest_p7_rollup_path: {summary.get('rollup_path')}", + f"- latest_analysis_decision_monitor_history_records: {local.get('record_count')}", + f"- latest_analysis_decision_monitor_consecutive_passes: {local.get('consecutive_pass_count')}", + f"- latest_analysis_decision_monitor_stable_window: {str(local.get('stable_monitoring_window')).lower()}", + f"- latest_analysis_decision_monitor_quality_gate_status: {local.get('quality_gate_status')}", + f"- latest_real_trade_sample_gate_status: {observed.get('real_trade_sample_gate_status')}", + f"- repair_next_action: {next_slice}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Consumed analysis decision monitor history, trend, and quality gate without adding a new monitor sample.", + "- Generated `.claude/analysis_decision_monitor_maturity_rollup.json`.", + "- Closed P7-1 monitor maturity rollup when the stable quality window was mature.", + "- Preserved P0-P6 Research Council freeze semantics and did not create Research Council P7.", + "- Did not wait on OKX fills because real fill verification is deferred to a future real-trading validation phase.", + "- Did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Working State", + "", + "- P7-1 monitor maturity rollup is closed.", + "- Local stable window conclusion: " + str(rollup.get("stable_window_conclusion")), + f"- Local monitor records: {local.get('record_count')}.", + f"- Local monitor consecutive passes: {local.get('consecutive_pass_count')} / {local.get('stable_sample_threshold')}.", + f"- Local quality gate: {local.get('quality_gate_status')}.", + f"- Current allowed lane: {rollup.get('current_allowed_lane')}.", + "- Remote drift check status: pending.", + f"- Current broker status: {observed.get('broker_status')}.", + f"- Current broker fills: {observed.get('broker_fills')}.", + f"- Current runtime health: {observed.get('runtime_health')}.", + f"- Current market gap: {observed.get('market_gap')}.", + f"- Current decision action: {observed.get('decision_action')}.", + f"- Current operator mode: {observed.get('decision_operator_mode')}.", + f"- Current trust classification: {observed.get('decision_trust_classification')}.", + "", + "## P7 Blockers", + "", + blocker_line, + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains a strict unrestricted lane and must not be force-enabled.", + "- The recovered OKX path depends on the local v2rayN/xray process staying up on `192.168.3.73:10808`.", + "- `real_trade_sample_verified` is deferred and not required while the system is analysis/decision-only.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "- P7-2 must compare local and remote monitor maturity before returning to long-running monitoring.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {next_slice}", + "", + "Compare local and remote monitor maturity state. If both sides are passed and stable, continue to P7-3 alert and decision-support quality review; if remote state drifts, route to `repair_analysis_decision_monitor_remote_drift`.", + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_analysis_decision_monitor tests.unit.test_runtime_script_json", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_until_blocked.py", + "```", + "", + ] + ) + + +def render_analysis_decision_monitor_maturity_rollup_tasks(*, summary: dict) -> str: + rollup = dict(summary.get("rollup") or {}) + local = dict(rollup.get("local") or {}) + next_slice = str(rollup.get("next_slice") or summary.get("next_slice") or "") + blocker_lines = ( + [f"- [ ] {rollup.get('blocked_reason')}"] + if rollup.get("blocked_reason") + else [ + "- [ ] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled", + "- [ ] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready", + "- [ ] P7-2 remote drift check is still pending", + ] + ) + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` must not create Research Council P7", + "- [x] Stable monitor quality may enter automation execution-layer P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Run P7-2 local/remote monitor maturity drift check", + "- [ ] Keep local v2rayN/xray running on `192.168.3.73:10808`", + "- [ ] Monitor restricted decision-support output and alert quality", + "- [ ] Treat OKX fills remaining `0` as acceptable in the current analysis/decision-only phase", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + "", + "## Automation Status", + "", + f"- [x] state: {rollup.get('state')}", + "- [x] current_phase: Restricted Decision-Support Soft Rollout", + "- [x] latest_p6_tick_status: no_op_healthy", + "- [x] no_new_research_council_phase_created: true", + f"- [x] latest_p7_maturity_status: {rollup.get('maturity_status')}", + f"- [x] latest_p7_rollup_path: {summary.get('rollup_path')}", + f"- [x] latest_analysis_decision_monitor_history_records: {local.get('record_count')}", + f"- [x] latest_analysis_decision_monitor_consecutive_passes: {local.get('consecutive_pass_count')}", + f"- [x] latest_analysis_decision_monitor_stable_window: {str(local.get('stable_monitoring_window')).lower()}", + f"- [x] latest_analysis_decision_monitor_quality_gate_status: {local.get('quality_gate_status')}", + "", + "## Newly Done", + "", + "- [x] Generated analysis decision monitor maturity rollup", + "- [x] Promoted stable monitor evidence into a P7 operator-consumable state artifact", + "- [x] Closed P7-1 without adding a new Research Council phase", + "- [x] Kept OKX fills out of the current-phase blocker set", + "- [x] Preserved execution, live readiness, risk, trust, and operator confirmation gates", + "", + "## Blocked", + "", + *blocker_lines, + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Operational Dependencies", + "", + "- [ ] Keep `D:\\v2rayN-windows-64\\v2rayN.exe` / `xray.exe` running on the local Windows host", + "- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`", + "", + "## Major Done Milestones", + "", + "- [x] `P0-1` Real-time market truth layer", + "- [x] `P0-2` Real Account And Order Read Sync", + "- [x] `P0-3` Runtime Scheduling And Supervision", + "- [x] `P0-4` Decision Packet And Proactive Alerting", + "- [x] `P0-5` Real Trade Mapping And Post-Trade Loop", + "- [x] `P1-1` Trust Scoring And Model Whitelists", + "- [x] `P1-2` Operator Experience And Workflow Tools", + "- [x] `P1-3` Live Adapter Readiness Prep", + "- [x] `Research Council P0` local gate", + "- [x] `Research Council P1` local gate", + "- [x] `Research Council P2` remote deploy and smoke gate", + "- [x] `Research Council P3` remote deploy and smoke gate", + "- [x] `Research Council P4` remote deploy and smoke gate", + "- [x] `Research Council P5` remote deploy, remote cycle, and review smoke gate", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + "- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor", + "- [x] `automation_execution_layer_p7_monitor_maturity_rollup` P7-1 maturity rollup", + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def render_analysis_decision_monitor_session(*, summary: dict) -> str: + updated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d UTC") + observed = dict(summary.get("observed") or {}) + history = dict(summary.get("monitor_history") or {}) + trend = dict(history.get("trend") or {}) + quality_gate = dict(history.get("quality_gate") or {}) + next_slice = str(summary.get("next_slice") or "") + p7_ready = next_slice == ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE + status_text = "passed" if summary.get("monitor_passed") else "blocked" + blocker_lines = [f"- {item}" for item in summary.get("monitor_blockers") or []] or ["- none"] + warning_lines = [f"- {item}" for item in summary.get("monitor_warnings") or []] or ["- none"] + stable_next_step_text = ( + "Enter P7 monitor maturity rollup. Convert the stable monitoring window into an operator-facing " + "maturity summary, local/remote drift check, alert-quality review, and the next bounded automation slice. " + "This is an automation execution-layer P7, not a new Research Council phase." + if p7_ready + else "Run the analysis/decision monitor again on the next periodic tick. If it passes, continue observation and state recording. If it reports blockers, repair the smallest blocker slice first. Do not wait on OKX fills in this phase." + ) + stable_done_line = ( + "- Stable monitor quality gate passed, so automation opened execution-layer P7 without creating a new Research Council phase." + if p7_ready + else "- Preserved P0-P6 freeze semantics and did not create P7." + ) + stable_working_line = ( + "- A passing stable-window monitor now routes to automation execution-layer P7 instead of looping forever on periodic monitoring." + if p7_ready + else "- A passing monitor closes the current loop and schedules the next periodic monitoring tick." + ) + return "\n".join( + [ + "# ai-exchange Session", + "", + "## Current Goal", + "", + "Automation execution-layer bootstrap is complete after the P0-P6 Research Council phase freeze. The active path is analysis/decision-only restricted decision-support monitoring; real fill verification is deferred because this phase does not do live trading.", + "", + "## Current Branch", + "", + "`main`", + "", + "## Last Updated", + "", + updated_ts, + "", + "## Automation Status", + "", + "- state: analysis_decision_monitor_runner_automation_wired", + "- current_phase: Restricted Decision-Support Soft Rollout", + f"- updated_ts: {updated_ts}", + "- latest_p6_tick_status: no_op_healthy", + "- latest_p6_effective_health_state: healthy", + "- latest_safe_to_auto_continue: true", + f"- latest_analysis_decision_monitor_status: {status_text}", + f"- latest_analysis_decision_monitor_ts: {summary.get('monitor_generated_ts')}", + f"- latest_analysis_decision_monitor_history_path: {history.get('path')}", + f"- latest_analysis_decision_monitor_history_records: {history.get('record_count')}", + f"- latest_analysis_decision_monitor_trend_path: {history.get('trend_path')}", + f"- latest_analysis_decision_monitor_consecutive_passes: {trend.get('consecutive_pass_count')}", + f"- latest_analysis_decision_monitor_stable_window: {str(trend.get('stable_monitoring_window')).lower()}", + f"- latest_analysis_decision_monitor_quality_gate_path: {history.get('quality_gate_path')}", + f"- latest_analysis_decision_monitor_quality_gate_status: {quality_gate.get('status')}", + f"- latest_real_trade_sample_gate_required: {str(observed.get('real_trade_sample_gate_required')).lower()}", + f"- latest_real_trade_sample_gate_status: {observed.get('real_trade_sample_gate_status')}", + f"- repair_next_action: {summary.get('next_slice')}", + "", + "## Completed In This Run", + "", + "- Read the required automation anchors.", + "- Routed `run_until_blocked.py` into the analysis/decision-only monitor when that phase is active.", + "- Ran the single-command analysis decision monitor for launch readiness, broker state, runtime health, decision packet, review packet, and P6 no-new-phase governance.", + "- Wrote machine-consumable automation status and refreshed `SESSION.md` / `TASKS.md`.", + "- Appended the monitor sample to `.claude/analysis_decision_monitor_history.jsonl` for periodic evidence tracking.", + "- Refreshed `.claude/analysis_decision_monitor_trend.json` so periodic monitoring has a stable-window summary.", + "- Refreshed `.claude/analysis_decision_monitor_quality_gate.json` so automation can consume the current-phase gate directly.", + stable_done_line, + "- Did not wait on OKX fills because real fill verification is deferred to a future real-trading validation phase.", + "- Did not change `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.", + "", + "## Current Working State", + "", + "- P0-P6 Research Council automation phases remain complete and frozen.", + "- `scripts/run_until_blocked.py` now treats the analysis/decision monitor as the active execution-layer stage after monitor deployment.", + stable_working_line, + "- A failing monitor routes to the smallest repair slice: `repair_analysis_decision_monitor_blockers`.", + f"- Monitor history records: {history.get('record_count')} at `{history.get('path')}`.", + f"- Monitor consecutive passes: {trend.get('consecutive_pass_count')} / {trend.get('stable_sample_threshold')}.", + f"- Monitor stable window: {str(trend.get('stable_monitoring_window')).lower()}.", + f"- Monitor quality gate: {quality_gate.get('status')}.", + f"- Current monitor result: {status_text}.", + f"- Current broker status: {observed.get('broker_status')}.", + f"- Current broker fills: {observed.get('broker_fills')}.", + f"- Current runtime health: {observed.get('runtime_health')}.", + f"- Current market stream: {observed.get('market_stream')}.", + f"- Current market freshness: {observed.get('market_freshness')}.", + f"- Current market gap: {observed.get('market_gap')}.", + f"- Current decision action: {observed.get('decision_action')}.", + f"- Current operator mode: {observed.get('decision_operator_mode')}.", + f"- Current trust classification: {observed.get('decision_trust_classification')}.", + "", + "## Monitor Blockers", + "", + *blocker_lines, + "", + "## Monitor Warnings", + "", + *warning_lines, + "", + "## Current Risks Or Open Questions", + "", + "- `gstack` / `GSTACK_OK` is not available as a local command or root sentinel in this checkout; this slice used repository-native focused validation.", + "- `decision_support_ready` remains a strict unrestricted lane and must not be force-enabled.", + "- The recovered OKX path depends on the local v2rayN/xray process staying up on `192.168.3.73:10808`.", + "- `real_trade_sample_verified` is deferred and not required while the system is analysis/decision-only.", + "- `real_live_ready` remains intentionally `false` because the OKX order adapter is still placeholder-only.", + "- Large backlog drain remains sensitive to remote PostgreSQL connection timeouts.", + "", + "## Exact Next Step", + "", + "Proceed directly to:", + "", + f"### {next_slice}", + "", + stable_next_step_text, + "", + "## Validation Commands Used Recently", + "", + "```bash", + r"D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_analysis_decision_monitor tests.unit.test_runtime_script_json", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py", + "npm run lint", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\deploy_with_password.py", + r"D:\ai-exchange\.venv\Scripts\python.exe scripts\run_analysis_decision_monitor.py", + "```", + "", + ] + ) + + +def render_analysis_decision_monitor_tasks(*, summary: dict) -> str: + status_text = "passed" if summary.get("monitor_passed") else "blocked" + history = dict(summary.get("monitor_history") or {}) + trend = dict(history.get("trend") or {}) + quality_gate = dict(history.get("quality_gate") or {}) + next_slice = str(summary.get("next_slice") or "") + p7_ready = next_slice == ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE + p7_next_up = ( + "- [ ] Execute P7 monitor maturity rollup before returning to periodic monitoring" + if p7_ready + else "- [ ] Keep P6 governance tick as no-new-phase health gate during monitoring" + ) + p7_done_line = ( + "- [x] Opened automation execution-layer P7 after stable monitor quality gate" + if p7_ready + else "- [x] Preserved P0-P6 freeze semantics and did not create P7" + ) + blocker_lines = ( + [f"- [ ] {summary.get('blocked_reason')}"] + if summary.get("blocked_reason") + else [ + "- [ ] `decision_support_ready` remains strict unrestricted lane and must not be force-enabled", + "- [ ] `real_live_path_ready` remains intentionally disabled until the live adapter is real-live ready", + "- [ ] Large backlog drain remains sensitive to remote PostgreSQL connection timeouts", + ] + ) + return "\n".join( + [ + "# ai-exchange Tasks", + "", + "## Execution Mode", + "", + "- [x] Autonomous mode enabled", + "- [x] No per-stage confirmation required", + "- [x] Each stage must close the loop through implementation, validation, deploy/smoke when possible, and state writeback", + "- [x] P0-P6 Research Council phase expansion is frozen", + "- [x] P6 `no_op_healthy` must not create Research Council P7", + "- [x] Stable monitor quality may enter automation execution-layer P7", + "", + "## In Progress", + "", + f"- [ ] `{next_slice}`", + "", + "## Next Up", + "", + "- [ ] Prefer `scripts/run_analysis_decision_monitor.py` for current-phase smoke", + "- [ ] Keep local v2rayN/xray running on `192.168.3.73:10808`", + "- [ ] Monitor restricted decision-support output and alert quality", + "- [ ] Treat OKX fills remaining `0` as acceptable in the current analysis/decision-only phase", + "- [ ] Keep execution, live readiness, risk, trust, and operator confirmation gates unchanged", + p7_next_up, + "", + "## Automation Status", + "", + "- [x] state: analysis_decision_monitor_runner_automation_wired", + "- [x] current_phase: Restricted Decision-Support Soft Rollout", + "- [x] latest_p6_tick_status: no_op_healthy", + "- [x] latest_p6_effective_health_state: healthy", + "- [x] no_new_phase_created: true", + f"- [x] latest_analysis_decision_monitor_status: {status_text}", + f"- [x] latest_analysis_decision_monitor_ts: {summary.get('monitor_generated_ts')}", + f"- [x] latest_analysis_decision_monitor_history_records: {history.get('record_count')}", + f"- [x] latest_analysis_decision_monitor_consecutive_passes: {trend.get('consecutive_pass_count')}", + f"- [x] latest_analysis_decision_monitor_stable_window: {str(trend.get('stable_monitoring_window')).lower()}", + f"- [x] latest_analysis_decision_monitor_quality_gate_status: {quality_gate.get('status')}", + "", + "## Newly Done", + "", + "- [x] Wired `run_until_blocked.py` to run the analysis/decision monitor for the current phase", + "- [x] Added machine-consumable monitor summary and runner status writeback", + "- [x] Appended periodic analysis decision monitor history", + "- [x] Refreshed periodic monitor trend summary", + "- [x] Refreshed analysis decision monitor quality gate", + "- [x] Refreshed `SESSION.md` and `TASKS.md` from monitor result", + "- [x] Kept OKX fills out of the current-phase blocker set", + p7_done_line, + "- [x] Preserved execution, live readiness, risk, trust, and operator confirmation gates", + "", + "## Blocked", + "", + *blocker_lines, + "", + "## Deferred Until Real-Trading Phase", + "", + "- [ ] `real_trade_sample_verified` is not required in the current analysis/decision-only phase", + "- [ ] Real fill reconciliation should be verified only when the project intentionally enters a real-trading validation phase", + "", + "## Operational Dependencies", + "", + "- [ ] Keep `D:\\v2rayN-windows-64\\v2rayN.exe` / `xray.exe` running on the local Windows host", + "- [ ] Keep remote `.env` proxy path compatible with `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`", + "", + "## Major Done Milestones", + "", + "- [x] `P0-1` Real-time market truth layer", + "- [x] `P0-2` Real Account And Order Read Sync", + "- [x] `P0-3` Runtime Scheduling And Supervision", + "- [x] `P0-4` Decision Packet And Proactive Alerting", + "- [x] `P0-5` Real Trade Mapping And Post-Trade Loop", + "- [x] `P1-1` Trust Scoring And Model Whitelists", + "- [x] `P1-2` Operator Experience And Workflow Tools", + "- [x] `P1-3` Live Adapter Readiness Prep", + "- [x] `Research Council P0` local gate", + "- [x] `Research Council P1` local gate", + "- [x] `Research Council P2` remote deploy and smoke gate", + "- [x] `Research Council P3` remote deploy and smoke gate", + "- [x] `Research Council P4` remote deploy and smoke gate", + "- [x] `Research Council P5` remote deploy, remote cycle, and review smoke gate", + "- [x] `Research Council P6` remote deploy and governance tick smoke gate", + "- [x] `P0-P6` reasonableness review and phase freeze gate", + "- [x] `Automation Execution-Layer P0` governance gate routing", + "- [x] `automation_execution_layer_p1_anchor_slice_selector` Anchor-driven smallest-slice selector", + "- [x] `automation_execution_layer_p2_validation_quality_router` Focused validation and quality-gate router", + "- [x] `automation_execution_layer_p3_deploy_smoke_router` Deploy and smoke routing policy", + "- [x] `automation_execution_layer_p4_state_writeback_renderer` State writeback renderer", + "- [x] `launch_readiness_operator_soft_rollout_lane` Runtime readiness progress lane", + "- [x] `analysis_decision_monitor_runner` Current-phase single-command monitor", + "", + "## Backlog", + "", + "- [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading", + "", + ] + ) + + +def derive_automation_execution_anchor_status(anchor_state: dict) -> dict: + completed = set(anchor_state.get("completed_execution_slices") or []) + for code, state, phase in AUTOMATION_EXECUTION_CLOSED_STATUS_SEQUENCE: + if code in completed: + return {"state": state, "current_phase": phase} + return { + "state": AUTOMATION_EXECUTION_GATE_CLOSED_STATE, + "current_phase": "Automation Execution-Layer P0 Governance Gate", + } + + +def render_completed_execution_slice_lines(anchor_state: dict) -> list[str]: + completed = set(anchor_state.get("completed_execution_slices") or []) + lines = [] + for code, _state, phase in reversed(AUTOMATION_EXECUTION_CLOSED_STATUS_SEQUENCE): + if code in completed: + lines.append(f"- [x] `{phase}`") + return lines or ["- [x] `Automation Execution-Layer P0` governance gate routing"] + + 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 @@ -313,6 +3290,7 @@ def compute_resume_fingerprint() -> str: def write_runner_status(payload: dict) -> None: + RUNNER_STATUS_PATH.parent.mkdir(parents=True, exist_ok=True) RUNNER_STATUS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/scripts/run_visual_review.py b/scripts/run_visual_review.py new file mode 100644 index 0000000..ff0979c --- /dev/null +++ b/scripts/run_visual_review.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.visual_watch import VisualReviewService + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "VISUAL_REVIEW_OUTPUT_JSON", False) + service = VisualReviewService() + watch_run_id = _get_optional_int(env, "VISUAL_REVIEW_WATCH_RUN_ID") + if watch_run_id is not None: + payload = service.review_watch_run( + watch_run_id=watch_run_id, + snapshot_id=_get_optional_int(env, "VISUAL_REVIEW_SNAPSHOT_ID"), + image_path=_get_optional_value(env, "VISUAL_REVIEW_IMAGE_PATH"), + source_name=_get_optional_value(env, "VISUAL_REVIEW_SOURCE_NAME"), + visual_state=_get_optional_value(env, "VISUAL_REVIEW_STATE"), + visual_confidence=_get_optional_float(env, "VISUAL_REVIEW_CONFIDENCE"), + visual_notes=_get_optional_value(env, "VISUAL_REVIEW_NOTES") or "", + ) + else: + payload = service.review_pending_watch_runs( + limit=_get_optional_int(env, "VISUAL_REVIEW_LIMIT") or 1, + instrument_id=_get_optional_int(env, "VISUAL_REVIEW_INSTRUMENT_ID"), + trigger_type=_get_optional_value(env, "VISUAL_REVIEW_TRIGGER_TYPE"), + image_path=_get_optional_value(env, "VISUAL_REVIEW_IMAGE_PATH"), + source_name=_get_optional_value(env, "VISUAL_REVIEW_SOURCE_NAME"), + ) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Visual review status: {payload.get('status')}") + print(f"Attempted: {payload.get('attempted', 1)} / Reviewed: {payload.get('reviewed', 1)}") + for item in payload.get("items") or []: + print( + f"- watch_run_id={item.get('watch_run_id')} " + f"state={item.get('visual_state')} snapshot_id={item.get('snapshot_id')}" + ) + if item.get("snapshot_image_path"): + print(f" image_path={item.get('snapshot_image_path')}") + if item.get("snapshot_artifact_path"): + print(f" artifact_path={item.get('snapshot_artifact_path')}") + return 0 if payload.get("status") not in {"failed"} else 2 + + +def _get_bool(env, key: str, default: bool = False) -> 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_optional_int(env, key: str): + value = env.get(key) + if value is None or value == "": + return None + return int(value) + + +def _get_optional_float(env, key: str): + value = env.get(key) + if value is None or value == "": + return None + return float(value) + + +def _get_optional_value(env, key: str): + value = env.get(key) + if value is None or value == "": + return None + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_visual_snapshot_capture.py b/scripts/run_visual_snapshot_capture.py new file mode 100644 index 0000000..35bacae --- /dev/null +++ b/scripts/run_visual_snapshot_capture.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.api.visual_watch import capture_visual_watch_snapshot + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "VISUAL_CAPTURE_OUTPUT_JSON", False) + payload = capture_visual_watch_snapshot( + watch_run_id=_get_optional_int(env, "VISUAL_CAPTURE_WATCH_RUN_ID"), + instrument_id=_get_optional_int(env, "VISUAL_CAPTURE_INSTRUMENT_ID"), + trigger_type=_get_optional_value(env, "VISUAL_CAPTURE_TRIGGER_TYPE"), + source_name=_get_optional_value(env, "VISUAL_CAPTURE_SOURCE_NAME"), + output_dir=_get_optional_value(env, "VISUAL_CAPTURE_OUTPUT_DIR"), + ) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Visual snapshot status: {payload.get('status')}") + print(f"Watch run: {payload.get('watch_run_id')}") + if payload.get("snapshot_id") is not None: + print(f"Snapshot id: {payload.get('snapshot_id')}") + if payload.get("image_path"): + print(f"Image path: {payload.get('image_path')}") + if payload.get("artifact_path"): + print(f"Artifact path: {payload.get('artifact_path')}") + if payload.get("reason"): + print(f"Reason: {payload.get('reason')}") + return 0 if payload.get("status") not in {"failed"} else 2 + + +def _get_bool(env, key: str, default: bool = False) -> 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_optional_int(env, key: str): + value = env.get(key) + if value is None or value == "": + return None + return int(value) + + +def _get_optional_value(env, key: str): + value = env.get(key) + if value is None or value == "": + return None + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_webhook_capture.py b/scripts/run_webhook_capture.py new file mode 100644 index 0000000..af0db3d --- /dev/null +++ b/scripts/run_webhook_capture.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +CAPTURE_FILE = ROOT / "tmp_webhook_capture.jsonl" + + +class CaptureHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = 0 + raw_body = self.rfile.read(length) if length else b"" + record = { + "path": self.path, + "headers": {key: value for key, value in self.headers.items()}, + "body": _decode_json(raw_body), + } + with CAPTURE_FILE.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + payload = json.dumps({"ok": True, "captured": True}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("x-request-id", "local-capture") + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args) -> None: + return + + +def main(env=None) -> int: + env = os.environ if env is None else env + host = env.get("WEBHOOK_CAPTURE_HOST", "127.0.0.1") + port = int(env.get("WEBHOOK_CAPTURE_PORT", "8787")) + server = ThreadingHTTPServer((host, port), CaptureHandler) + print(f"Webhook capture listening on http://{host}:{port}") + server.serve_forever() + return 0 + + +def _decode_json(raw_body: bytes): + if not raw_body: + return {} + try: + return json.loads(raw_body.decode("utf-8")) + except Exception: + return {"raw": raw_body.decode("utf-8", errors="replace")} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/__init__.py b/src/api/__init__.py index 4d41f7f..09bd7e1 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -22,6 +22,7 @@ from src.api.trading_runtime import ( generate_trading_plan, get_trading_decision_alerts, get_trading_decision_packet, + run_trading_alert_dispatch, get_latest_trading_plan, get_trading_candidate_by_id, get_trading_market_state, @@ -46,6 +47,31 @@ from src.api.trading_runtime import ( ) from src.api.trading_journal import get_recent_trading_journal, get_trading_journal_summary from src.api.trading_plans import get_recent_trading_plans +from src.api.alerts import dispatch_pending_alerts, get_alert_deliveries +from src.api.feedback import ( + create_alert_feedback, + create_visual_review_feedback, + get_alert_feedback, + get_visual_review_feedback, +) +from src.api.research_council import ( + get_latest_research_council_optimization_cycle, + get_latest_research_council_run, + get_recent_research_council_optimization_cycles, + get_recent_research_council_runs, + get_research_council_optimization_review, + run_research_council_governance_tick, + run_research_council_optimization_cycle, + run_research_council, +) +from src.api.visual_watch import ( + capture_visual_watch_snapshot, + get_latest_candidate_snapshot, + get_latest_visual_watch_run, + get_visual_reviews_for_packet, + review_pending_visual_watch_runs, + review_visual_watch_run, +) __all__ = [ "get_health", @@ -92,6 +118,26 @@ __all__ = [ "close_execution", "sync_execution", "get_recent_backtest_summaries", + "dispatch_pending_alerts", + "get_alert_deliveries", + "create_alert_feedback", + "create_visual_review_feedback", + "get_alert_feedback", + "get_visual_review_feedback", + "get_latest_research_council_run", + "get_latest_research_council_optimization_cycle", + "get_latest_candidate_snapshot", + "get_latest_visual_watch_run", + "capture_visual_watch_snapshot", + "review_pending_visual_watch_runs", + "review_visual_watch_run", + "get_recent_research_council_runs", + "get_recent_research_council_optimization_cycles", + "get_research_council_optimization_review", + "run_research_council_governance_tick", + "get_visual_reviews_for_packet", + "run_research_council", + "run_research_council_optimization_cycle", "route_request", "serve_api", ] diff --git a/src/api/alerts.py b/src/api/alerts.py new file mode 100644 index 0000000..20b8f2b --- /dev/null +++ b/src/api/alerts.py @@ -0,0 +1,12 @@ +from src.repositories.alert_delivery_repository import AlertDeliveryRepository +from src.services.alerts import AlertDispatcherService + + + +def dispatch_pending_alerts(limit: int = 50) -> dict: + return AlertDispatcherService().dispatch_pending(limit=limit) + + + +def get_alert_deliveries(alert_id: int, limit: int = 20) -> list[dict]: + return AlertDeliveryRepository().fetch_recent_deliveries(alert_id=alert_id, limit=limit) diff --git a/src/api/feedback.py b/src/api/feedback.py new file mode 100644 index 0000000..5bf7627 --- /dev/null +++ b/src/api/feedback.py @@ -0,0 +1,43 @@ +from src.services.feedback import AlertFeedbackService + + +def create_alert_feedback( + *, + alert_id: int, + label: str, + note: str | None = None, + created_by: str = "local_operator", + payload: dict | None = None, +) -> dict: + return AlertFeedbackService().record_alert_feedback( + alert_id=alert_id, + label=label, + note=note, + created_by=created_by, + payload=payload or {}, + ) + + +def get_alert_feedback(alert_id: int, limit: int = 20) -> list[dict]: + return AlertFeedbackService().list_alert_feedback(alert_id=alert_id, limit=limit) + + +def create_visual_review_feedback( + *, + visual_review_id: int, + label: str, + note: str | None = None, + created_by: str = "local_operator", + payload: dict | None = None, +) -> dict: + return AlertFeedbackService().record_visual_review_feedback( + visual_review_id=visual_review_id, + label=label, + note=note, + created_by=created_by, + payload=payload or {}, + ) + + +def get_visual_review_feedback(visual_review_id: int, limit: int = 20) -> list[dict]: + return AlertFeedbackService().list_visual_review_feedback(visual_review_id=visual_review_id, limit=limit) diff --git a/src/api/research_council.py b/src/api/research_council.py new file mode 100644 index 0000000..471cced --- /dev/null +++ b/src/api/research_council.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Optional + +from src.services.research_council import ( + CouncilGovernanceTickService, + CouncilOptimizationReviewService, + CouncilOptimizationService, + ResearchCouncilService, +) + + +def run_research_council( + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE", +) -> dict: + return ResearchCouncilService().build_and_persist( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + ) + + +def get_latest_research_council_run(instrument_id: int) -> Optional[dict]: + return ResearchCouncilService().get_latest_run(instrument_id=instrument_id) + + +def get_recent_research_council_runs(instrument_id: int, limit: int = 20) -> list[dict]: + return ResearchCouncilService().get_recent_runs(instrument_id=instrument_id, limit=limit) + + +def run_research_council_optimization_cycle( + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE", + replay_file: Optional[str] = None, + eval_limit: int = 20, + lesson_limit: int = 100, +) -> dict: + return CouncilOptimizationService().run_cycle( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + eval_limit=eval_limit, + lesson_limit=lesson_limit, + ) + + +def get_latest_research_council_optimization_cycle(instrument_id: int) -> Optional[dict]: + return CouncilOptimizationService().get_latest_cycle(instrument_id=instrument_id) + + +def get_recent_research_council_optimization_cycles(instrument_id: int, limit: int = 20) -> list[dict]: + return CouncilOptimizationService().get_recent_cycles(instrument_id=instrument_id, limit=limit) + + +def get_research_council_optimization_review( + *, + instrument_id: int, + limit: int = 5, + stale_after_hours: float = 24.0, +) -> dict: + return CouncilOptimizationReviewService().build_review( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + ) + + +def run_research_council_governance_tick( + *, + instrument_id: int, + limit: int = 5, + stale_after_hours: float = 24.0, + auto_run_stale_cycle: bool = False, + force_cycle: bool = False, + timeframe: str = "1m", + model_code: str = "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE", + replay_file: Optional[str] = None, + eval_limit: int = 20, + lesson_limit: int = 100, +) -> dict: + return CouncilGovernanceTickService().run_tick( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + auto_run_stale_cycle=auto_run_stale_cycle, + force_cycle=force_cycle, + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + eval_limit=eval_limit, + lesson_limit=lesson_limit, + ) diff --git a/src/api/server.py b/src/api/server.py index ad0eb78..a8ac0db 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -10,7 +10,34 @@ from src.api.candles import get_recent_candles from src.api.execution_tickets import get_recent_execution_tickets from src.api.health import get_health from src.api.live_operation_audits import get_recent_live_operation_audits +from src.api.alerts import get_alert_deliveries +from src.api.feedback import ( + create_alert_feedback, + create_visual_review_feedback, + get_alert_feedback, + get_visual_review_feedback, +) +from src.api.research_council import ( + get_latest_research_council_optimization_cycle, + get_latest_research_council_run, + get_recent_research_council_optimization_cycles, + get_recent_research_council_runs, + get_research_council_optimization_review, + run_research_council_governance_tick, + run_research_council_optimization_cycle, + run_research_council, +) from src.api.trade_plan_candidates import get_recent_trade_plan_candidates +from src.api.visual_watch import ( + capture_visual_watch_snapshot, + create_visual_review, + create_visual_snapshot, + get_latest_candidate_snapshot, + get_latest_visual_watch_run, + get_visual_reviews_for_packet, + review_pending_visual_watch_runs, + review_visual_watch_run, +) from src.api.trading_runtime import ( activate_trading_plan, build_ticket_from_candidate, @@ -40,6 +67,7 @@ from src.api.trading_runtime import ( rerun_ticket_risk_check, run_candidate_confirm_check, create_trading_operator_note, + run_trading_alert_dispatch, run_trading_reconciliation, run_trading_runtime_once, run_trading_watch, @@ -81,6 +109,7 @@ def build_api_dependencies() -> dict: "trading_market_state": get_trading_market_state, "trading_decision_packet": get_trading_decision_packet, "trading_decision_alerts": get_trading_decision_alerts, + "trading_alert_dispatch": run_trading_alert_dispatch, "trading_reconciliation_run": run_trading_reconciliation, "trading_runtime_status": get_trading_runtime_status, "trading_runtime_run_once": run_trading_runtime_once, @@ -89,9 +118,27 @@ def build_api_dependencies() -> dict: "trading_operator_notes": get_trading_operator_notes, "trading_operator_note_create": create_trading_operator_note, "trading_review_packet": get_trading_review_packet, + "trading_research_council_latest": get_latest_research_council_run, + "trading_research_council_run": run_research_council, + "trading_research_council_runs": get_recent_research_council_runs, + "trading_research_council_optimization_run": run_research_council_optimization_cycle, + "trading_research_council_optimization_latest": get_latest_research_council_optimization_cycle, + "trading_research_council_optimization_cycles": get_recent_research_council_optimization_cycles, + "trading_research_council_optimization_review": get_research_council_optimization_review, + "trading_research_council_governance_tick": run_research_council_governance_tick, "trading_live_readiness": get_trading_live_readiness, "trading_launch_readiness": get_trading_launch_readiness, "trading_watch_run": run_trading_watch, + "trading_visual_watch_latest": get_latest_visual_watch_run, + "trading_visual_candidate_snapshot": get_latest_candidate_snapshot, + "trading_visual_packet_reviews": get_visual_reviews_for_packet, + "trading_visual_capture_snapshot": capture_visual_watch_snapshot, + "trading_visual_review_pending": review_pending_visual_watch_runs, + "trading_visual_review_run": review_visual_watch_run, + "trading_alert_feedback_create": create_alert_feedback, + "trading_alert_feedback_list": get_alert_feedback, + "trading_visual_review_feedback_create": create_visual_review_feedback, + "trading_visual_review_feedback_list": get_visual_review_feedback, "trading_candidate_by_id": get_trading_candidate_by_id, "trading_candidate_confirm_check": run_candidate_confirm_check, "trading_candidate_build_ticket": build_ticket_from_candidate, @@ -202,6 +249,27 @@ def route_request( instrument_id = _require_int(query, "instrument_id") limit = _get_int(query, "limit", 20) return 200, {"items": deps["trading_decision_alerts"](instrument_id=instrument_id, limit=limit)} + if method == "POST" and path == "/trading/alerts/dispatch": + limit = _get_body_optional_int(body, "limit") or 50 + return 200, deps["trading_alert_dispatch"](limit=limit) + if method == "GET" and len(path_parts) == 4 and path_parts[:2] == ["trading", "alerts"] and path_parts[3] == "deliveries": + alert_id = _parse_path_int(path_parts[2], "alert_id") + return 200, {"items": get_alert_deliveries(alert_id=alert_id, limit=_get_int(query, "limit", 20))} + if method == "GET" and len(path_parts) == 4 and path_parts[:2] == ["trading", "alerts"] and path_parts[3] == "feedback": + alert_id = _parse_path_int(path_parts[2], "alert_id") + return 200, {"items": deps["trading_alert_feedback_list"](alert_id=alert_id, limit=_get_int(query, "limit", 20))} + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "alerts"] and path_parts[3] == "feedback": + alert_id = _parse_path_int(path_parts[2], "alert_id") + payload = deps["trading_alert_feedback_create"]( + alert_id=alert_id, + label=_get_body_value(body, "label", ""), + note=_get_body_value(body, "note", ""), + created_by=_get_body_value(body, "created_by", "local_operator"), + payload=body.get("payload") if isinstance(body, dict) else None, + ) + if payload.get("status") == "ignored": + return 404 if payload.get("reason") == "alert_missing" else 400, payload + return 201, payload if method == "POST" and path == "/trading/reconcile": instrument_id = _require_json_int(body or {}, "instrument_id") return 200, deps["trading_reconciliation_run"](instrument_id=instrument_id) @@ -233,6 +301,165 @@ def route_request( if method == "GET" and path == "/trading/review-packet": instrument_id = _require_int(query, "instrument_id") return 200, deps["trading_review_packet"](instrument_id=instrument_id) + if method == "GET" and path == "/trading/research-council/latest": + instrument_id = _require_int(query, "instrument_id") + payload = deps["trading_research_council_latest"](instrument_id=instrument_id) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "GET" and path == "/trading/research-council/runs": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["trading_research_council_runs"](instrument_id=instrument_id, limit=limit)} + if method == "POST" and path == "/trading/research-council/run": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_research_council_run"]( + instrument_id=instrument_id, + timeframe=_get_body_value(body, "timeframe", "1m"), + model_code=_get_body_value(body, "model_code", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"), + ) + if method == "POST" and path == "/trading/research-council/optimization-cycle": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_research_council_optimization_run"]( + instrument_id=instrument_id, + timeframe=_get_body_value(body, "timeframe", "1m"), + model_code=_get_body_value(body, "model_code", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"), + replay_file=_get_body_value(body, "replay_file"), + eval_limit=_get_body_int(body, "eval_limit", 20), + lesson_limit=_get_body_int(body, "lesson_limit", 100), + ) + if method == "GET" and path == "/trading/research-council/optimization-cycle/latest": + instrument_id = _require_int(query, "instrument_id") + payload = deps["trading_research_council_optimization_latest"](instrument_id=instrument_id) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "GET" and path == "/trading/research-council/optimization-cycles": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["trading_research_council_optimization_cycles"](instrument_id=instrument_id, limit=limit)} + if method == "GET" and path == "/trading/research-council/optimization-review": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 5) + stale_after_hours = float(_get_single(query, "stale_after_hours", "24")) + return 200, deps["trading_research_council_optimization_review"]( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + ) + if method == "POST" and path == "/trading/research-council/governance-tick": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_research_council_governance_tick"]( + instrument_id=instrument_id, + limit=_get_body_int(body, "limit", 5), + stale_after_hours=_get_body_float(body, "stale_after_hours", 24.0), + auto_run_stale_cycle=_get_body_bool(body, "auto_run_stale_cycle", False), + force_cycle=_get_body_bool(body, "force_cycle", False), + timeframe=_get_body_value(body, "timeframe", "1m"), + model_code=_get_body_value(body, "model_code", "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"), + replay_file=_get_body_value(body, "replay_file"), + eval_limit=_get_body_int(body, "eval_limit", 20), + lesson_limit=_get_body_int(body, "lesson_limit", 100), + ) + if method == "GET" and path == "/trading/visual-watch/latest": + instrument_id = _require_int(query, "instrument_id") + trigger_type = _get_single(query, "trigger_type", "candidate_actionable_watch") + payload = deps["trading_visual_watch_latest"](instrument_id=instrument_id, trigger_type=trigger_type) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "GET" and path == "/trading/visual-watch/candidate-snapshot": + candidate_id = _require_int(query, "candidate_id") + payload = deps["trading_visual_candidate_snapshot"](candidate_id=candidate_id) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "GET" and path == "/trading/visual-watch/reviews": + decision_packet_id = _require_int(query, "decision_packet_id") + limit = _get_int(query, "limit", 10) + return 200, {"items": deps["trading_visual_packet_reviews"](decision_packet_id=decision_packet_id, limit=limit)} + if method == "GET" and len(path_parts) == 5 and path_parts[:3] == ["trading", "visual-watch", "reviews"] and path_parts[4] == "feedback": + review_id = _parse_path_int(path_parts[3], "review_id") + return 200, {"items": deps["trading_visual_review_feedback_list"](visual_review_id=review_id, limit=_get_int(query, "limit", 20))} + if method == "POST" and len(path_parts) == 5 and path_parts[:3] == ["trading", "visual-watch", "reviews"] and path_parts[4] == "feedback": + review_id = _parse_path_int(path_parts[3], "review_id") + payload = deps["trading_visual_review_feedback_create"]( + visual_review_id=review_id, + label=_get_body_value(body, "label", ""), + note=_get_body_value(body, "note", ""), + created_by=_get_body_value(body, "created_by", "local_operator"), + payload=body.get("payload") if isinstance(body, dict) else None, + ) + if payload.get("status") == "ignored": + return 404 if payload.get("reason") == "visual_review_missing" else 400, payload + return 201, payload + if method == "POST" and path == "/trading/visual-watch/capture": + watch_run_id = _get_body_optional_int(body, "watch_run_id") + instrument_id = _get_body_optional_int(body, "instrument_id") + if watch_run_id is None and instrument_id is None: + raise ValueError("missing_required_body_field:watch_run_id_or_instrument_id") + payload = deps["trading_visual_capture_snapshot"]( + watch_run_id=watch_run_id, + instrument_id=instrument_id, + trigger_type=_get_body_value(body, "trigger_type", None), + source_name=_get_body_value(body, "source_name", None), + output_dir=_get_body_value(body, "output_dir", None), + ) + if payload.get("status") == "ignored": + return 404, {"error": payload.get("reason") or "not_found"} + if payload.get("status") == "failed": + return 500, payload + return 201, payload + if method == "POST" and path == "/trading/visual-watch/snapshots": + watch_run_id = _require_json_int(body or {}, "watch_run_id") + image_path = _get_body_value(body, "image_path", "") + if not image_path: + raise ValueError("missing_required_body_field:image_path") + return 201, create_visual_snapshot( + watch_run_id=watch_run_id, + image_path=image_path, + source_name=_get_body_value(body, "source_name", "manual_capture"), + captured_ts=_get_body_value(body, "captured_ts"), + status=_get_body_value(body, "status", "captured"), + payload=body.get("payload") if isinstance(body, dict) else None, + ) + if method == "POST" and path == "/trading/visual-watch/reviews": + watch_run_id = _require_json_int(body or {}, "watch_run_id") + visual_state = _get_body_value(body, "visual_state", "") + if not visual_state: + raise ValueError("missing_required_body_field:visual_state") + return 201, create_visual_review( + watch_run_id=watch_run_id, + snapshot_id=_get_body_optional_int(body, "snapshot_id"), + visual_state=visual_state, + visual_confidence=_get_body_float(body, "visual_confidence", None), + visual_notes=_get_body_value(body, "visual_notes", ""), + ui_anomalies=_get_body_list(body, "ui_anomalies"), + reviewed_ts=_get_body_value(body, "reviewed_ts"), + payload=body.get("payload") if isinstance(body, dict) else None, + ) + if method == "POST" and path == "/trading/visual-watch/review": + watch_run_id = _require_json_int(body or {}, "watch_run_id") + return 201, deps["trading_visual_review_run"]( + watch_run_id=watch_run_id, + snapshot_id=_get_body_optional_int(body, "snapshot_id"), + image_path=_get_body_value(body, "image_path", None), + source_name=_get_body_value(body, "source_name", None), + visual_state=_get_body_value(body, "visual_state", None), + visual_confidence=_get_body_optional_float(body, "visual_confidence"), + visual_notes=_get_body_value(body, "visual_notes", ""), + ui_anomalies=_get_body_list(body, "ui_anomalies"), + reviewed_ts=_get_body_value(body, "reviewed_ts"), + payload=body.get("payload") if isinstance(body, dict) else None, + ) + if method == "POST" and path == "/trading/visual-watch/review-pending": + return 200, deps["trading_visual_review_pending"]( + limit=_get_body_int(body, "limit", 10), + instrument_id=_get_body_optional_int(body, "instrument_id"), + trigger_type=_get_body_value(body, "trigger_type", None), + image_path=_get_body_value(body, "image_path", None), + source_name=_get_body_value(body, "source_name", None), + ) if method == "GET" and path == "/trading/live-readiness": instrument_id = _require_int(query, "instrument_id") timeframe = _get_single(query, "timeframe", "1m") @@ -561,6 +788,20 @@ def _get_body_float(body: Optional[dict], key: str, default: float) -> float: return float(value) +def _get_body_optional_float(body: Optional[dict], key: str): + if not isinstance(body, dict): + return None + value = body.get(key) + if value is None or value == "": + return None + return float(value) + + +def _get_body_int(body: Optional[dict], key: str, default: int) -> int: + value = _get_body_value(body, key, default) + return int(value) + + def _get_body_optional_int(body: Optional[dict], key: str): if not isinstance(body, dict): return None diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index 0345f4e..6f0645a 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -383,6 +383,14 @@ def get_trading_decision_alerts(instrument_id: int, limit: int = 20) -> list[dic return DecisionPacketService().fetch_recent_alerts(instrument_id=instrument_id, limit=limit) + +def run_trading_alert_dispatch(limit: int = 50) -> dict: + from src.services.alerts import AlertDispatcherService + + return AlertDispatcherService().dispatch_pending(limit=limit) + + + def run_trading_reconciliation(instrument_id: int) -> dict: return BrokerExecutionReconciliationService().reconcile_instrument(instrument_id=instrument_id) diff --git a/src/api/visual_watch.py b/src/api/visual_watch.py new file mode 100644 index 0000000..7d95d9d --- /dev/null +++ b/src/api/visual_watch.py @@ -0,0 +1,183 @@ +from src.repositories.visual_snapshot_repository import VisualSnapshotRepository +from src.services.feedback import AlertFeedbackService +from src.services.visual_watch import ( + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VisualReviewService, + VisualSnapshotCaptureService, + VisualWatchService, +) + + +def get_latest_visual_watch_run(instrument_id: int, trigger_type: str) -> dict | None: + return VisualSnapshotRepository().fetch_latest_watch_run(instrument_id=instrument_id, trigger_type=trigger_type) + + +def get_latest_candidate_snapshot(candidate_id: int) -> dict | None: + return VisualSnapshotRepository().fetch_latest_snapshot_for_candidate(candidate_id=candidate_id) + + +def get_visual_reviews_for_packet(decision_packet_id: int, limit: int = 10) -> list[dict]: + repository = VisualSnapshotRepository() + feedback_service = AlertFeedbackService() + reviews = repository.fetch_reviews_for_packet(decision_packet_id=decision_packet_id, limit=limit) + items: list[dict] = [] + for review in reviews: + enriched = dict(review) + review_id = enriched.get("id") + feedback_rows: list[dict] = [] + if review_id is not None: + try: + feedback_rows = feedback_service.list_visual_review_feedback(int(review_id), limit=20) + except Exception: + feedback_rows = [] + enriched["feedback"] = feedback_rows + enriched["feedback_summary"] = feedback_service.summarize_feedback(feedback_rows) + items.append(enriched) + return items + + +def review_visual_watch_run( + *, + watch_run_id: int, + snapshot_id: int | None = None, + image_path: str | None = None, + source_name: str | None = None, + visual_state: str | None = None, + visual_confidence: float | None = None, + visual_notes: str = "", + ui_anomalies: list | None = None, + reviewed_ts=None, + payload: dict | None = None, +) -> dict: + return VisualReviewService().review_watch_run( + watch_run_id=watch_run_id, + snapshot_id=snapshot_id, + image_path=image_path, + source_name=source_name, + visual_state=visual_state, + visual_confidence=visual_confidence, + visual_notes=visual_notes, + ui_anomalies=ui_anomalies or [], + reviewed_ts=reviewed_ts, + payload=payload or {}, + ) + + +def review_pending_visual_watch_runs( + *, + limit: int = 10, + instrument_id: int | None = None, + trigger_type: str | None = None, + image_path: str | None = None, + source_name: str | None = None, +) -> dict: + return VisualReviewService().review_pending_watch_runs( + limit=limit, + instrument_id=instrument_id, + trigger_type=trigger_type, + image_path=image_path, + source_name=source_name, + ) + + +def capture_visual_watch_snapshot( + *, + watch_run_id: int | None = None, + instrument_id: int | None = None, + trigger_type: str | None = None, + source_name: str | None = None, + output_dir: str | None = None, +) -> dict: + repository = VisualSnapshotRepository() + watch_run = None + if watch_run_id is not None: + watch_run = repository.fetch_watch_run_by_id(watch_run_id) + elif instrument_id is not None: + watch_run = repository.fetch_latest_watch_run( + instrument_id=instrument_id, + trigger_type=trigger_type or VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + ) + if not watch_run: + return { + "status": "ignored", + "reason": "watch_run_missing", + "watch_run_id": watch_run_id, + "instrument_id": instrument_id, + } + + capture = VisualSnapshotCaptureService().capture_watch_run_snapshot( + watch_run=watch_run, + source_name=source_name, + output_dir=output_dir, + ) + if capture.get("status") != "captured": + return capture + try: + snapshot = VisualWatchService(repository=repository).record_snapshot( + { + "watch_run_id": watch_run["id"], + "image_path": capture.get("image_path"), + "source_name": capture.get("source_name") or source_name or "auto_visual_watch_capture", + "captured_ts": capture.get("captured_ts"), + "status": capture.get("status") or "captured", + "payload": capture.get("payload") or {}, + } + ) + except Exception as exc: + return { + "status": "failed", + "reason": "snapshot_record_failed", + "watch_run_id": watch_run["id"], + "error_type": exc.__class__.__name__, + "error": str(exc), + "capture": capture, + } + capture["snapshot_id"] = snapshot.get("snapshot_id") + capture["snapshot_status"] = snapshot.get("status") + return capture + + +def create_visual_snapshot( + *, + watch_run_id: int, + image_path: str, + source_name: str = "manual_capture", + captured_ts=None, + status: str = "captured", + payload: dict | None = None, +) -> dict: + return VisualWatchService().record_snapshot( + { + "watch_run_id": watch_run_id, + "image_path": image_path, + "source_name": source_name, + "captured_ts": captured_ts, + "status": status, + "payload": payload or {}, + } + ) + + +def create_visual_review( + *, + watch_run_id: int, + visual_state: str, + snapshot_id: int | None = None, + visual_confidence: float | None = None, + visual_notes: str = "", + ui_anomalies: list | None = None, + reviewed_ts=None, + payload: dict | None = None, +) -> dict: + return VisualWatchService().record_visual_review( + { + "watch_run_id": watch_run_id, + "snapshot_id": snapshot_id, + "visual_state": visual_state, + "visual_confidence": visual_confidence, + "visual_notes": visual_notes, + "ui_anomalies": ui_anomalies or [], + "reviewed_ts": reviewed_ts, + "payload": payload or {}, + } + ) diff --git a/src/config/settings.py b/src/config/settings.py index 8f7e83b..c507e96 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -1,12 +1,44 @@ +from pathlib import Path +from urllib.parse import quote + from pydantic_settings import BaseSettings, SettingsConfigDict +ROOT = Path(__file__).resolve().parents[2] + + +def _compose_database_url(values: dict) -> str: + explicit = str(values.get("database_url") or "").strip() + if explicit: + return explicit + host = str(values.get("database_host") or "").strip() + port = str(values.get("database_port") or "").strip() + name = str(values.get("database_name") or "").strip() + user = str(values.get("database_user") or "").strip() + password = str(values.get("database_password") or "") + if not all([host, port, name, user]): + return "" + return f"postgresql+psycopg://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{name}" + + class Settings(BaseSettings): - database_url: str + database_url: str = "" + database_host: str = "" + database_port: int = 5432 + database_name: str = "" + database_user: str = "" + database_password: str = "" database_connect_timeout: int = 5 + + def model_post_init(self, __context) -> None: + if not self.database_url: + self.database_url = _compose_database_url(self.__dict__) + if not self.database_url: + raise ValueError("database_url_missing") okx_base_url: str = "https://www.okx.com" okx_timeout: int = 30 okx_ws_public_url: str = "wss://ws.okx.com:8443/ws/v5/public" + okx_ws_business_url: str = "wss://ws.okx.com:8443/ws/v5/business" live_trading_enabled: bool = False okx_order_base_url: str = "https://www.okx.com" okx_api_key: str = "" @@ -43,7 +75,7 @@ class Settings(BaseSettings): news_event_pre_window_minutes: int = 15 news_event_post_window_minutes: int = 15 news_event_high_impact_only: bool = True - launch_require_real_trade_sample: bool = True + launch_require_real_trade_sample: bool = False live_submit_strategy: str = "blocked_until_verified" trading_runtime_enabled: bool = True trading_runtime_symbols: str = "BTC-USDT,ETH-USDT" @@ -54,13 +86,24 @@ class Settings(BaseSettings): trading_runtime_broker_sync_interval_seconds: int = 30 trading_runtime_reconciliation_interval_seconds: int = 45 trading_runtime_watch_interval_seconds: int = 20 + trading_runtime_research_council_interval_seconds: int = 60 trading_runtime_decision_packet_interval_seconds: int = 20 trading_runtime_plan_interval_seconds: int = 900 + alert_dispatch_enabled: bool = True + alert_dispatch_interval_seconds: int = 20 + alert_dispatch_max_per_run: int = 25 + alert_resend_cooldown_seconds: int = 300 + alert_default_channel: str = "workbench" + alert_webhook_url: str = "" default_timezone: str = "UTC" session_timezone: str = "America/New_York" log_level: str = "INFO" - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + model_config = SettingsConfigDict( + env_file=(str(ROOT / ".env.local"), str(ROOT / ".env")), + env_file_encoding="utf-8", + extra="ignore", + ) settings = Settings() diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index ae29fb2..40a6212 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -1,5 +1,6 @@ """Repository layer package.""" +from src.repositories.alert_feedback_repository import AlertFeedbackRepository from src.repositories.backtest_repository import BacktestRepository from src.repositories.bias_repository import BiasRepository from src.repositories.candle_repository import CandleRepository @@ -10,6 +11,12 @@ from src.repositories.fvg_repository import FVGRepository from src.repositories.liquidity_pool_repository import LiquidityPoolRepository from src.repositories.market_state_repository import MarketStateRepository from src.repositories.market_session_repository import MarketSessionRepository +from src.repositories.research_council_checkpoint_repository import ResearchCouncilCheckpointRepository +from src.repositories.research_council_lesson_repository import ResearchCouncilLessonRepository +from src.repositories.research_council_link_repository import ResearchCouncilLinkRepository +from src.repositories.research_council_message_repository import ResearchCouncilMessageRepository +from src.repositories.research_council_optimization_repository import ResearchCouncilOptimizationRepository +from src.repositories.research_council_run_repository import ResearchCouncilRunRepository from src.repositories.signal_repository import SignalRepository from src.repositories.structure_repository import StructureRepository from src.repositories.swing_repository import SwingRepository @@ -19,6 +26,7 @@ from src.repositories.trading_plan_repository import TradingPlanRepository from src.repositories.training_case_repository import TrainingCaseRepository __all__ = [ + "AlertFeedbackRepository", "BacktestRepository", "BiasRepository", "CandleRepository", @@ -29,6 +37,12 @@ __all__ = [ "LiquidityPoolRepository", "MarketStateRepository", "MarketSessionRepository", + "ResearchCouncilCheckpointRepository", + "ResearchCouncilLessonRepository", + "ResearchCouncilLinkRepository", + "ResearchCouncilMessageRepository", + "ResearchCouncilOptimizationRepository", + "ResearchCouncilRunRepository", "SignalRepository", "StructureRepository", "SwingRepository", diff --git a/src/repositories/alert_delivery_repository.py b/src/repositories/alert_delivery_repository.py new file mode 100644 index 0000000..18ddb4a --- /dev/null +++ b/src/repositories/alert_delivery_repository.py @@ -0,0 +1,139 @@ +import json + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class AlertDeliveryRepository: + def fetch_latest_delivery(self, *, alert_id: int, channel: str | None = None) -> dict | None: + if channel is None: + sql = text( + """ + select id, alert_id, channel, status, attempted_ts, delivered_ts, + external_message_id, error, payload + from alert_deliveries + where alert_id = :alert_id + order by attempted_ts desc, id desc + limit 1 + """ + ) + params = {"alert_id": alert_id} + else: + sql = text( + """ + select id, alert_id, channel, status, attempted_ts, delivered_ts, + external_message_id, error, payload + from alert_deliveries + where alert_id = :alert_id + and channel = :channel + order by attempted_ts desc, id desc + limit 1 + """ + ) + params = {"alert_id": alert_id, "channel": channel} + with SessionLocal() as session: + row = session.execute(sql, params).mappings().first() + return dict(row) if row else None + + def fetch_latest_delivery_by_dedupe_scope(self, *, channel: str, dedupe_scope: dict) -> dict | None: + sql = text( + """ + select id, alert_id, channel, status, attempted_ts, delivered_ts, + external_message_id, error, payload + from alert_deliveries + where channel = :channel + and status in ('delivered', 'skipped') + and payload -> 'dedupe_scope' = cast(:dedupe_scope as jsonb) + order by attempted_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"channel": channel, "dedupe_scope": json.dumps(dedupe_scope, ensure_ascii=False, sort_keys=True)}, + ).mappings().first() + return dict(row) if row else None + + def insert_delivery( + self, + *, + alert_id: int, + channel: str, + status: str, + attempted_ts, + delivered_ts=None, + external_message_id=None, + error=None, + payload: dict | None = None, + ) -> int: + sql = text( + """ + insert into alert_deliveries ( + alert_id, + channel, + status, + attempted_ts, + delivered_ts, + external_message_id, + error, + payload + ) values ( + :alert_id, + :channel, + :status, + :attempted_ts, + :delivered_ts, + :external_message_id, + :error, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "alert_id": alert_id, + "channel": channel, + "status": status, + "attempted_ts": attempted_ts, + "delivered_ts": delivered_ts, + "external_message_id": external_message_id, + "error": error, + "payload": json.dumps(payload or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def fetch_recent_deliveries(self, alert_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, alert_id, channel, status, attempted_ts, delivered_ts, + external_message_id, error, payload + from alert_deliveries + where alert_id = :alert_id + order by attempted_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"alert_id": alert_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_failed_deliveries(self, limit: int = 100) -> list[dict]: + sql = text( + """ + select id, alert_id, channel, status, attempted_ts, delivered_ts, + external_message_id, error, payload + from alert_deliveries + where status = 'failed' + order by attempted_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/repositories/alert_feedback_repository.py b/src/repositories/alert_feedback_repository.py new file mode 100644 index 0000000..8c7e6d0 --- /dev/null +++ b/src/repositories/alert_feedback_repository.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class AlertFeedbackRepository: + def insert_feedback( + self, + *, + subject_type: str, + subject_id: int, + label: str, + note: str = "", + created_by: str = "local_operator", + payload: dict | None = None, + alert_id: int | None = None, + visual_review_id: int | None = None, + created_ts=None, + ) -> int: + sql = text( + """ + insert into alert_feedback ( + subject_type, + subject_id, + alert_id, + visual_review_id, + label, + note, + created_by, + created_ts, + payload + ) values ( + :subject_type, + :subject_id, + :alert_id, + :visual_review_id, + :label, + :note, + :created_by, + :created_ts, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "subject_type": subject_type, + "subject_id": subject_id, + "alert_id": alert_id, + "visual_review_id": visual_review_id, + "label": label, + "note": note or "", + "created_by": created_by, + "created_ts": created_ts, + "payload": json.dumps(payload or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def fetch_feedback_for_alert(self, alert_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, subject_type, subject_id, alert_id, visual_review_id, + label, note, created_by, created_ts, payload + from alert_feedback + where alert_id = :alert_id + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"alert_id": alert_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_feedback_for_visual_review(self, visual_review_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, subject_type, subject_id, alert_id, visual_review_id, + label, note, created_by, created_ts, payload + from alert_feedback + where visual_review_id = :visual_review_id + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"visual_review_id": visual_review_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/repositories/decision_packet_repository.py b/src/repositories/decision_packet_repository.py index 13434cd..ca0c570 100644 --- a/src/repositories/decision_packet_repository.py +++ b/src/repositories/decision_packet_repository.py @@ -7,6 +7,91 @@ from src.db.session import SessionLocal class DecisionPacketRepository: + def fetch_pending_decision_alerts(self, limit: int = 50) -> list[dict]: + sql = text( + """ + select id, instrument_id, decision_packet_id, alert_key, alert_type, + status, channel, message, payload, created_ts, delivered_ts + from decision_alerts + where status = 'queued' + order by created_ts asc, id asc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def update_decision_alert_status( + self, + *, + alert_id: int, + status: str, + channel: str | None = None, + delivered_ts=None, + ) -> None: + sql = text( + """ + update decision_alerts + set status = :status, + channel = coalesce(:channel, channel), + delivered_ts = :delivered_ts + where id = :alert_id + """ + ) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "alert_id": alert_id, + "status": status, + "channel": channel, + "delivered_ts": delivered_ts, + }, + ) + + def fetch_decision_alert_by_id(self, alert_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, decision_packet_id, alert_key, alert_type, + status, channel, message, payload, created_ts, delivered_ts + from decision_alerts + where id = :alert_id + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"alert_id": alert_id}).mappings().first() + return dict(row) if row else None + + def fetch_decision_packet_by_id(self, packet_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, trading_plan_id, candidate_id, signal_id, + execution_ticket_id, packet_ts, status, action_recommendation, + quality_score, ai_explanation, payload + from decision_packets + where id = :packet_id + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"packet_id": packet_id}).mappings().first() + return dict(row) if row else None + + def fetch_recent_decision_alerts(self, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, instrument_id, decision_packet_id, alert_key, alert_type, + status, channel, message, payload, created_ts, delivered_ts + from decision_alerts + where instrument_id = :instrument_id + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + def insert_decision_packet( self, *, @@ -148,18 +233,3 @@ class DecisionPacketRepository: with SessionLocal() as session: row = session.execute(sql, {"instrument_id": instrument_id}).mappings().first() return dict(row) if row else None - - def fetch_recent_decision_alerts(self, instrument_id: int, limit: int = 20) -> list[dict]: - sql = text( - """ - select id, instrument_id, decision_packet_id, alert_key, alert_type, - status, channel, message, payload, created_ts, delivered_ts - from decision_alerts - where instrument_id = :instrument_id - order by created_ts desc, id desc - limit :limit - """ - ) - with SessionLocal() as session: - rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() - return [dict(row) for row in rows] diff --git a/src/repositories/research_council_checkpoint_repository.py b/src/repositories/research_council_checkpoint_repository.py new file mode 100644 index 0000000..8ebb997 --- /dev/null +++ b/src/repositories/research_council_checkpoint_repository.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilCheckpointRepository: + def insert_checkpoint( + self, + *, + run_id: Optional[int], + instrument_id: int, + checkpoint_key: str, + status: str, + stage: str, + payload: dict, + created_ts, + updated_ts, + ) -> int: + sql = text( + """ + insert into research_council_checkpoints ( + run_id, + instrument_id, + checkpoint_key, + status, + stage, + payload, + created_ts, + updated_ts + ) values ( + :run_id, + :instrument_id, + :checkpoint_key, + :status, + :stage, + cast(:payload as jsonb), + :created_ts, + :updated_ts + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "run_id": run_id, + "instrument_id": instrument_id, + "checkpoint_key": checkpoint_key, + "status": status, + "stage": stage, + "payload": json.dumps(payload or {}, default=str), + "created_ts": created_ts, + "updated_ts": updated_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_latest_checkpoint(self, *, instrument_id: int, checkpoint_key: str) -> Optional[dict]: + sql = text( + """ + select * + from research_council_checkpoints + where instrument_id = :instrument_id + and checkpoint_key = :checkpoint_key + order by updated_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + { + "instrument_id": instrument_id, + "checkpoint_key": checkpoint_key, + }, + ).mappings().first() + return _normalize_row(row) + + +def _normalize_row(row) -> Optional[dict]: + if not row: + return None + payload = dict(row) + if isinstance(payload.get("payload"), str): + payload["payload"] = json.loads(payload["payload"]) + return payload diff --git a/src/repositories/research_council_lesson_repository.py b/src/repositories/research_council_lesson_repository.py new file mode 100644 index 0000000..6cdf65f --- /dev/null +++ b/src/repositories/research_council_lesson_repository.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilLessonRepository: + def upsert_lesson( + self, + *, + lesson_key: str, + instrument_id: int, + source_type: str, + source_ref: str, + lesson_type: str, + symbol: Optional[str], + session_code: Optional[str], + setup_type: Optional[str], + model_code: Optional[str], + title: str, + lesson_summary: str, + tags: list[str], + priority_score: float, + payload: dict, + created_ts, + updated_ts, + ) -> int: + sql = text( + """ + insert into research_council_lessons ( + lesson_key, + instrument_id, + source_type, + source_ref, + lesson_type, + symbol, + session_code, + setup_type, + model_code, + title, + lesson_summary, + tags, + priority_score, + payload, + created_ts, + updated_ts + ) values ( + :lesson_key, + :instrument_id, + :source_type, + :source_ref, + :lesson_type, + :symbol, + :session_code, + :setup_type, + :model_code, + :title, + :lesson_summary, + cast(:tags as jsonb), + :priority_score, + cast(:payload as jsonb), + :created_ts, + :updated_ts + ) + on conflict (lesson_key) + do update set + instrument_id = excluded.instrument_id, + source_type = excluded.source_type, + source_ref = excluded.source_ref, + lesson_type = excluded.lesson_type, + symbol = excluded.symbol, + session_code = excluded.session_code, + setup_type = excluded.setup_type, + model_code = excluded.model_code, + title = excluded.title, + lesson_summary = excluded.lesson_summary, + tags = excluded.tags, + priority_score = excluded.priority_score, + payload = excluded.payload, + updated_ts = excluded.updated_ts, + updated_at = now() + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "lesson_key": lesson_key, + "instrument_id": instrument_id, + "source_type": source_type, + "source_ref": source_ref, + "lesson_type": lesson_type, + "symbol": symbol, + "session_code": session_code, + "setup_type": setup_type, + "model_code": model_code, + "title": title, + "lesson_summary": lesson_summary, + "tags": json.dumps(list(tags or []), default=str), + "priority_score": priority_score, + "payload": json.dumps(payload or {}, default=str), + "created_ts": created_ts, + "updated_ts": updated_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_lesson_by_key(self, *, lesson_key: str) -> Optional[dict]: + sql = text( + """ + select * + from research_council_lessons + where lesson_key = :lesson_key + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"lesson_key": lesson_key}).mappings().first() + return _normalize_row(row) + + def fetch_recent_lessons( + self, + *, + instrument_id: int, + limit: int = 100, + symbol: Optional[str] = None, + session_code: Optional[str] = None, + setup_type: Optional[str] = None, + model_code: Optional[str] = None, + ) -> list[dict]: + filters = ["instrument_id = :instrument_id"] + params = {"instrument_id": instrument_id, "limit": limit} + if symbol: + filters.append("symbol = :symbol") + params["symbol"] = symbol + if session_code: + filters.append("session_code = :session_code") + params["session_code"] = session_code + if setup_type: + filters.append("setup_type = :setup_type") + params["setup_type"] = setup_type + if model_code: + filters.append("model_code = :model_code") + params["model_code"] = model_code + sql = text( + f""" + select * + from research_council_lessons + where {' and '.join(filters)} + order by updated_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, params).mappings().all() + return [_normalize_row(row) for row in rows] + + +def _normalize_row(row) -> Optional[dict]: + if not row: + return None + payload = dict(row) + for key in ("tags", "payload"): + if isinstance(payload.get(key), str): + payload[key] = json.loads(payload[key]) + return payload diff --git a/src/repositories/research_council_link_repository.py b/src/repositories/research_council_link_repository.py new file mode 100644 index 0000000..2054da6 --- /dev/null +++ b/src/repositories/research_council_link_repository.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilLinkRepository: + def insert_link( + self, + *, + run_id: int, + trading_plan_id: Optional[int], + candidate_id: Optional[int], + signal_id: Optional[int], + decision_packet_id: Optional[int], + execution_ticket_id: Optional[int], + created_ts, + ) -> int: + sql = text( + """ + insert into research_council_links ( + run_id, + trading_plan_id, + candidate_id, + signal_id, + decision_packet_id, + execution_ticket_id, + created_ts + ) values ( + :run_id, + :trading_plan_id, + :candidate_id, + :signal_id, + :decision_packet_id, + :execution_ticket_id, + :created_ts + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "run_id": run_id, + "trading_plan_id": trading_plan_id, + "candidate_id": candidate_id, + "signal_id": signal_id, + "decision_packet_id": decision_packet_id, + "execution_ticket_id": execution_ticket_id, + "created_ts": created_ts, + }, + ) + return int(result.scalar_one()) diff --git a/src/repositories/research_council_message_repository.py b/src/repositories/research_council_message_repository.py new file mode 100644 index 0000000..7c76d09 --- /dev/null +++ b/src/repositories/research_council_message_repository.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilMessageRepository: + def insert_message( + self, + *, + run_id: int, + agent_role: str, + message_type: str, + payload: dict, + created_ts, + ) -> int: + sql = text( + """ + insert into research_council_messages ( + run_id, + agent_role, + message_type, + payload, + created_ts + ) values ( + :run_id, + :agent_role, + :message_type, + cast(:payload as jsonb), + :created_ts + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "run_id": run_id, + "agent_role": agent_role, + "message_type": message_type, + "payload": json.dumps(payload, default=str), + "created_ts": created_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_messages_for_run(self, *, run_id: int, limit: int = 50) -> list[dict]: + sql = text( + """ + select * + from research_council_messages + where run_id = :run_id + order by created_ts asc, id asc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"run_id": run_id, "limit": limit}).mappings().all() + payloads = [] + for row in rows: + item = dict(row) + if isinstance(item.get("payload"), str): + item["payload"] = json.loads(item["payload"]) + payloads.append(item) + return payloads diff --git a/src/repositories/research_council_optimization_repository.py b/src/repositories/research_council_optimization_repository.py new file mode 100644 index 0000000..720ddc3 --- /dev/null +++ b/src/repositories/research_council_optimization_repository.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilOptimizationRepository: + def upsert_cycle( + self, + *, + cycle_key: str, + instrument_id: int, + status: str, + timeframe: str, + model_code: str, + replay_file: Optional[str], + lesson_summary: dict, + replay_summary: dict, + eval_summary: dict, + arena_summary: dict, + next_actions: list[str], + payload: dict, + started_ts, + finished_ts, + ) -> int: + sql = text( + """ + insert into research_council_optimization_cycles ( + cycle_key, + instrument_id, + status, + timeframe, + model_code, + replay_file, + lesson_summary, + replay_summary, + eval_summary, + arena_summary, + next_actions, + payload, + started_ts, + finished_ts + ) values ( + :cycle_key, + :instrument_id, + :status, + :timeframe, + :model_code, + :replay_file, + cast(:lesson_summary as jsonb), + cast(:replay_summary as jsonb), + cast(:eval_summary as jsonb), + cast(:arena_summary as jsonb), + cast(:next_actions as jsonb), + cast(:payload as jsonb), + :started_ts, + :finished_ts + ) + on conflict (cycle_key) + do update set + status = excluded.status, + timeframe = excluded.timeframe, + model_code = excluded.model_code, + replay_file = excluded.replay_file, + lesson_summary = excluded.lesson_summary, + replay_summary = excluded.replay_summary, + eval_summary = excluded.eval_summary, + arena_summary = excluded.arena_summary, + next_actions = excluded.next_actions, + payload = excluded.payload, + finished_ts = excluded.finished_ts, + updated_at = now() + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "cycle_key": cycle_key, + "instrument_id": instrument_id, + "status": status, + "timeframe": timeframe, + "model_code": model_code, + "replay_file": replay_file, + "lesson_summary": json.dumps(lesson_summary or {}, default=str), + "replay_summary": json.dumps(replay_summary or {}, default=str), + "eval_summary": json.dumps(eval_summary or {}, default=str), + "arena_summary": json.dumps(arena_summary or {}, default=str), + "next_actions": json.dumps(list(next_actions or []), default=str), + "payload": json.dumps(payload or {}, default=str), + "started_ts": started_ts, + "finished_ts": finished_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_latest_cycle(self, *, instrument_id: int) -> Optional[dict]: + sql = text( + """ + select * + from research_council_optimization_cycles + where instrument_id = :instrument_id + order by finished_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id}).mappings().first() + return _normalize_row(row) + + def fetch_recent_cycles(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select * + from research_council_optimization_cycles + where instrument_id = :instrument_id + order by finished_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [_normalize_row(row) for row in rows] + + +def _normalize_row(row) -> Optional[dict]: + if not row: + return None + payload = dict(row) + for key in ( + "lesson_summary", + "replay_summary", + "eval_summary", + "arena_summary", + "next_actions", + "payload", + ): + if isinstance(payload.get(key), str): + payload[key] = json.loads(payload[key]) + return payload diff --git a/src/repositories/research_council_run_repository.py b/src/repositories/research_council_run_repository.py new file mode 100644 index 0000000..3c0680e --- /dev/null +++ b/src/repositories/research_council_run_repository.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ResearchCouncilRunRepository: + def insert_run( + self, + *, + instrument_id: int, + trading_plan_id: Optional[int], + candidate_id: Optional[int], + signal_id: Optional[int], + decision_packet_id: Optional[int], + status: str, + timeframe: str, + model_code: str, + committee_confidence: float, + summary_text: str, + payload: dict, + generated_ts, + ) -> int: + sql = text( + """ + insert into research_council_runs ( + instrument_id, + trading_plan_id, + candidate_id, + signal_id, + decision_packet_id, + status, + timeframe, + model_code, + committee_confidence, + summary_text, + payload, + generated_ts + ) values ( + :instrument_id, + :trading_plan_id, + :candidate_id, + :signal_id, + :decision_packet_id, + :status, + :timeframe, + :model_code, + :committee_confidence, + :summary_text, + cast(:payload as jsonb), + :generated_ts + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "instrument_id": instrument_id, + "trading_plan_id": trading_plan_id, + "candidate_id": candidate_id, + "signal_id": signal_id, + "decision_packet_id": decision_packet_id, + "status": status, + "timeframe": timeframe, + "model_code": model_code, + "committee_confidence": committee_confidence, + "summary_text": summary_text, + "payload": json.dumps(payload, default=str), + "generated_ts": generated_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_latest_run(self, *, instrument_id: int) -> Optional[dict]: + sql = text( + """ + select * + from research_council_runs + where instrument_id = :instrument_id + order by generated_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id}).mappings().first() + return _normalize_row(row) + + def fetch_recent_runs(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select * + from research_council_runs + where instrument_id = :instrument_id + order by generated_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [_normalize_row(row) for row in rows] + + +def _normalize_row(row) -> Optional[dict]: + if not row: + return None + payload = dict(row) + if isinstance(payload.get("payload"), str): + payload["payload"] = json.loads(payload["payload"]) + return payload diff --git a/src/repositories/visual_snapshot_repository.py b/src/repositories/visual_snapshot_repository.py new file mode 100644 index 0000000..ae55f8f --- /dev/null +++ b/src/repositories/visual_snapshot_repository.py @@ -0,0 +1,338 @@ +from datetime import datetime, timezone +import json + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class VisualSnapshotRepository: + def insert_visual_watch_run( + self, + *, + trigger_type: str, + source_name: str, + instrument_id: int, + candidate_id: int | None, + signal_id: int | None, + decision_packet_id: int | None, + symbol: str, + timeframe: str, + status: str, + payload: dict | None = None, + ) -> int: + sql = text( + """ + insert into visual_watch_runs ( + trigger_type, + source_name, + instrument_id, + candidate_id, + signal_id, + decision_packet_id, + symbol, + timeframe, + status, + payload + ) values ( + :trigger_type, + :source_name, + :instrument_id, + :candidate_id, + :signal_id, + :decision_packet_id, + :symbol, + :timeframe, + :status, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "trigger_type": trigger_type, + "source_name": source_name, + "instrument_id": instrument_id, + "candidate_id": candidate_id, + "signal_id": signal_id, + "decision_packet_id": decision_packet_id, + "symbol": symbol, + "timeframe": timeframe, + "status": status, + "payload": json.dumps(payload or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def update_visual_watch_run_payload(self, *, watch_run_id: int, payload: dict) -> None: + sql = text( + """ + update visual_watch_runs + set payload = cast(:payload as jsonb) + where id = :watch_run_id + """ + ) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "watch_run_id": watch_run_id, + "payload": json.dumps(payload or {}, default=str), + }, + ) + + def mark_visual_watch_run_completed( + self, + *, + watch_run_id: int, + status: str, + payload: dict | None = None, + completed_ts=None, + ) -> None: + sql = text( + """ + update visual_watch_runs + set status = :status, + completed_ts = :completed_ts, + payload = cast(:payload as jsonb) + where id = :watch_run_id + """ + ) + finalized_ts = completed_ts or datetime.now(timezone.utc) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "watch_run_id": watch_run_id, + "status": status, + "completed_ts": finalized_ts, + "payload": json.dumps(payload or {}, default=str), + }, + ) + + def insert_snapshot( + self, + *, + watch_run_id: int, + source_name: str, + image_path: str, + captured_ts, + status: str, + payload: dict | None = None, + ) -> int: + sql = text( + """ + insert into visual_snapshots ( + watch_run_id, + source_name, + image_path, + captured_ts, + status, + payload + ) values ( + :watch_run_id, + :source_name, + :image_path, + :captured_ts, + :status, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "watch_run_id": watch_run_id, + "source_name": source_name, + "image_path": image_path, + "captured_ts": captured_ts, + "status": status, + "payload": json.dumps(payload or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def insert_visual_review( + self, + *, + watch_run_id: int, + snapshot_id: int | None, + visual_state: str, + visual_confidence: float | None, + visual_notes: str, + ui_anomalies: list | None, + reviewed_ts, + payload: dict | None = None, + ) -> int: + sql = text( + """ + insert into visual_reviews ( + watch_run_id, + snapshot_id, + visual_state, + visual_confidence, + visual_notes, + ui_anomalies, + reviewed_ts, + payload + ) values ( + :watch_run_id, + :snapshot_id, + :visual_state, + :visual_confidence, + :visual_notes, + cast(:ui_anomalies as jsonb), + :reviewed_ts, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "watch_run_id": watch_run_id, + "snapshot_id": snapshot_id, + "visual_state": visual_state, + "visual_confidence": visual_confidence, + "visual_notes": visual_notes, + "ui_anomalies": json.dumps(ui_anomalies or [], default=str), + "reviewed_ts": reviewed_ts, + "payload": json.dumps(payload or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def fetch_visual_review_by_id(self, visual_review_id: int) -> dict | None: + sql = text( + """ + select vr.id, vr.watch_run_id, vr.snapshot_id, vr.visual_state, vr.visual_confidence, + vr.visual_notes, vr.ui_anomalies, vr.reviewed_ts, vr.payload, + vwr.instrument_id, vwr.candidate_id, vwr.signal_id, vwr.decision_packet_id, + vwr.trigger_type, vwr.symbol, vwr.timeframe, + vs.source_name as snapshot_source_name, + vs.image_path as snapshot_image_path, + vs.payload as snapshot_payload + from visual_reviews vr + join visual_watch_runs vwr on vwr.id = vr.watch_run_id + left join visual_snapshots vs on vs.id = vr.snapshot_id + where vr.id = :visual_review_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"visual_review_id": visual_review_id}).mappings().first() + return dict(row) if row else None + + def fetch_latest_watch_run(self, *, instrument_id: int, trigger_type: str) -> dict | None: + sql = text( + """ + select id, trigger_type, source_name, instrument_id, candidate_id, signal_id, + decision_packet_id, symbol, timeframe, status, created_ts, completed_ts, payload + from visual_watch_runs + where instrument_id = :instrument_id + and trigger_type = :trigger_type + order by created_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id, "trigger_type": trigger_type}).mappings().first() + return dict(row) if row else None + + def fetch_watch_run_by_id(self, watch_run_id: int) -> dict | None: + sql = text( + """ + select id, trigger_type, source_name, instrument_id, candidate_id, signal_id, + decision_packet_id, symbol, timeframe, status, created_ts, completed_ts, payload + from visual_watch_runs + where id = :watch_run_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"watch_run_id": watch_run_id}).mappings().first() + return dict(row) if row else None + + def fetch_pending_watch_runs( + self, + *, + limit: int = 10, + instrument_id: int | None = None, + trigger_type: str | None = None, + ) -> list[dict]: + conditions = ["completed_ts is null", "status in ('reserved', 'queued', 'pending')"] + params: dict = {"limit": limit} + if instrument_id is not None: + conditions.append("instrument_id = :instrument_id") + params["instrument_id"] = instrument_id + if trigger_type is not None: + conditions.append("trigger_type = :trigger_type") + params["trigger_type"] = trigger_type + sql = text( + f""" + select id, trigger_type, source_name, instrument_id, candidate_id, signal_id, + decision_packet_id, symbol, timeframe, status, created_ts, completed_ts, payload + from visual_watch_runs + where {' and '.join(conditions)} + order by created_ts asc, id asc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, params).mappings().all() + return [dict(row) for row in rows] + + def fetch_snapshot_by_id(self, snapshot_id: int) -> dict | None: + sql = text( + """ + select id, watch_run_id, source_name, image_path, captured_ts, status, payload + from visual_snapshots + where id = :snapshot_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"snapshot_id": snapshot_id}).mappings().first() + return dict(row) if row else None + + def fetch_latest_snapshot_for_candidate(self, candidate_id: int) -> dict | None: + sql = text( + """ + select vs.id, vs.watch_run_id, vs.source_name, vs.image_path, vs.captured_ts, vs.status, vs.payload + from visual_snapshots vs + join visual_watch_runs vwr on vwr.id = vs.watch_run_id + where vwr.candidate_id = :candidate_id + order by vs.captured_ts desc, vs.id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"candidate_id": candidate_id}).mappings().first() + return dict(row) if row else None + + def fetch_reviews_for_packet(self, decision_packet_id: int, limit: int = 10) -> list[dict]: + sql = text( + """ + select vr.id, vr.watch_run_id, vr.snapshot_id, vr.visual_state, vr.visual_confidence, + vr.visual_notes, vr.ui_anomalies, vr.reviewed_ts, vr.payload, + vs.source_name as snapshot_source_name, + vs.image_path as snapshot_image_path, + vs.payload as snapshot_payload + from visual_reviews vr + join visual_watch_runs vwr on vwr.id = vr.watch_run_id + left join visual_snapshots vs on vs.id = vr.snapshot_id + where vwr.decision_packet_id = :decision_packet_id + order by vr.reviewed_ts desc, vr.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"decision_packet_id": decision_packet_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/services/alerts/__init__.py b/src/services/alerts/__init__.py new file mode 100644 index 0000000..8eb69d1 --- /dev/null +++ b/src/services/alerts/__init__.py @@ -0,0 +1,3 @@ +from src.services.alerts.alert_dispatcher_service import AlertDispatcherService + +__all__ = ["AlertDispatcherService"] diff --git a/src/services/alerts/alert_dispatcher_service.py b/src/services/alerts/alert_dispatcher_service.py new file mode 100644 index 0000000..ee1c37e --- /dev/null +++ b/src/services/alerts/alert_dispatcher_service.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy.exc import SQLAlchemyError + +from src.config.settings import settings +from src.repositories.alert_delivery_repository import AlertDeliveryRepository +from src.repositories.decision_packet_repository import DecisionPacketRepository +from src.services.feedback import AlertFeedbackService +from src.services.alerts.channel_router_service import select_alert_channel +from src.services.alerts.delivery_policy_service import should_skip_alert +from src.services.alerts.message_template_service import build_alert_message +from src.services.notifications import WebhookNotifier +from src.services.visual_watch import VisualWatchService + + +class AlertDispatcherService: + def __init__( + self, + *, + decision_repository: DecisionPacketRepository | None = None, + delivery_repository: AlertDeliveryRepository | None = None, + visual_watch_service: VisualWatchService | None = None, + feedback_service: AlertFeedbackService | None = None, + ) -> None: + self.decision_repository = decision_repository or DecisionPacketRepository() + self.delivery_repository = delivery_repository or AlertDeliveryRepository() + self.visual_watch_service = visual_watch_service or VisualWatchService() + self.feedback_service = feedback_service or AlertFeedbackService() + + def dispatch_pending(self, limit: int = 50) -> dict: + effective_limit = max(1, min(limit, settings.alert_dispatch_max_per_run)) + alerts = self.decision_repository.fetch_pending_decision_alerts(limit=effective_limit) + items = [] + for alert in alerts: + items.append(self.dispatch_alert(int(alert["id"]))) + return { + "attempted": len(alerts), + "requested_limit": limit, + "effective_limit": effective_limit, + "items": items, + } + + def dispatch_alert(self, alert_id: int) -> dict: + alert = self.decision_repository.fetch_decision_alert_by_id(alert_id) + if not alert: + return {"alert_id": alert_id, "status": "missing"} + + channel = select_alert_channel(alert) + packet = None + packet_id = alert.get("decision_packet_id") + if packet_id: + packet = self.decision_repository.fetch_decision_packet_by_id(int(packet_id)) + visual = self._build_visual_context(packet_id=packet_id) + message = build_alert_message(alert=alert, packet=packet, visual=visual) + dedupe_scope = self._build_dedupe_scope(alert=alert, channel=channel, message=message) + latest_delivery = self.delivery_repository.fetch_latest_delivery(alert_id=alert_id, channel=channel) + latest_equivalent_delivery = self.delivery_repository.fetch_latest_delivery_by_dedupe_scope( + channel=channel, + dedupe_scope=dedupe_scope, + ) + skip, reason = should_skip_alert( + alert, + latest_delivery=latest_delivery, + latest_equivalent_delivery=latest_equivalent_delivery, + ) + attempted_ts = datetime.now(timezone.utc) + + if skip: + self._safe_update_alert_status(alert_id=alert_id, status="skipped", channel=channel) + self._safe_insert_delivery( + alert_id=alert_id, + channel=channel, + status="skipped", + attempted_ts=attempted_ts, + error=reason, + payload={"reason": reason, "message": message, "dedupe_scope": dedupe_scope, "visual": visual}, + ) + return { + "alert_id": alert_id, + "status": "skipped", + "channel": channel, + "reason": reason, + "visual": self._visual_result_summary(visual), + } + + if channel == "workbench": + self._safe_update_alert_status(alert_id=alert_id, status="skipped", channel=channel) + self._safe_insert_delivery( + alert_id=alert_id, + channel=channel, + status="skipped", + attempted_ts=attempted_ts, + payload={"message": message, "reason": "workbench_only", "dedupe_scope": dedupe_scope, "visual": visual}, + ) + return { + "alert_id": alert_id, + "status": "skipped", + "channel": channel, + "visual": self._visual_result_summary(visual), + } + + try: + result = self._send(channel=channel, message=message) + delivered_ts = datetime.now(timezone.utc) + status_update_ok = self._safe_update_alert_status( + alert_id=alert_id, + status="delivered", + channel=channel, + delivered_ts=delivered_ts, + ) + delivery_write_ok = self._safe_insert_delivery( + alert_id=alert_id, + channel=channel, + status="delivered", + attempted_ts=attempted_ts, + delivered_ts=delivered_ts, + external_message_id=result.get("external_message_id"), + payload={"message": message, "result": result, "dedupe_scope": dedupe_scope, "visual": visual}, + ) + return { + "alert_id": alert_id, + "status": "delivered", + "channel": channel, + "status_update_ok": status_update_ok, + "delivery_write_ok": delivery_write_ok, + "visual": self._visual_result_summary(visual), + } + except Exception as exc: + status_update_ok = self._safe_update_alert_status(alert_id=alert_id, status="failed", channel=channel) + delivery_write_ok = self._safe_insert_delivery( + alert_id=alert_id, + channel=channel, + status="failed", + attempted_ts=attempted_ts, + error=str(exc), + payload={"message": message, "dedupe_scope": dedupe_scope, "visual": visual}, + ) + return { + "alert_id": alert_id, + "status": "failed", + "channel": channel, + "error": str(exc), + "status_update_ok": status_update_ok, + "delivery_write_ok": delivery_write_ok, + "visual": self._visual_result_summary(visual), + } + + def _build_dedupe_scope(self, *, alert: dict, channel: str, message: str) -> dict: + payload = dict(alert.get("payload") or {}) + scope = dict(payload.get("dedupe_scope") or {}) + return { + "channel": channel, + "alert_type": str(alert.get("alert_type") or ""), + "action_recommendation": str(payload.get("action_recommendation") or ""), + "status": str(payload.get("status") or ""), + "candidate_id": scope.get("candidate_id"), + "signal_id": scope.get("signal_id"), + "instrument_id": scope.get("instrument_id"), + "reason_codes": list(scope.get("reason_codes") or []), + "message": message, + } + + def _build_visual_context(self, *, packet_id: int | None) -> dict | None: + if packet_id is None: + return None + try: + reviews = self.visual_watch_service.fetch_reviews_for_packet(decision_packet_id=int(packet_id), limit=1) + except Exception: + return None + if not reviews: + return None + review = dict(reviews[0]) + feedback_rows: list[dict] = [] + review_id = review.get("id") + if review_id is not None: + try: + feedback_rows = self.feedback_service.list_visual_review_feedback(int(review_id), limit=10) + except Exception: + feedback_rows = [] + review["feedback"] = feedback_rows + review["feedback_summary"] = self.feedback_service.summarize_feedback(feedback_rows) + return review + + def _visual_result_summary(self, visual: dict | None) -> dict | None: + if not visual: + return None + return { + "visual_review_id": visual.get("id"), + "watch_run_id": visual.get("watch_run_id"), + "visual_state": visual.get("visual_state"), + "snapshot_source_name": visual.get("snapshot_source_name"), + "snapshot_image_path": visual.get("snapshot_image_path"), + "snapshot_artifact_path": _snapshot_artifact_path(visual), + "feedback_summary": dict(visual.get("feedback_summary") or {}), + } + + def _safe_update_alert_status(self, *, alert_id: int, status: str, channel: str | None = None, delivered_ts=None) -> bool: + try: + self.decision_repository.update_decision_alert_status( + alert_id=alert_id, + status=status, + channel=channel, + delivered_ts=delivered_ts, + ) + return True + except SQLAlchemyError: + return False + + def _safe_insert_delivery( + self, + *, + alert_id: int, + channel: str, + status: str, + attempted_ts, + delivered_ts=None, + external_message_id=None, + error=None, + payload: dict | None = None, + ) -> bool: + try: + self.delivery_repository.insert_delivery( + alert_id=alert_id, + channel=channel, + status=status, + attempted_ts=attempted_ts, + delivered_ts=delivered_ts, + external_message_id=external_message_id, + error=error, + payload=payload, + ) + return True + except SQLAlchemyError: + return False + + def _send(self, *, channel: str, message: str) -> dict: + if channel == "webhook": + return WebhookNotifier(webhook_url=settings.alert_webhook_url).send_text(message) + raise ValueError(f"unsupported_alert_channel:{channel}") + + +def _snapshot_artifact_path(visual: dict | None) -> str | None: + if not visual: + return None + payload = dict(visual.get("snapshot_payload") or {}) + nested = payload.get("payload") + if payload.get("artifact_path"): + return payload.get("artifact_path") + if isinstance(nested, dict): + return nested.get("artifact_path") + return None diff --git a/src/services/alerts/channel_router_service.py b/src/services/alerts/channel_router_service.py new file mode 100644 index 0000000..697319c --- /dev/null +++ b/src/services/alerts/channel_router_service.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from src.config.settings import settings + + +WEBHOOK_ALERT_TYPES = {"candidate_actionable", "ticket_ready", "runtime_degraded"} + + +def select_alert_channel(alert: dict) -> str: + alert_type = str(alert.get("alert_type") or "") + action = str((alert.get("payload") or {}).get("action_recommendation") or "") + if alert_type in WEBHOOK_ALERT_TYPES and settings.alert_webhook_url: + return "webhook" + if alert_type == "candidate_blocked" and action == "do_not_execute" and settings.alert_webhook_url: + return "webhook" + return settings.alert_default_channel diff --git a/src/services/alerts/delivery_policy_service.py b/src/services/alerts/delivery_policy_service.py new file mode 100644 index 0000000..64889f6 --- /dev/null +++ b/src/services/alerts/delivery_policy_service.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from src.config.settings import settings + + +def should_skip_alert( + alert: dict, + *, + latest_delivery: dict | None = None, + latest_equivalent_delivery: dict | None = None, +) -> tuple[bool, str]: + status = str(alert.get("status") or "") + if status != "queued": + return True, "alert_not_queued" + cooldown_seconds = max(settings.alert_resend_cooldown_seconds, 0) + if _delivery_in_cooldown(latest_delivery, cooldown_seconds=cooldown_seconds): + return True, "alert_cooldown_active" + if _delivery_in_cooldown(latest_equivalent_delivery, cooldown_seconds=cooldown_seconds): + return True, "equivalent_alert_cooldown_active" + return False, "" + + +def _delivery_in_cooldown(latest_delivery: dict | None, *, cooldown_seconds: int) -> bool: + if not latest_delivery: + return False + latest_status = str(latest_delivery.get("status") or "") + if latest_status not in {"delivered", "skipped"}: + return False + attempted_ts = _parse_ts(latest_delivery.get("attempted_ts")) + if attempted_ts is None: + return False + age_seconds = (datetime.now(timezone.utc) - attempted_ts).total_seconds() + return age_seconds < cooldown_seconds + + +def _parse_ts(value) -> datetime | None: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc) + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + return parsed.astimezone(timezone.utc) if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +__all__ = ["should_skip_alert"] diff --git a/src/services/alerts/message_template_service.py b/src/services/alerts/message_template_service.py new file mode 100644 index 0000000..4c23c6c --- /dev/null +++ b/src/services/alerts/message_template_service.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +def build_alert_message(*, alert: dict, packet: dict | None = None, visual: dict | None = None) -> str: + payload = dict(alert.get("payload") or {}) + action = str(payload.get("action_recommendation") or "unknown") + status = str(payload.get("status") or "unknown") + why_allowed = ", ".join(list(payload.get("why_allowed") or [])) or "none" + why_blocked = ", ".join(list(payload.get("why_blocked") or [])) or "none" + symbol = "" + if packet: + symbol = str((packet.get("payload") or {}).get("symbol") or packet.get("symbol") or "") + prefix = f"[{symbol}] " if symbol else "" + message = f"{prefix}{alert.get('alert_type')}: action={action}, status={status}, why_allowed={why_allowed}, why_blocked={why_blocked}" + visual_bits = [] + if visual: + visual_state = str(visual.get("visual_state") or "unknown") + visual_bits.append(f"visual={visual_state}") + confidence = visual.get("visual_confidence") + if confidence not in (None, ""): + visual_bits.append(f"confidence={confidence}") + snapshot_source = str(visual.get("snapshot_source_name") or visual.get("source_name") or "") + if snapshot_source: + visual_bits.append(f"source={snapshot_source}") + snapshot_path = str(visual.get("snapshot_image_path") or visual.get("snapshot_artifact_path") or "") + if snapshot_path: + visual_bits.append(f"snapshot={snapshot_path}") + feedback_summary = visual.get("feedback_summary") or {} + if feedback_summary.get("count"): + latest_label = feedback_summary.get("latest_label") or "unknown" + visual_bits.append(f"feedback={feedback_summary.get('count')}:{latest_label}") + notes = str(visual.get("visual_notes") or "").strip() + if notes: + visual_bits.append(f"notes={notes}") + if visual_bits: + return f"{message} | " + ", ".join(visual_bits) + return message diff --git a/src/services/decision/decision_packet_service.py b/src/services/decision/decision_packet_service.py index dcb8ae8..5a98924 100644 --- a/src/services/decision/decision_packet_service.py +++ b/src/services/decision/decision_packet_service.py @@ -6,11 +6,18 @@ from typing import Optional from src.repositories.decision_packet_repository import DecisionPacketRepository from src.repositories.execution_repository import ExecutionRepository +from src.repositories.research_council_run_repository import ResearchCouncilRunRepository from src.repositories.signal_repository import SignalRepository from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository from src.repositories.trading_plan_repository import TradingPlanRepository from src.services.execution import ExecutionService from src.services.trust import ModelTrustService +from src.services.visual_watch import ( + VisualWatchService, + build_visual_watch_request_stub, + suggest_visual_trigger, + suggest_visual_triggers_from_council, +) DEFAULT_MODEL_CODE = "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE" @@ -44,6 +51,7 @@ class DecisionPacket: ai_explanation: str trust_classification: str operator_mode: str + research_council: dict alerts: list[dict] raw: dict @@ -60,6 +68,7 @@ class DecisionPacketService: candidate_repository: Optional[TradePlanCandidateRepository] = None, signal_repository: Optional[SignalRepository] = None, execution_repository: Optional[ExecutionRepository] = None, + research_council_repository: Optional[ResearchCouncilRunRepository] = None, execution_service: Optional[ExecutionService] = None, trust_service: Optional[ModelTrustService] = None, ) -> None: @@ -68,6 +77,7 @@ class DecisionPacketService: self.candidate_repository = candidate_repository or TradePlanCandidateRepository() self.signal_repository = signal_repository or SignalRepository() self.execution_repository = execution_repository or ExecutionRepository() + self.research_council_repository = research_council_repository or ResearchCouncilRunRepository() self.execution_service = execution_service or ExecutionService() self.trust_service = trust_service or ModelTrustService() @@ -79,10 +89,16 @@ class DecisionPacketService: signal = self.signal_repository.fetch_latest_trade_signal_for_candidate(int(candidate["id"])) if candidate and candidate.get("id") else None ticket = _latest_ticket(self.execution_repository, signal_id=int(signal["id"])) if signal and signal.get("id") else None risk_result = _build_risk_result(self.execution_service, signal) + trust_model_code = DEFAULT_MODEL_CODE + if candidate and candidate.get("model_code"): + trust_model_code = str(candidate.get("model_code")) + elif signal and signal.get("model_code"): + trust_model_code = str(signal.get("model_code")) trust_policy = resolve_model_trust_policy( trust_summary=self.trust_service.build_summary(instrument_id=instrument_id), - model_code=str(candidate.get("model_code") or signal.get("model_code") or DEFAULT_MODEL_CODE), + model_code=trust_model_code, ) + research_council = self._fetch_latest_research_council(instrument_id=instrument_id) packet = build_decision_packet_from_rows( instrument_id=instrument_id, observed_ts=observed_ts, @@ -92,9 +108,26 @@ class DecisionPacketService: ticket=ticket or {}, risk_result=risk_result, trust_policy=trust_policy, + research_council=research_council, ) return packet + def _fetch_latest_research_council(self, *, instrument_id: int) -> Optional[dict]: + try: + return self.research_council_repository.fetch_latest_run(instrument_id=instrument_id) + except Exception as exc: + return { + "_unavailable": True, + "status": "unavailable", + "error": str(exc), + "payload": { + "decision": { + "follow_up_checks": ["research_council_unavailable"], + "recommended_watch_items": [], + } + }, + } + def persist_packet(self, packet: DecisionPacket) -> int: return self.decision_repository.insert_decision_packet( instrument_id=packet.instrument_id, @@ -121,19 +154,33 @@ class DecisionPacketService: packet = self.build_packet(instrument_id=instrument_id, model_code=model_code) packet_id = self.persist_packet(packet) alerts = [] + visual_watch_service = VisualWatchService() for alert in build_alert_candidates(packet): + payload = dict(alert["payload"] or {}) + watch_requests = [dict(item or {}) for item in list(payload.get("visual_watch_requests") or []) if item] + if not watch_requests and payload.get("visual_watch_request"): + watch_requests = [dict(payload.get("visual_watch_request") or {})] + reservations = [] + for watch_request in watch_requests: + watch_request["decision_packet_id"] = packet_id + reservations.append(visual_watch_service.reserve_watch_request(watch_request)) + if watch_requests: + payload["visual_watch_requests"] = watch_requests + payload["visual_watch_reservations"] = reservations + payload["visual_watch_request"] = watch_requests[0] + payload["visual_watch_reservation"] = reservations[0] if reservations else None alert_id = self.decision_repository.insert_decision_alert( instrument_id=instrument_id, decision_packet_id=packet_id, alert_key=alert["alert_key"], alert_type=alert["alert_type"], status="queued", - channel="workbench", + channel="queued", message=alert["message"], - payload=alert["payload"], + payload=payload, created_ts=packet.packet_ts, ) - alerts.append({**alert, "id": alert_id, "status": "queued"}) + alerts.append({**alert, "payload": payload, "id": alert_id, "status": "queued"}) return { "id": packet_id, **packet.to_dict(), @@ -154,6 +201,7 @@ def build_decision_packet_from_rows( ticket: dict, risk_result: dict, trust_policy: Optional[dict] = None, + research_council: Optional[dict] = None, ) -> DecisionPacket: trust_policy = dict(trust_policy or {}) confirmation_state = dict(candidate.get("confirmation_state") or signal.get("confirmation_quality") or {}) @@ -167,9 +215,20 @@ def build_decision_packet_from_rows( why_allowed = [] if action in {"build_execution_ticket", "confirm_ticket", "manage_ticket"}: why_allowed = _dedupe(["active_plan_bound", "candidate_confirmed", "risk_governor_passed"]) + elif action == "decision_support_only": + why_allowed = _dedupe(["decision_support_allowed", "execution_assist_not_allowed"]) + elif action == "restricted_decision_support": + why_allowed = _dedupe(["restricted_decision_support_allowed", "execution_assist_not_allowed"]) elif action == "observe_only": why_allowed = _dedupe(["analysis_only_allowed"]) - ai_explanation = build_ai_explanation(action=action, plan=plan, candidate=candidate, risk_reasons=risk_reasons, missing=missing, trust_reasons=trust_reasons) + ai_explanation = build_ai_explanation( + action=action, + plan=plan, + candidate=candidate, + risk_reasons=risk_reasons, + missing=missing, + trust_reasons=trust_reasons, + ) return DecisionPacket( instrument_id=instrument_id, packet_ts=observed_ts, @@ -214,6 +273,7 @@ def build_decision_packet_from_rows( ai_explanation=ai_explanation, trust_classification=str(trust_policy.get("classification") or "unknown"), operator_mode=str(trust_policy.get("operator_mode") or "unknown"), + research_council=_build_council_packet_fields(research_council), alerts=[], raw={ "plan": plan, @@ -221,6 +281,7 @@ def build_decision_packet_from_rows( "signal": signal, "ticket": ticket, "trust_policy": trust_policy, + "research_council": _build_council_packet_fields(research_council), }, ) @@ -230,8 +291,10 @@ def resolve_action(*, plan: dict, candidate: dict, ticket: dict, risk_result: di return "blocked", "wait_for_active_plan" if not candidate: return "monitoring", "wait_for_candidate" - if trust_policy and not bool(trust_policy.get("decision_support_allowed", False)): + if trust_policy and not bool(trust_policy.get("restricted_decision_support_allowed", False)): return "monitoring", "observe_only" + if trust_policy and not bool(trust_policy.get("decision_support_allowed", False)): + return "monitoring", "restricted_decision_support" if missing or candidate.get("status") in {"blocked", "draft"}: return "blocked", "wait_for_confirmations" if risk_result.get("allowed") is False: @@ -240,6 +303,8 @@ def resolve_action(*, plan: dict, candidate: dict, ticket: dict, risk_result: di return "actionable", "confirm_ticket" if ticket.get("status") in {"confirmed", "pending"}: return "actionable", "manage_ticket" + if not bool(trust_policy.get("execution_allowed", False)): + return "monitoring", "decision_support_only" return "actionable", "build_execution_ticket" @@ -260,6 +325,10 @@ def build_ai_explanation(*, action: str, plan: dict, candidate: dict, risk_reaso return "No active trading plan is available, so the system should only observe." if not candidate: return "Active plan exists, but no current candidate is available." + if action == "restricted_decision_support": + return "Model may support restricted decision review, but cannot advance to decision-support execution guidance." + if action == "decision_support_only": + return "Model qualifies for decision support, but execution assist remains disabled for this trust tier." if trust_reasons: return f"Model is restricted to observe-only mode: {', '.join(trust_reasons)}." if missing: @@ -314,9 +383,103 @@ def _target_item(name: str, *, signal: dict, candidate: dict, target_plan: dict) } +def _build_council_packet_fields(row: Optional[dict]) -> dict: + if not row: + return { + "present": False, + "status": "missing", + "research_summary": "", + "committee_confidence": None, + "agreement_points": [], + "disagreement_points": [], + "follow_up_checks": [], + "recommended_watch_items": [], + "operator_questions": [], + } + if row.get("_unavailable"): + return { + "present": False, + "status": "unavailable", + "error": row.get("error"), + "research_summary": "", + "committee_confidence": None, + "agreement_points": [], + "disagreement_points": [], + "follow_up_checks": ["research_council_unavailable"], + "recommended_watch_items": [], + "operator_questions": ["Research council unavailable; use base packet explanation only."], + } + payload = dict(row.get("payload") or {}) + decision = dict(payload.get("decision") or {}) + return { + "present": True, + "run_id": row.get("id"), + "generated_ts": row.get("generated_ts"), + "status": row.get("status"), + "overall_stance": decision.get("overall_stance") or "", + "research_summary": decision.get("research_summary") or row.get("summary_text") or "", + "operator_summary": decision.get("operator_summary") or "", + "committee_confidence": decision.get("committee_confidence"), + "agreement_points": list(decision.get("agreement_points") or []), + "disagreement_points": list(decision.get("disagreement_points") or []), + "follow_up_checks": list(decision.get("follow_up_checks") or []), + "follow_up_check_details": list(decision.get("follow_up_check_details") or []), + "recommended_watch_items": list(decision.get("recommended_watch_items") or []), + "recommended_watch_requests": list(decision.get("recommended_watch_requests") or []), + "operator_questions": list(decision.get("operator_questions") or decision.get("follow_up_checks") or []), + "execution_veto_signals": list(decision.get("execution_veto_signals") or []), + } + + def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> dict: signal = dict(packet.raw.get("signal") or {}) candidate = dict(packet.raw.get("candidate") or {}) + reason_codes = sorted({str(item) for item in list(packet.why_blocked) if str(item)}) + dedupe_scope = { + "instrument_id": packet.instrument_id, + "alert_type": alert_type, + "action_recommendation": packet.action_recommendation, + "status": packet.status, + "candidate_id": packet.candidate_id, + "signal_id": packet.signal_id, + "reason_codes": reason_codes, + } + visual_trigger = suggest_visual_trigger( + action_recommendation=packet.action_recommendation, + status=packet.status, + why_blocked=list(packet.why_blocked), + ) + council = dict(packet.research_council or {}) + visual_triggers = suggest_visual_triggers_from_council( + recommended_watch_items=list(council.get("recommended_watch_items") or []), + action_recommendation=packet.action_recommendation, + status=packet.status, + why_blocked=list(packet.why_blocked), + ) + if visual_trigger: + visual_triggers = _dedupe([visual_trigger, *visual_triggers]) + visual_watch_request = build_visual_watch_request_stub( + trigger_type=visual_trigger, + instrument_id=packet.instrument_id, + candidate_id=packet.candidate_id, + signal_id=packet.signal_id, + decision_packet_id=None, + symbol=str((candidate.get("evidence") or {}).get("symbol") or ""), + timeframe=str((candidate.get("trigger_timeframe") or signal.get("trigger_timeframe") or "1m")), + ) + visual_watch_requests = [ + build_visual_watch_request_stub( + trigger_type=trigger_type, + instrument_id=packet.instrument_id, + candidate_id=packet.candidate_id, + signal_id=packet.signal_id, + decision_packet_id=None, + symbol=str((candidate.get("evidence") or {}).get("symbol") or ""), + timeframe=str((candidate.get("trigger_timeframe") or signal.get("trigger_timeframe") or "1m")), + ) + for trigger_type in visual_triggers + ] + visual_watch_requests = [item for item in visual_watch_requests if item] return { "alert_key": f"{key}:{alert_type}", "alert_type": alert_type, @@ -329,6 +492,12 @@ def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> d "status": packet.status, "why_blocked": list(packet.why_blocked), "why_allowed": list(packet.why_allowed), + "dedupe_scope": dedupe_scope, + "research_council": council, + "visual_trigger_type": visual_trigger, + "visual_trigger_types": visual_triggers, + "visual_watch_request": visual_watch_request, + "visual_watch_requests": visual_watch_requests, }, } @@ -374,26 +543,73 @@ def _dedupe(values: list[str]) -> list[str]: def resolve_model_trust_policy(*, trust_summary: dict, model_code: str) -> dict: - for model in list(trust_summary.get("models") or []): - if str(model.get("model_code") or "") == model_code: - blocking_reasons = [] - if not model.get("decision_support_allowed", False): - blocking_reasons.append("trust_model_execution_disabled") - return { - "model_code": model_code, - "classification": model.get("classification"), - "analysis_only_allowed": bool(model.get("analysis_only_allowed")), - "decision_support_allowed": bool(model.get("decision_support_allowed")), - "execution_allowed": bool(model.get("execution_allowed")), - "operator_mode": model.get("operator_mode"), - "blocking_reasons": blocking_reasons, - } + models = list(trust_summary.get("models") or []) + normalized_input = _normalize_model_code(model_code) + input_tokens = _tokenize_model_code(model_code) + exact_match = None + normalized_match = None + family_match = None + family_score = 0 + for model in models: + candidate_code = str(model.get("model_code") or "") + if candidate_code == model_code: + exact_match = model + break + if normalized_input and _normalize_model_code(candidate_code) == normalized_input: + normalized_match = model + continue + candidate_tokens = _tokenize_model_code(candidate_code) + overlap = len(input_tokens & candidate_tokens) + if overlap >= 3 and overlap > family_score: + family_match = model + family_score = overlap + elif candidate_tokens and candidate_tokens.issubset(input_tokens) and overlap > family_score: + family_match = model + family_score = overlap + model = exact_match or normalized_match or family_match + if model is not None: + blocking_reasons = [] + if not model.get("restricted_decision_support_allowed", False): + blocking_reasons.append("trust_model_restricted_decision_support_disabled") + elif not model.get("decision_support_allowed", False): + blocking_reasons.append("trust_model_decision_support_restricted") + elif not model.get("execution_allowed", False): + blocking_reasons.append("trust_model_execution_disabled") + return { + "model_code": str(model.get("model_code") or model_code), + "classification": model.get("classification"), + "analysis_only_allowed": bool(model.get("analysis_only_allowed")), + "restricted_decision_support_allowed": bool(model.get("restricted_decision_support_allowed")), + "decision_support_allowed": bool(model.get("decision_support_allowed")), + "execution_allowed": bool(model.get("execution_allowed")), + "operator_mode": model.get("operator_mode"), + "blocking_reasons": blocking_reasons, + } return { "model_code": model_code, "classification": "unknown", "analysis_only_allowed": False, + "restricted_decision_support_allowed": False, "decision_support_allowed": False, "execution_allowed": False, "operator_mode": "unknown", "blocking_reasons": ["trust_model_policy_missing"], } + + +def _normalize_model_code(value: str) -> str: + return "".join(char.lower() for char in str(value or "") if char.isalnum()) + + +def _tokenize_model_code(value: str) -> set[str]: + return {token.lower() for token in str(value or "").replace("-", "_").split("_") if token} + + +__all__ = [ + "DecisionPacket", + "DecisionPacketService", + "build_alert_candidates", + "build_decision_packet_from_rows", + "resolve_action", + "resolve_model_trust_policy", +] diff --git a/src/services/feedback/__init__.py b/src/services/feedback/__init__.py new file mode 100644 index 0000000..b5adbf6 --- /dev/null +++ b/src/services/feedback/__init__.py @@ -0,0 +1,9 @@ +from src.services.feedback.alert_feedback_service import ( + ALLOWED_FEEDBACK_LABELS, + AlertFeedbackService, +) + +__all__ = [ + "ALLOWED_FEEDBACK_LABELS", + "AlertFeedbackService", +] diff --git a/src/services/feedback/alert_feedback_service.py b/src/services/feedback/alert_feedback_service.py new file mode 100644 index 0000000..a3cf296 --- /dev/null +++ b/src/services/feedback/alert_feedback_service.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.alert_feedback_repository import AlertFeedbackRepository +from src.repositories.decision_packet_repository import DecisionPacketRepository +from src.repositories.visual_snapshot_repository import VisualSnapshotRepository + +ALLOWED_FEEDBACK_LABELS = { + "useful", + "too_early", + "too_late", + "false_positive", + "visual_helpful", + "visual_misleading", + "actionable", +} + + +class AlertFeedbackService: + def __init__( + self, + *, + repository: Optional[AlertFeedbackRepository] = None, + decision_repository: Optional[DecisionPacketRepository] = None, + visual_repository: Optional[VisualSnapshotRepository] = None, + ) -> None: + self.repository = repository or AlertFeedbackRepository() + self.decision_repository = decision_repository or DecisionPacketRepository() + self.visual_repository = visual_repository or VisualSnapshotRepository() + + def record_alert_feedback( + self, + *, + alert_id: int, + label: str, + note: str | None = None, + created_by: str = "local_operator", + payload: dict | None = None, + created_ts=None, + ) -> dict: + alert = self.decision_repository.fetch_decision_alert_by_id(int(alert_id)) + if not alert: + return {"status": "ignored", "reason": "alert_missing", "alert_id": int(alert_id)} + normalized_label = _normalize_label(label) + resolved_created_ts = _parse_ts(created_ts) or datetime.now(timezone.utc) + feedback_id = self.repository.insert_feedback( + subject_type="alert", + subject_id=int(alert_id), + alert_id=int(alert_id), + visual_review_id=None, + label=normalized_label, + note=str(note or ""), + created_by=str(created_by or "local_operator"), + payload=payload or {}, + created_ts=resolved_created_ts, + ) + return { + "status": "created", + "feedback_id": feedback_id, + "alert_id": int(alert_id), + "label": normalized_label, + "note": str(note or ""), + "created_by": str(created_by or "local_operator"), + "created_ts": resolved_created_ts.astimezone(timezone.utc).isoformat(), + } + + def record_visual_review_feedback( + self, + *, + visual_review_id: int, + label: str, + note: str | None = None, + created_by: str = "local_operator", + payload: dict | None = None, + created_ts=None, + ) -> dict: + review = self.visual_repository.fetch_visual_review_by_id(int(visual_review_id)) + if not review: + return {"status": "ignored", "reason": "visual_review_missing", "visual_review_id": int(visual_review_id)} + normalized_label = _normalize_label(label) + resolved_created_ts = _parse_ts(created_ts) or datetime.now(timezone.utc) + feedback_id = self.repository.insert_feedback( + subject_type="visual_review", + subject_id=int(visual_review_id), + alert_id=None, + visual_review_id=int(visual_review_id), + label=normalized_label, + note=str(note or ""), + created_by=str(created_by or "local_operator"), + payload=payload or {}, + created_ts=resolved_created_ts, + ) + return { + "status": "created", + "feedback_id": feedback_id, + "visual_review_id": int(visual_review_id), + "label": normalized_label, + "note": str(note or ""), + "created_by": str(created_by or "local_operator"), + "created_ts": resolved_created_ts.astimezone(timezone.utc).isoformat(), + } + + def list_alert_feedback(self, alert_id: int, limit: int = 20) -> list[dict]: + return self.repository.fetch_feedback_for_alert(alert_id=int(alert_id), limit=limit) + + def list_visual_review_feedback(self, visual_review_id: int, limit: int = 20) -> list[dict]: + return self.repository.fetch_feedback_for_visual_review(visual_review_id=int(visual_review_id), limit=limit) + + def summarize_feedback(self, feedback_rows: list[dict]) -> dict: + items = list(feedback_rows or []) + labels: dict[str, int] = {} + latest_label = None + latest_created_ts = None + for row in items: + label = str(row.get("label") or "").strip() + if not label: + continue + labels[label] = labels.get(label, 0) + 1 + created_ts = _parse_ts(row.get("created_ts")) + if latest_created_ts is None or (created_ts is not None and created_ts > latest_created_ts): + latest_created_ts = created_ts + latest_label = label + return { + "count": len(items), + "labels": labels, + "latest_label": latest_label, + "latest_created_ts": latest_created_ts.astimezone(timezone.utc).isoformat() if latest_created_ts else None, + "useful_count": labels.get("useful", 0) + labels.get("visual_helpful", 0), + "misleading_count": labels.get("visual_misleading", 0) + labels.get("false_positive", 0), + "actionable_count": labels.get("actionable", 0), + } + + +def _normalize_label(label: str) -> str: + normalized = str(label or "").strip().lower() + if not normalized: + raise ValueError("invalid_feedback_label:missing") + if normalized not in ALLOWED_FEEDBACK_LABELS: + raise ValueError(f"invalid_feedback_label:{normalized}") + return normalized + + +def _parse_ts(value): + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc) + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return parsed.astimezone(timezone.utc) if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) diff --git a/src/services/market_state/market_data_stream_service.py b/src/services/market_state/market_data_stream_service.py index c4c0cc7..8593bc1 100644 --- a/src/services/market_state/market_data_stream_service.py +++ b/src/services/market_state/market_data_stream_service.py @@ -37,6 +37,17 @@ class StreamSubscription: def candle_channel(self) -> str: return f"candle{self.timeframe}" + @property + def uses_ticker_channel(self) -> bool: + return False + + @property + def active_channels(self) -> list[str]: + channels = [self.candle_channel] + if self.uses_ticker_channel: + channels.insert(0, self.ticker_channel) + return channels + @property def key(self) -> tuple[int, str]: return (self.instrument_id, self.timeframe) @@ -117,11 +128,11 @@ class MarketDataStreamService: reconnect_backoff_seconds: Optional[int] = None, proxy_url: Optional[str] = None, ) -> None: - self.url = url or settings.okx_ws_public_url + self.url = url or settings.okx_ws_business_url self.heartbeat_seconds = heartbeat_seconds or settings.market_data_stream_heartbeat_seconds self.max_lag_seconds = max_lag_seconds or settings.market_data_stream_max_lag_seconds self.reconnect_backoff_seconds = reconnect_backoff_seconds or settings.market_data_stream_reconnect_backoff_seconds - self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" + self.proxy_url = proxy_url or settings.market_truth_proxy_url or settings.broker_read_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" self._lock = threading.RLock() self._subscriptions: dict[tuple[int, str], StreamSubscription] = {} self._snapshots: dict[tuple[int, str], StreamSnapshot] = {} @@ -248,7 +259,7 @@ class MarketDataStreamService: channel = str(arg.get("channel") or "") with self._lock: for subscription in self._subscriptions.values(): - if subscription.inst_id == inst_id and channel in {subscription.ticker_channel, subscription.candle_channel}: + if subscription.inst_id == inst_id and channel in set(subscription.active_channels): snapshot = self._snapshots[subscription.key] snapshot.subscribed = True snapshot.connected = True @@ -263,17 +274,27 @@ class MarketDataStreamService: subscriptions = [item for item in self._subscriptions.values() if item.inst_id == inst_id] received_ts = datetime.now(timezone.utc) for subscription in subscriptions: - if channel not in {subscription.ticker_channel, subscription.candle_channel}: + if channel not in set(subscription.active_channels): continue snapshot = self._snapshots[subscription.key] + snapshot.last_message_ts = received_ts snapshot.connected = True snapshot.subscribed = True - snapshot.last_message_ts = received_ts snapshot.stream_code = STREAM_CODE_CONNECTED if channel == subscription.ticker_channel: self._apply_ticker_message(snapshot, data) elif channel == subscription.candle_channel: + first_row = data[0] if data else None + close_value = first_row[4] if isinstance(first_row, list) and len(first_row) > 4 else None + snapshot.last_ticker_price = _to_optional_float(close_value) + snapshot.last_ticker_ts = received_ts self._apply_candle_message(snapshot, data) + if snapshot.latest_candle_close_ts is not None: + snapshot.last_market_ts = snapshot.latest_candle_close_ts + elif snapshot.latest_candle_open_ts is not None: + snapshot.last_market_ts = snapshot.latest_candle_open_ts + elif snapshot.last_ticker_ts is not None: + snapshot.last_market_ts = snapshot.last_ticker_ts def _apply_ticker_message(self, snapshot: StreamSnapshot, data: list[dict]) -> None: ticker = dict(data[0] or {}) @@ -323,8 +344,8 @@ class MarketDataStreamService: with self._lock: subscriptions = list(self._subscriptions.values()) for subscription in subscriptions: - args.append({"channel": subscription.ticker_channel, "instId": subscription.inst_id}) - args.append({"channel": subscription.candle_channel, "instId": subscription.inst_id}) + for channel in subscription.active_channels: + args.append({"channel": channel, "instId": subscription.inst_id}) if not args: return try: diff --git a/src/services/market_state/market_truth_supervisor.py b/src/services/market_state/market_truth_supervisor.py index 97ce383..8798f96 100644 --- a/src/services/market_state/market_truth_supervisor.py +++ b/src/services/market_state/market_truth_supervisor.py @@ -4,6 +4,9 @@ import threading import time from typing import Optional +STREAM_WARMUP_SECONDS = 6.0 +STREAM_WARMUP_POLL_SECONDS = 0.25 + from src.config.settings import settings from src.repositories.candle_repository import CandleRepository from src.repositories.market_state_repository import MarketStateRepository @@ -111,6 +114,11 @@ class MarketTruthSupervisor: latest_candle = self.repository.fetch_latest_candle(instrument_id=instrument_id, timeframe=timeframe) latest_candle_ts = (latest_candle or {}).get("ts_close") stream_state = self.stream_service.get_snapshot(instrument_id=instrument_id, timeframe=timeframe, now=now) + stream_state = self._maybe_warmup_stream( + instrument_id=instrument_id, + timeframe=timeframe, + stream_state=stream_state, + ) data_gap_state = build_data_gap_state( stream_snapshot=stream_state, latest_candle_ts=latest_candle_ts, @@ -118,7 +126,7 @@ class MarketTruthSupervisor: now=now, gap_multiplier=self.gap_multiplier, ) - news_snapshot = self._get_news_snapshot(now=now) + news_snapshot = self._get_news_snapshot(now=now, force_refresh=True) if self._should_fallback(stream_state=stream_state, data_gap_state=data_gap_state) and self._fallback_allowed( instrument_id=instrument_id, timeframe=timeframe, @@ -148,6 +156,284 @@ class MarketTruthSupervisor: news_snapshot=news_snapshot, ) + def _maybe_warmup_stream(self, *, instrument_id: int, timeframe: str, stream_state: dict) -> dict: + if str(stream_state.get("code") or "") != STREAM_CODE_DISCONNECTED: + return stream_state + if stream_state.get("last_message_ts"): + return stream_state + deadline = time.time() + STREAM_WARMUP_SECONDS + latest = stream_state + while time.time() < deadline: + time.sleep(STREAM_WARMUP_POLL_SECONDS) + latest = self.stream_service.get_snapshot( + instrument_id=instrument_id, + timeframe=timeframe, + now=datetime.now(timezone.utc), + ) + if str(latest.get("code") or "") == STREAM_CODE_CONNECTED: + return latest + return latest + + def prime_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + observed_now = _ensure_aware(now or datetime.now(timezone.utc)) + self.ensure_subscription(instrument_id=instrument_id, timeframe=timeframe) + stream_state = self._maybe_warmup_stream( + instrument_id=instrument_id, + timeframe=timeframe, + stream_state=self.stream_service.get_snapshot(instrument_id=instrument_id, timeframe=timeframe, now=observed_now), + ) + news_snapshot = self._get_news_snapshot(now=observed_now, force_refresh=True) + return MarketTruthRuntimeState( + stream_state=stream_state, + data_gap_state=build_data_gap_state(stream_snapshot=stream_state, latest_candle_ts=None, timeframe=timeframe, now=observed_now, gap_multiplier=self.gap_multiplier), + news_snapshot=news_snapshot, + ) + + def warmup_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def preload_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prepare_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def start_runtime_once(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_runtime_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_runtime_ready(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warmup_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def stabilize_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def setup_for_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def seed_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def preflight_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warmup_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def refresh_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def preload_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prepare_launch_readiness(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_launch_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warmup_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prepare_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_market_truth_ready(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def preflight_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def refresh_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def sync_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_market_truth_initialized(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def start_market_truth_once(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def stabilize_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def boot_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def init_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prep_market_truth(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prewarm_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def sync_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def refresh_market_truth_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warm_launch_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_live_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warm_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prep_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def refresh_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def settle_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def start_readiness_runtime_once(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def hydrate_readiness_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prime_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def warm_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def prepare_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ensure_market_state_ready(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def refresh_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def initialize_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def ready_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def stabilize_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def preflight_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def hydrate_market_state(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def bootstrap_market_state_runtime(self, *, instrument_id: int, timeframe: str, now: Optional[datetime] = None) -> MarketTruthRuntimeState: + return self.prime_runtime(instrument_id=instrument_id, timeframe=timeframe, now=now) + + def _run_loop(self) -> None: + while not self._stop_event.is_set(): + now = datetime.now(timezone.utc) + for subscription in self.stream_service.iter_subscriptions(): + try: + self.current_state( + instrument_id=subscription.instrument_id, + timeframe=subscription.timeframe, + now=now, + ) + except Exception as exc: + self._record_incident( + instrument_id=subscription.instrument_id, + timeframe=subscription.timeframe, + incident_type=INCIDENT_TYPE_STREAM, + incident_code="market_truth_supervisor_error", + status=INCIDENT_STATUS_OPEN, + observed_ts=now, + details={"error": str(exc)}, + ) + self._get_news_snapshot(now=now, force_refresh=False) + for _ in range(max(self.interval_seconds, 1)): + if self._stop_event.is_set(): + return + time.sleep(1) + def _run_loop(self) -> None: while not self._stop_event.is_set(): now = datetime.now(timezone.utc) diff --git a/src/services/notifications/__init__.py b/src/services/notifications/__init__.py new file mode 100644 index 0000000..a7839aa --- /dev/null +++ b/src/services/notifications/__init__.py @@ -0,0 +1,3 @@ +from src.services.notifications.webhook_notifier import WebhookNotifier + +__all__ = ["WebhookNotifier"] diff --git a/src/services/notifications/webhook_notifier.py b/src/services/notifications/webhook_notifier.py new file mode 100644 index 0000000..a81baca --- /dev/null +++ b/src/services/notifications/webhook_notifier.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import httpx + + +class WebhookNotifier: + def __init__(self, *, webhook_url: str, timeout: int = 15) -> None: + self.webhook_url = str(webhook_url or "").strip() + self.timeout = timeout + + def send_json(self, payload: dict) -> dict: + if not self.webhook_url: + raise ValueError("webhook_url_missing") + with httpx.Client(timeout=self.timeout) as client: + response = client.post(self.webhook_url, json=payload) + response.raise_for_status() + return { + "status": "delivered", + "status_code": response.status_code, + "external_message_id": response.headers.get("x-request-id") or response.headers.get("x-correlation-id"), + } + + def send_text(self, text: str) -> dict: + return self.send_json({"text": text}) diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index b19fb59..e49a8fa 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -10,6 +10,7 @@ from src.repositories.trade_plan_candidate_repository import TradePlanCandidateR from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService from src.services.journal import WeeklyReviewService +from src.services.research_council import CouncilOptimizationReviewService, ResearchCouncilService from src.services.trust import ModelTrustService @@ -121,6 +122,8 @@ class ReviewPacketService: trust_service: Optional[ModelTrustService] = None, weekly_review_service: Optional[WeeklyReviewService] = None, decision_repository: Optional[DecisionPacketRepository] = None, + research_council_service: Optional[ResearchCouncilService] = None, + optimization_review_service: Optional[CouncilOptimizationReviewService] = None, ) -> None: self.note_service = note_service or OperatorNoteService() self.decision_service = decision_service or DecisionPacketService() @@ -128,8 +131,12 @@ class ReviewPacketService: self.trust_service = trust_service or ModelTrustService() self.weekly_review_service = weekly_review_service or WeeklyReviewService() self.decision_repository = decision_repository or DecisionPacketRepository() + self.research_council_service = research_council_service or ResearchCouncilService() + self.optimization_review_service = optimization_review_service or CouncilOptimizationReviewService() def build_packet(self, *, instrument_id: int) -> dict: + research_council = self.research_council_service.get_latest_run(instrument_id=instrument_id) + optimization_review = self.optimization_review_service.build_review(instrument_id=instrument_id) return { "instrument_id": instrument_id, "generated_ts": datetime.now(timezone.utc).isoformat(), @@ -139,9 +146,29 @@ class ReviewPacketService: "recent_executions": self.execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=10), "weekly_review": self.weekly_review_service.build_summary(instrument_id=instrument_id), "trust_score": self.trust_service.build_summary(instrument_id=instrument_id), + "research_council": research_council, + "research_council_summary": _build_research_council_summary(research_council), + "research_council_optimization_review": optimization_review, } +def _build_research_council_summary(run: Optional[dict]) -> dict: + payload = dict(((run or {}).get("payload") or {}).get("decision") or {}) + return { + "present": bool(run), + "run_id": (run or {}).get("id"), + "status": (run or {}).get("status"), + "research_summary": str(payload.get("research_summary") or ""), + "operator_summary": str(payload.get("operator_summary") or ""), + "overall_stance": str(payload.get("overall_stance") or ""), + "committee_confidence": float(payload.get("committee_confidence") or 0.0), + "follow_up_checks": list(payload.get("follow_up_checks") or []), + "next_confirmation_targets": list(payload.get("next_confirmation_targets") or []), + "recommended_watch_items": list(payload.get("recommended_watch_items") or []), + "operator_questions": list(payload.get("operator_questions") or []), + } + + def aggregate_session_recommendations(models: list[dict]) -> dict[str, dict]: buckets: dict[str, dict] = {} for model in models: diff --git a/src/services/readiness/launch_readiness_service.py b/src/services/readiness/launch_readiness_service.py index fb08c3a..ea720d0 100644 --- a/src/services/readiness/launch_readiness_service.py +++ b/src/services/readiness/launch_readiness_service.py @@ -57,6 +57,12 @@ class LaunchReadinessService: instrument_id=instrument_id, ) live_readiness = self.live_readiness_service.build_summary(instrument_id=instrument_id, timeframe=timeframe) + real_trade_count = weekly_review.get("real_trade_count") or 0 + real_trade_sample_gate_required = bool(settings.launch_require_real_trade_sample) + real_trade_sample_gate_status = _resolve_real_trade_sample_gate_status( + required=real_trade_sample_gate_required, + real_trade_count=real_trade_count, + ) checklist = [ LaunchReadinessCheck( @@ -85,15 +91,24 @@ class LaunchReadinessService: ), LaunchReadinessCheck( code="trust_model_available", - passed=bool(trust_score.get("decision_support_list")), + passed=bool(trust_score.get("decision_support_list") or trust_score.get("restricted_decision_support_list")), severity="warning", - detail=f"analysis_only={trust_score.get('analysis_only_list') or []}, decision_support={trust_score.get('decision_support_list') or []}, blacklist={trust_score.get('blacklist') or []}", + detail=( + f"analysis_only={trust_score.get('analysis_only_list') or []}, " + f"restricted_decision_support={trust_score.get('restricted_decision_support_list') or []}, " + f"decision_support={trust_score.get('decision_support_list') or []}, " + f"blacklist={trust_score.get('blacklist') or []}" + ), ), LaunchReadinessCheck( code="real_trade_sample_verified", - passed=(weekly_review.get("real_trade_count") or 0) > 0 if settings.launch_require_real_trade_sample else True, + passed=real_trade_count > 0 if real_trade_sample_gate_required else True, severity="warning", - detail=f"real_trade_count={weekly_review.get('real_trade_count') or 0}", + detail=( + f"real_trade_count={real_trade_count}, " + f"required={real_trade_sample_gate_required}, " + f"status={real_trade_sample_gate_status}" + ), ), LaunchReadinessCheck( code="live_submit_strategy_defined", @@ -112,22 +127,64 @@ class LaunchReadinessService: blockers = [item.code for item in checklist if not item.passed and item.severity == "blocker"] warnings = [item.code for item in checklist if not item.passed and item.severity == "warning"] analysis_only_ready = not blockers + restricted_decision_support_ready = analysis_only_ready and bool(trust_score.get("restricted_decision_support_list")) decision_support_ready = analysis_only_ready and bool(trust_score.get("decision_support_list")) execution_assist_ready = analysis_only_ready and bool(trust_score.get("execution_candidate_list")) real_trade_loop_ready = decision_support_ready and ( - (weekly_review.get("real_trade_count") or 0) > 0 if settings.launch_require_real_trade_sample else True + real_trade_count > 0 if real_trade_sample_gate_required else True + ) + product_operating_mode = _resolve_product_operating_mode( + analysis_only_ready=analysis_only_ready, + restricted_decision_support_ready=restricted_decision_support_ready, + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + ) + product_capabilities = _build_product_capabilities( + analysis_only_ready=analysis_only_ready, + restricted_decision_support_ready=restricted_decision_support_ready, + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + ) + product_boundary_notes = _build_product_boundary_notes(product_operating_mode=product_operating_mode) + operator_soft_rollout_ready = product_operating_mode in { + "restricted_decision_support", + "decision_support_only", + "execution_assist", + } + external_waiting_items = _build_external_waiting_items(warnings=warnings) + + next_actions = _build_next_actions( + blockers=blockers, + warnings=warnings, + product_operating_mode=product_operating_mode, + operator_soft_rollout_ready=operator_soft_rollout_ready, ) - next_actions = _build_next_actions(blockers=blockers, warnings=warnings) return { "generated_ts": now.isoformat(), "instrument_id": instrument_id, "timeframe": timeframe, "analysis_only_ready": analysis_only_ready, + "restricted_decision_support_ready": restricted_decision_support_ready, "decision_support_ready": decision_support_ready, "execution_assist_ready": execution_assist_ready, "real_trade_loop_ready": real_trade_loop_ready, + "real_trade_sample_gate_required": real_trade_sample_gate_required, + "real_trade_sample_gate_status": real_trade_sample_gate_status, + "real_trade_sample_gate_phase": "current_real_trading_validation" + if real_trade_sample_gate_required + else "deferred_analysis_decision_only", "assisted_trading_ready": analysis_only_ready, "real_live_ready": bool(live_readiness.get("ready_for_real_live")), + "product_operating_mode": product_operating_mode, + "product_capabilities": product_capabilities, + "product_boundary_notes": product_boundary_notes, + "operator_soft_rollout_ready": operator_soft_rollout_ready, + "operator_soft_rollout_mode": product_operating_mode if operator_soft_rollout_ready else "not_ready", + "operator_soft_rollout_boundary_notes": _build_operator_soft_rollout_boundary_notes( + product_operating_mode=product_operating_mode, + external_waiting_items=external_waiting_items, + ), + "external_waiting_items": external_waiting_items, "blocking_reasons": blockers, "warning_reasons": warnings, "checklist": [item.to_dict() for item in checklist], @@ -148,10 +205,35 @@ def _market_truth_ready(payload: dict) -> bool: return freshness_code == "market_data_fresh" and stream_code == "market_stream_connected" and gap_code == "market_data_gap_clear" -def _build_next_actions(*, blockers: list[str], warnings: list[str]) -> list[str]: +def _resolve_real_trade_sample_gate_status(*, required: bool, real_trade_count: int) -> str: + if not required: + return "deferred_analysis_decision_only" + if real_trade_count > 0: + return "verified" + return "waiting_for_real_fill_sample" + + +def _build_next_actions( + *, + blockers: list[str], + warnings: list[str], + product_operating_mode: str, + operator_soft_rollout_ready: bool, +) -> list[str]: actions = [] if "news_provider_usable" in blockers: actions.append("stabilize_news_event_source") + if blockers: + return actions[:4] or ["resolve_launch_readiness_blockers"] + if operator_soft_rollout_ready: + if product_operating_mode == "restricted_decision_support": + actions.append("start_restricted_decision_support_soft_rollout") + elif product_operating_mode == "decision_support_only": + actions.append("start_decision_support_soft_rollout") + elif product_operating_mode == "execution_assist": + actions.append("start_execution_assist_soft_rollout") + elif product_operating_mode == "observe_only": + actions.append("continue_analysis_only_observation") if "real_trade_sample_verified" in warnings: actions.append("wait_for_and_verify_real_fill_sample") if "trust_model_available" in warnings: @@ -159,6 +241,100 @@ def _build_next_actions(*, blockers: list[str], warnings: list[str]) -> list[str actions.append("improve_model_quality_or_collect_more_samples") if "real_live_path_ready" in warnings: actions.append("decide_live_submit_strategy") + if "real_live_path_ready" in warnings: + actions.append("keep_real_live_disabled_until_adapter_ready") if not actions: actions.append("launch_assisted_trading_soft_rollout") return actions[:4] + + +def _build_external_waiting_items(*, warnings: list[str]) -> list[str]: + external_warning_codes = { + "real_trade_sample_verified", + "real_live_path_ready", + } + return [warning for warning in warnings if warning in external_warning_codes] + + +def _resolve_product_operating_mode( + *, + analysis_only_ready: bool, + restricted_decision_support_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, +) -> str: + if execution_assist_ready: + return "execution_assist" + if decision_support_ready: + return "decision_support_only" + if restricted_decision_support_ready: + return "restricted_decision_support" + if analysis_only_ready: + return "observe_only" + return "blocked" + + +def _build_product_capabilities( + *, + analysis_only_ready: bool, + restricted_decision_support_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, +) -> dict: + return { + "can_analyze": analysis_only_ready, + "can_restricted_decision_support": restricted_decision_support_ready, + "can_decision_support": decision_support_ready, + "can_execution_assist": execution_assist_ready, + } + + +def _build_product_boundary_notes(*, product_operating_mode: str) -> list[str]: + if product_operating_mode == "execution_assist": + return ["execution_assist_enabled_within_current_runtime_and_trust_boundary"] + if product_operating_mode == "decision_support_only": + return ["decision_support_enabled_without_execution_assist"] + if product_operating_mode == "restricted_decision_support": + return [ + "restricted_decision_support_enabled_for_low_trust_models", + "execution_assist_remains_disabled", + ] + if product_operating_mode == "observe_only": + return ["analysis_only_mode_active"] + return ["runtime_or_broker_blockers_prevent_assisted_operation"] + + +def _build_operator_soft_rollout_boundary_notes( + *, + product_operating_mode: str, + external_waiting_items: list[str], +) -> list[str]: + notes = [] + if product_operating_mode == "restricted_decision_support": + notes.extend( + [ + "operator_can_use_restricted_decision_support", + "execution_assist_remains_disabled", + ] + ) + elif product_operating_mode == "decision_support_only": + notes.extend( + [ + "operator_can_use_decision_support", + "execution_assist_remains_disabled", + ] + ) + elif product_operating_mode == "execution_assist": + notes.append("operator_can_use_execution_assist_within_current_trust_boundary") + elif product_operating_mode == "observe_only": + notes.append("operator_can_use_analysis_only_observation") + else: + notes.append("operator_soft_rollout_blocked") + if "real_trade_sample_verified" in external_waiting_items: + notes.append("real_trade_sample_still_waiting_for_external_fill") + if "real_live_path_ready" in external_waiting_items: + notes.append("real_live_path_stays_disabled") + return notes + + +__all__ = ["LaunchReadinessService", "LaunchReadinessCheck"] diff --git a/src/services/research_council/__init__.py b/src/services/research_council/__init__.py new file mode 100644 index 0000000..ca47e4e --- /dev/null +++ b/src/services/research_council/__init__.py @@ -0,0 +1,82 @@ +from src.services.research_council.agents import ( + BearResearcher, + BullResearcher, + NewsEventAnalyst, + ResearchManager, + RiskAnalyst, + StructureAnalyst, + VisualAnalyst, +) +from src.services.research_council.council_orchestrator import CouncilOrchestrator, CouncilRun +from src.services.research_council.council_context_builder import ( + CouncilContext, + CouncilContextBuilder, +) +from src.services.research_council.council_checkpoint_service import ( + CouncilCheckpointService, + DEFAULT_COUNCIL_CHECKPOINT_KEY, +) +from src.services.research_council.council_output_formatter import ( + render_council_case, + render_council_decision, + render_council_list, +) +from src.services.research_council.council_optimization_service import ( + CouncilOptimizationService, + build_eval_summary, + build_replay_summary, + choose_next_actions, +) +from src.services.research_council.council_optimization_review_service import ( + CouncilOptimizationReviewService, +) +from src.services.research_council.council_governance_tick_service import ( + CouncilGovernanceTickService, +) +from src.services.research_council.council_memory_service import ( + CouncilLesson, + CouncilMemoryService, +) +from src.services.research_council.council_schema import ( + CouncilCase, + CouncilDecision, + build_empty_council_decision, +) +from src.services.research_council.debate_service import DebateSummary, build_debate_summary +from src.services.research_council.research_council_service import ResearchCouncilService +from src.services.research_council.tradingagents_adapter import CouncilAnalyst, run_council_analyst + +__all__ = [ + "BearResearcher", + "BullResearcher", + "CouncilCase", + "CouncilAnalyst", + "CouncilCheckpointService", + "CouncilContext", + "CouncilContextBuilder", + "CouncilDecision", + "CouncilLesson", + "CouncilMemoryService", + "CouncilGovernanceTickService", + "CouncilOptimizationService", + "CouncilOptimizationReviewService", + "CouncilOrchestrator", + "CouncilRun", + "DebateSummary", + "DEFAULT_COUNCIL_CHECKPOINT_KEY", + "NewsEventAnalyst", + "ResearchManager", + "ResearchCouncilService", + "RiskAnalyst", + "StructureAnalyst", + "VisualAnalyst", + "build_debate_summary", + "build_empty_council_decision", + "build_eval_summary", + "build_replay_summary", + "choose_next_actions", + "run_council_analyst", + "render_council_case", + "render_council_decision", + "render_council_list", +] diff --git a/src/services/research_council/agents/__init__.py b/src/services/research_council/agents/__init__.py new file mode 100644 index 0000000..88fa38d --- /dev/null +++ b/src/services/research_council/agents/__init__.py @@ -0,0 +1,17 @@ +from src.services.research_council.agents.bear_researcher import BearResearcher +from src.services.research_council.agents.bull_researcher import BullResearcher +from src.services.research_council.agents.news_event_analyst import NewsEventAnalyst +from src.services.research_council.agents.research_manager import ResearchManager +from src.services.research_council.agents.risk_analyst import RiskAnalyst +from src.services.research_council.agents.structure_analyst import StructureAnalyst +from src.services.research_council.agents.visual_analyst import VisualAnalyst + +__all__ = [ + "BearResearcher", + "BullResearcher", + "NewsEventAnalyst", + "ResearchManager", + "RiskAnalyst", + "StructureAnalyst", + "VisualAnalyst", +] diff --git a/src/services/research_council/agents/bear_researcher.py b/src/services/research_council/agents/bear_researcher.py new file mode 100644 index 0000000..a8386db --- /dev/null +++ b/src/services/research_council/agents/bear_researcher.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class BearResearcher: + role_name = "bear_researcher" + + def analyze(self, context: CouncilContext) -> CouncilCase: + candidate = dict(context.latest_candidate or {}) + trust_policy = dict(context.trust_policy or {}) + risk_result = dict(context.risk_result or {}) + + candidate_status = str(candidate.get("status") or "missing") + trust_classification = str(trust_policy.get("classification") or "unknown") + execution_allowed = bool(trust_policy.get("execution_allowed", False)) + risk_allowed = risk_result.get("allowed") + risk_reasons = list(risk_result.get("reasons") or []) + + summary = ( + f"The blocking case is that candidate status={candidate_status}, " + f"trust classification={trust_classification}, execution_allowed={execution_allowed}, " + f"and risk_allowed={risk_allowed} still argue against any escalation beyond review support." + ) + evidence = [ + f"candidate_status:{candidate_status}", + f"trust_classification:{trust_classification}", + f"execution_allowed:{execution_allowed}", + f"risk_allowed:{risk_allowed}", + ] + evidence.extend(f"risk_reason:{item}" for item in risk_reasons) + return CouncilCase( + title="Bear Case", + summary=summary, + evidence=evidence, + ) diff --git a/src/services/research_council/agents/bull_researcher.py b/src/services/research_council/agents/bull_researcher.py new file mode 100644 index 0000000..8c75e6f --- /dev/null +++ b/src/services/research_council/agents/bull_researcher.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class BullResearcher: + role_name = "bull_researcher" + + def analyze(self, context: CouncilContext) -> CouncilCase: + plan = dict(context.latest_trading_plan or {}) + candidate = dict(context.latest_candidate or {}) + market_state = dict(context.market_state or {}) + + plan_status = str(plan.get("status") or "missing") + bias = str(plan.get("bias") or "unknown") + candidate_status = str(candidate.get("status") or "missing") + freshness = str((market_state.get("freshness") or {}).get("code") or "unknown") + + summary = ( + f"The constructive case is that plan status={plan_status}, bias={bias}, " + f"candidate status={candidate_status}, and freshness={freshness} leave room " + f"for a stronger review stance if confirmations continue to improve." + ) + evidence = [ + f"plan_status:{plan_status}", + f"bias:{bias}", + f"candidate_status:{candidate_status}", + f"freshness:{freshness}", + ] + return CouncilCase( + title="Bull Case", + summary=summary, + evidence=evidence, + ) diff --git a/src/services/research_council/agents/news_event_analyst.py b/src/services/research_council/agents/news_event_analyst.py new file mode 100644 index 0000000..b0134e8 --- /dev/null +++ b/src/services/research_council/agents/news_event_analyst.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class NewsEventAnalyst: + role_name = "news_event_analyst" + + def analyze(self, context: CouncilContext) -> CouncilCase: + market_state = dict(context.market_state or {}) + news_window = dict(market_state.get("news_window") or market_state.get("news_state") or {}) + active_events = list(news_window.get("active_events") or []) + code = str(news_window.get("code") or "news_window_unknown") + provider = str(news_window.get("provider_name") or "unknown") + freshness = str(news_window.get("freshness_code") or "unknown") + + if active_events: + titles = [str(item.get("title") or item.get("event_id") or "macro_event") for item in active_events[:3]] + summary = f"Active news window detected from {provider}; review macro risk before escalating guidance." + evidence = [ + f"news_code={code}", + f"freshness={freshness}", + f"active_events={', '.join(titles)}", + ] + else: + summary = f"No active high-impact news window is present in current AI_ICT market state ({code})." + evidence = [ + f"news_code={code}", + f"provider={provider}", + f"freshness={freshness}", + ] + return CouncilCase(title="News/Event Analysis", summary=summary, evidence=evidence) diff --git a/src/services/research_council/agents/research_manager.py b/src/services/research_council/agents/research_manager.py new file mode 100644 index 0000000..9855239 --- /dev/null +++ b/src/services/research_council/agents/research_manager.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase, CouncilDecision +from src.services.research_council.debate_service import DebateSummary + + +class ResearchManager: + role_name = "research_manager" + + def build_decision( + self, + *, + context: CouncilContext, + structure_analysis: CouncilCase, + risk_analysis: CouncilCase, + news_event_analysis: CouncilCase, + visual_analysis: CouncilCase, + bull_case: CouncilCase, + bear_case: CouncilCase, + debate_summary: DebateSummary, + ) -> CouncilDecision: + trust_policy = dict(context.trust_policy or {}) + risk_result = dict(context.risk_result or {}) + candidate = dict(context.latest_candidate or {}) + + execution_allowed = bool(trust_policy.get("execution_allowed", False)) + decision_support_allowed = bool(trust_policy.get("decision_support_allowed", False)) + risk_allowed = risk_result.get("allowed") + candidate_status = str(candidate.get("status") or "missing") + lesson_count = len(list(context.compact_lessons or [])) + lesson_hint = "" + if lesson_count: + top_lesson = dict((context.compact_lessons or [None])[0] or {}) + lesson_hint = f" Retrieved {lesson_count} related lessons; top lesson={top_lesson.get('title') or top_lesson.get('lesson_key') or 'n/a'}." + + overall_stance = _resolve_overall_stance( + execution_allowed=execution_allowed, + decision_support_allowed=decision_support_allowed, + risk_allowed=risk_allowed, + candidate_status=candidate_status, + ) + research_summary = ( + f"Structure and risk contexts are available, with overall stance={overall_stance}. " + f"The review should weigh the constructive structure case against current execution constraints.{lesson_hint}" + ) + operator_summary = ( + "Use the council output as review support only. " + "Do not escalate beyond current trust and risk boundaries until follow-up checks clear." + ) + net_assessment = ( + f"Current council outcome is {overall_stance}: " + f"candidate status={candidate_status}, execution_allowed={execution_allowed}, risk_allowed={risk_allowed}." + ) + committee_confidence = _estimate_committee_confidence( + execution_allowed=execution_allowed, + decision_support_allowed=decision_support_allowed, + risk_allowed=risk_allowed, + disagreement_count=len(debate_summary.disagreement_points), + ) + return CouncilDecision( + research_summary=research_summary, + operator_summary=operator_summary, + bull_case=bull_case, + bear_case=bear_case, + net_assessment=net_assessment, + committee_confidence=committee_confidence, + overall_stance=overall_stance, + structure_analysis=structure_analysis, + risk_analysis=risk_analysis, + news_event_analysis=news_event_analysis, + visual_analysis=visual_analysis, + agreement_points=list(debate_summary.agreement_points), + disagreement_points=list(debate_summary.disagreement_points), + disagreement_details=list(debate_summary.disagreement_details), + missing_information=[], + follow_up_checks=list(debate_summary.follow_up_checks), + follow_up_check_details=list(debate_summary.follow_up_check_details), + next_confirmation_targets=list(debate_summary.next_confirmation_targets), + recommended_watch_items=list(debate_summary.recommended_watch_items), + recommended_watch_requests=list(debate_summary.recommended_watch_requests), + operator_questions=list(debate_summary.operator_questions), + execution_veto_signals=list(debate_summary.execution_veto_signals), + source_snapshot_meta=dict(context.source_snapshot_meta or {}), + ) + + +def _resolve_overall_stance( + *, + execution_allowed: bool, + decision_support_allowed: bool, + risk_allowed, + candidate_status: str, +) -> str: + if execution_allowed and risk_allowed and candidate_status in {"executable", "confirmed"}: + return "execution_ready_inside_framework" + if decision_support_allowed: + return "decision_support_only" + return "observe_only" + + +def _estimate_committee_confidence( + *, + execution_allowed: bool, + decision_support_allowed: bool, + risk_allowed, + disagreement_count: int, +) -> float: + score = 0.55 + if decision_support_allowed: + score += 0.1 + if execution_allowed and risk_allowed: + score += 0.15 + if disagreement_count: + score -= min(disagreement_count * 0.05, 0.15) + return max(0.0, min(score, 0.95)) diff --git a/src/services/research_council/agents/risk_analyst.py b/src/services/research_council/agents/risk_analyst.py new file mode 100644 index 0000000..b57f437 --- /dev/null +++ b/src/services/research_council/agents/risk_analyst.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class RiskAnalyst: + role_name = "risk_analyst" + + def analyze(self, context: CouncilContext) -> CouncilCase: + risk_result = dict(context.risk_result or {}) + trust_policy = dict(context.trust_policy or {}) + weekly_review = dict(context.weekly_review_summary or {}) + + risk_allowed = risk_result.get("allowed") + risk_status = str(risk_result.get("status") or "unknown") + risk_reasons = list(risk_result.get("reasons") or []) + + trust_classification = str(trust_policy.get("classification") or "unknown") + operator_mode = str(trust_policy.get("operator_mode") or "unknown") + execution_allowed = bool(trust_policy.get("execution_allowed", False)) + decision_support_allowed = bool(trust_policy.get("decision_support_allowed", False)) + + review_entry_count = int(weekly_review.get("entry_count") or 0) + review_next_actions = list(weekly_review.get("next_week_actions") or []) + + summary = ( + f"Risk gate status is {risk_status} with allowed={risk_allowed}. " + f"Trust classification is {trust_classification} and operator_mode={operator_mode}. " + f"Decision support allowed={decision_support_allowed}, execution allowed={execution_allowed}. " + f"Weekly review currently has {review_entry_count} entries." + ) + + evidence = [ + f"risk_status:{risk_status}", + f"risk_allowed:{risk_allowed}", + f"trust_classification:{trust_classification}", + f"operator_mode:{operator_mode}", + f"decision_support_allowed:{decision_support_allowed}", + f"execution_allowed:{execution_allowed}", + f"weekly_review_entry_count:{review_entry_count}", + ] + evidence.extend(f"risk_reason:{item}" for item in risk_reasons) + evidence.extend(f"review_next_action:{item}" for item in review_next_actions) + + return CouncilCase( + title="Risk Analysis", + summary=summary, + evidence=evidence, + ) diff --git a/src/services/research_council/agents/structure_analyst.py b/src/services/research_council/agents/structure_analyst.py new file mode 100644 index 0000000..de4fbe1 --- /dev/null +++ b/src/services/research_council/agents/structure_analyst.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class StructureAnalyst: + role_name = "structure_analyst" + + def analyze(self, context: CouncilContext) -> CouncilCase: + market_state = dict(context.market_state or {}) + freshness_code = str((market_state.get("freshness") or {}).get("code") or "unknown") + stream_code = str((market_state.get("stream_state") or {}).get("code") or "unknown") + gap_code = str((market_state.get("data_gap_state") or {}).get("code") or "unknown") + plan = dict(context.latest_trading_plan or {}) + candidate = dict(context.latest_candidate or {}) + signal = dict(context.latest_signal or {}) + + bias = str(plan.get("bias") or "unknown") + plan_status = str(plan.get("status") or "missing") + candidate_status = str(candidate.get("status") or "missing") + setup_type = str(candidate.get("model_code") or signal.get("model_code") or "unknown") + + summary = ( + f"Plan status is {plan_status} with bias {bias}. " + f"Candidate status is {candidate_status} under setup {setup_type}. " + f"Market truth reads freshness={freshness_code}, stream={stream_code}, gap={gap_code}." + ) + + evidence = [ + f"plan_status:{plan_status}", + f"bias:{bias}", + f"candidate_status:{candidate_status}", + f"setup_type:{setup_type}", + f"freshness:{freshness_code}", + f"stream:{stream_code}", + f"gap:{gap_code}", + ] + if candidate.get("id") is not None: + evidence.append(f"candidate_id:{candidate.get('id')}") + if signal.get("id") is not None: + evidence.append(f"signal_id:{signal.get('id')}") + + return CouncilCase( + title="Structure Analysis", + summary=summary, + evidence=evidence, + ) diff --git a/src/services/research_council/agents/visual_analyst.py b/src/services/research_council/agents/visual_analyst.py new file mode 100644 index 0000000..7bf1094 --- /dev/null +++ b/src/services/research_council/agents/visual_analyst.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class VisualAnalyst: + role_name = "visual_analyst" + + def analyze(self, context: CouncilContext) -> CouncilCase: + reviews = list(context.visual_reviews or []) + if not reviews: + return CouncilCase( + title="Visual Analysis", + summary="No visual review has been attached to the current decision context.", + evidence=["visual_review_missing"], + ) + + latest = dict(reviews[0] or {}) + visual_state = str(latest.get("visual_state") or "unknown") + confidence = latest.get("visual_confidence") + anomalies = list(latest.get("ui_anomalies") or []) + notes = str(latest.get("visual_notes") or "").strip() + summary = ( + f"Latest visual review reports state={visual_state}. " + "Treat it as confirmation support, not as an execution permission source." + ) + evidence = [ + f"review_count={len(reviews)}", + f"latest_visual_state={visual_state}", + f"latest_visual_confidence={confidence if confidence is not None else 'n/a'}", + ] + if anomalies: + evidence.append(f"ui_anomalies={', '.join(str(item) for item in anomalies)}") + if notes: + evidence.append(f"visual_notes={notes}") + return CouncilCase(title="Visual Analysis", summary=summary, evidence=evidence) diff --git a/src/services/research_council/council_checkpoint_service.py b/src/services/research_council/council_checkpoint_service.py new file mode 100644 index 0000000..2bba369 --- /dev/null +++ b/src/services/research_council/council_checkpoint_service.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.research_council_checkpoint_repository import ResearchCouncilCheckpointRepository + + +DEFAULT_COUNCIL_CHECKPOINT_KEY = "research_council_run" + + +class CouncilCheckpointService: + def __init__(self, *, repository: Optional[ResearchCouncilCheckpointRepository] = None) -> None: + self.repository = repository or ResearchCouncilCheckpointRepository() + + def save_checkpoint( + self, + *, + instrument_id: int, + stage: str, + payload: dict, + run_id: Optional[int] = None, + checkpoint_key: str = DEFAULT_COUNCIL_CHECKPOINT_KEY, + status: str = "in_progress", + now: Optional[datetime] = None, + ) -> dict: + observed_ts = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + checkpoint_id = self.repository.insert_checkpoint( + run_id=run_id, + instrument_id=instrument_id, + checkpoint_key=checkpoint_key, + status=status, + stage=stage, + payload=payload, + created_ts=observed_ts, + updated_ts=observed_ts, + ) + return { + "id": checkpoint_id, + "run_id": run_id, + "instrument_id": instrument_id, + "checkpoint_key": checkpoint_key, + "status": status, + "stage": stage, + "payload": dict(payload or {}), + "updated_ts": observed_ts.isoformat(), + } + + def load_latest( + self, + *, + instrument_id: int, + checkpoint_key: str = DEFAULT_COUNCIL_CHECKPOINT_KEY, + ) -> Optional[dict]: + return self.repository.fetch_latest_checkpoint( + instrument_id=instrument_id, + checkpoint_key=checkpoint_key, + ) + + def build_resume_state( + self, + *, + instrument_id: int, + checkpoint_key: str = DEFAULT_COUNCIL_CHECKPOINT_KEY, + ) -> dict: + checkpoint = self.load_latest(instrument_id=instrument_id, checkpoint_key=checkpoint_key) + can_resume = bool(checkpoint and checkpoint.get("status") not in {"completed", "cancelled"}) + return { + "instrument_id": instrument_id, + "checkpoint_key": checkpoint_key, + "can_resume": can_resume, + "latest_checkpoint": checkpoint, + } diff --git a/src/services/research_council/council_context_builder.py b/src/services/research_council/council_context_builder.py new file mode 100644 index 0000000..ee0f997 --- /dev/null +++ b/src/services/research_council/council_context_builder.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +from src.repositories.decision_packet_repository import DecisionPacketRepository +from src.repositories.trading_journal_repository import TradingJournalRepository +from src.services.decision import DecisionPacketService +from src.services.decision.decision_packet_service import DEFAULT_MODEL_CODE +from src.services.journal import WeeklyReviewService +from src.services.market_state import MarketStateService, get_market_truth_supervisor +from src.services.research_council.council_memory_service import CouncilMemoryService +from src.services.visual_watch import VisualWatchService + + +@dataclass(frozen=True) +class CouncilContext: + instrument_id: int + generated_ts: str + timeframe: str + model_code: str + market_state: dict[str, Any] + latest_trading_plan: dict[str, Any] + latest_candidate: dict[str, Any] + latest_signal: dict[str, Any] + risk_result: dict[str, Any] + trust_policy: dict[str, Any] + recent_alerts: list[dict[str, Any]] + recent_journal_entries: list[dict[str, Any]] + weekly_review_summary: dict[str, Any] + visual_reviews: list[dict[str, Any]] + compact_lessons: list[dict[str, Any]] + source_snapshot_meta: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + return dict(asdict(self)) + + +class CouncilContextBuilder: + """Build a compact, read-only Research Council context from AI_ICT facts.""" + + def __init__( + self, + *, + market_state_service: Optional[MarketStateService] = None, + decision_service: Optional[DecisionPacketService] = None, + decision_repository: Optional[DecisionPacketRepository] = None, + weekly_review_service: Optional[WeeklyReviewService] = None, + journal_repository: Optional[TradingJournalRepository] = None, + visual_watch_service: Optional[VisualWatchService] = None, + memory_service: Optional[CouncilMemoryService] = None, + ) -> None: + self.market_state_service = market_state_service or MarketStateService( + market_truth_supervisor=get_market_truth_supervisor() + ) + self.decision_service = decision_service or DecisionPacketService() + self.decision_repository = decision_repository or DecisionPacketRepository() + self.weekly_review_service = weekly_review_service or WeeklyReviewService() + self.journal_repository = journal_repository or TradingJournalRepository() + self.visual_watch_service = visual_watch_service or VisualWatchService() + self.memory_service = memory_service or CouncilMemoryService( + journal_repository=self.journal_repository, + weekly_review_service=self.weekly_review_service, + ) + + def build_context( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + alert_limit: int = 10, + journal_limit: int = 10, + now: Optional[datetime] = None, + ) -> CouncilContext: + observed_ts = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + market_state = self.market_state_service.build_market_state( + instrument_id=instrument_id, + timeframe=timeframe, + now=observed_ts, + ).to_dict() + decision_preview = self.decision_service.build_preview( + instrument_id=instrument_id, + model_code=model_code, + ) + raw = dict(decision_preview.get("raw") or {}) + latest_trading_plan = dict(raw.get("plan") or {}) + latest_candidate = dict(raw.get("candidate") or {}) + latest_signal = dict(raw.get("signal") or {}) + trust_policy = dict(raw.get("trust_policy") or {}) + risk_result = dict(decision_preview.get("risk_result") or {}) + recent_alerts = self.decision_repository.fetch_recent_decision_alerts( + instrument_id=instrument_id, + limit=alert_limit, + ) + recent_journal_entries = self.journal_repository.fetch_recent_journal_entries( + instrument_id=instrument_id, + limit=journal_limit, + ) + weekly_review_summary = self.weekly_review_service.build_summary( + instrument_id=instrument_id, + ) + decision_packet_id = _int_or_none(decision_preview.get("id") or latest_signal.get("decision_packet_id")) + visual_reviews = [] + if decision_packet_id is not None: + visual_reviews = self.visual_watch_service.fetch_reviews_for_packet( + decision_packet_id=decision_packet_id, + limit=10, + ) + compact_lessons = self.memory_service.fetch_compact_lessons( + instrument_id=instrument_id, + symbol=str((latest_candidate.get("evidence") or {}).get("symbol") or "") or None, + session_code=str(latest_candidate.get("session_code") or latest_signal.get("session_code") or "") or None, + setup_type=str(latest_candidate.get("model_code") or latest_signal.get("model_code") or "") or None, + model_code=model_code, + tags=list(latest_candidate.get("missing_confirmations") or []), + limit=5, + ) + source_snapshot_meta = { + "generated_ts": observed_ts.isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "model_code": model_code, + "decision_status": decision_preview.get("status"), + "action_recommendation": decision_preview.get("action_recommendation"), + "trading_plan_id": latest_trading_plan.get("id"), + "candidate_id": latest_candidate.get("id"), + "signal_id": latest_signal.get("id"), + "decision_packet_id": decision_packet_id, + "alert_count": len(recent_alerts), + "journal_entry_count": len(recent_journal_entries), + "visual_review_count": len(visual_reviews), + "lesson_count": len(compact_lessons), + } + return CouncilContext( + instrument_id=instrument_id, + generated_ts=observed_ts.isoformat(), + timeframe=timeframe, + model_code=model_code, + market_state=market_state, + latest_trading_plan=latest_trading_plan, + latest_candidate=latest_candidate, + latest_signal=latest_signal, + risk_result=risk_result, + trust_policy=trust_policy, + recent_alerts=list(recent_alerts or []), + recent_journal_entries=list(recent_journal_entries or []), + weekly_review_summary=dict(weekly_review_summary or {}), + visual_reviews=list(visual_reviews or []), + compact_lessons=list(compact_lessons or []), + source_snapshot_meta=source_snapshot_meta, + ) + + +def _int_or_none(value) -> Optional[int]: + if value in (None, ""): + return None + return int(value) diff --git a/src/services/research_council/council_governance_tick_service.py b/src/services/research_council/council_governance_tick_service.py new file mode 100644 index 0000000..81bbf6a --- /dev/null +++ b/src/services/research_council/council_governance_tick_service.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.services.decision.decision_packet_service import DEFAULT_MODEL_CODE +from src.services.research_council.council_optimization_review_service import ( + DEFAULT_OPTIMIZATION_REVIEW_LIMIT, + DEFAULT_STALE_AFTER_HOURS, + CouncilOptimizationReviewService, +) +from src.services.research_council.council_optimization_service import CouncilOptimizationService + + +PERIODIC_ACTIONS = {"continue_periodic_optimization_cycle", "rerun_optimization_cycle_after_next_market_sample"} +CYCLE_REQUIRED_ACTIONS = {"run_research_council_optimization_loop"} + + +class CouncilGovernanceTickService: + """Run one safe Research Council governance tick.""" + + def __init__( + self, + *, + review_service: Optional[CouncilOptimizationReviewService] = None, + optimization_service: Optional[CouncilOptimizationService] = None, + ) -> None: + self.review_service = review_service or CouncilOptimizationReviewService() + self.optimization_service = optimization_service or CouncilOptimizationService() + + def run_tick( + self, + *, + instrument_id: int, + limit: int = DEFAULT_OPTIMIZATION_REVIEW_LIMIT, + stale_after_hours: float = DEFAULT_STALE_AFTER_HOURS, + auto_run_stale_cycle: bool = False, + force_cycle: bool = False, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + replay_file: Optional[str] = None, + eval_limit: int = 20, + lesson_limit: int = 100, + cycle_key: Optional[str] = None, + ) -> dict: + generated_ts = datetime.now(timezone.utc) + initial_review = self.review_service.build_review( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + ) + recommended_actions = _actions(initial_review) + should_run_cycle = force_cycle or (auto_run_stale_cycle and bool(CYCLE_REQUIRED_ACTIONS.intersection(recommended_actions))) + cycle_payload = None + post_cycle_review = None + if should_run_cycle: + cycle_payload = self.optimization_service.run_cycle( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + eval_limit=eval_limit, + lesson_limit=lesson_limit, + cycle_key=cycle_key, + ) + post_cycle_review = self.review_service.build_review( + instrument_id=instrument_id, + limit=limit, + stale_after_hours=stale_after_hours, + ) + + status = _tick_status( + initial_review=initial_review, + recommended_actions=recommended_actions, + cycle_payload=cycle_payload, + ) + effective_review = post_cycle_review or initial_review + return { + "status": status, + "passed": True, + "instrument_id": instrument_id, + "generated_ts": generated_ts.isoformat(), + "initial_health_state": initial_review.get("health_state"), + "effective_health_state": effective_review.get("health_state"), + "initial_review": initial_review, + "post_cycle_review": post_cycle_review, + "cycle": cycle_payload, + "recommended_actions": _actions(effective_review), + "requires_cycle_run": status == "cycle_required", + "requires_bounded_slice": status == "bounded_slice_required", + "safe_to_auto_continue": status in {"no_op_healthy", "cycle_run"}, + "boundary_notes": _boundary_notes(), + } + + +def _tick_status(*, initial_review: dict, recommended_actions: list[str], cycle_payload: Optional[dict]) -> str: + if cycle_payload is not None: + return "cycle_run" + non_periodic = [ + action + for action in recommended_actions + if action not in PERIODIC_ACTIONS and action not in CYCLE_REQUIRED_ACTIONS + ] + if non_periodic or initial_review.get("can_open_bounded_slice"): + return "bounded_slice_required" + if CYCLE_REQUIRED_ACTIONS.intersection(recommended_actions): + return "cycle_required" + return "no_op_healthy" + + +def _actions(review: Optional[dict]) -> list[str]: + return [str(item) for item in list((review or {}).get("recommended_actions") or []) if str(item)] + + +def _boundary_notes() -> list[str]: + return [ + "governance_tick_does_not_change_execution_gate", + "governance_tick_does_not_change_live_readiness", + "governance_tick_does_not_change_trust_bucket", + ] diff --git a/src/services/research_council/council_memory_service.py b/src/services/research_council/council_memory_service.py new file mode 100644 index 0000000..869059b --- /dev/null +++ b/src/services/research_council/council_memory_service.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import date, datetime, timezone +from typing import Optional + +from src.repositories.backtest_repository import BacktestRepository +from src.repositories.research_council_lesson_repository import ResearchCouncilLessonRepository +from src.repositories.trading_journal_repository import TradingJournalRepository +from src.services.journal import WeeklyReviewService +from src.services.trust import ModelTrustService + + +@dataclass(frozen=True) +class CouncilLesson: + lesson_key: str + instrument_id: int + source_type: str + source_ref: str + lesson_type: str + title: str + lesson_summary: str + symbol: Optional[str] = None + session_code: Optional[str] = None + setup_type: Optional[str] = None + model_code: Optional[str] = None + tags: list[str] | None = None + priority_score: float = 0.0 + payload: dict | None = None + + def to_dict(self) -> dict: + payload = dict(asdict(self)) + payload["tags"] = list(self.tags or []) + payload["payload"] = dict(self.payload or {}) + return payload + + +class CouncilMemoryService: + def __init__( + self, + *, + lesson_repository: Optional[ResearchCouncilLessonRepository] = None, + journal_repository: Optional[TradingJournalRepository] = None, + weekly_review_service: Optional[WeeklyReviewService] = None, + trust_service: Optional[ModelTrustService] = None, + backtest_repository: Optional[BacktestRepository] = None, + ) -> None: + self.lesson_repository = lesson_repository or ResearchCouncilLessonRepository() + self.journal_repository = journal_repository or TradingJournalRepository() + self.weekly_review_service = weekly_review_service or WeeklyReviewService() + self.trust_service = trust_service or ModelTrustService() + self.backtest_repository = backtest_repository or BacktestRepository() + + def upsert_lesson(self, lesson: CouncilLesson, *, now: Optional[datetime] = None) -> dict: + observed_ts = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + existing = self.lesson_repository.fetch_lesson_by_key(lesson_key=lesson.lesson_key) + lesson_id = self.lesson_repository.upsert_lesson( + lesson_key=lesson.lesson_key, + instrument_id=lesson.instrument_id, + source_type=lesson.source_type, + source_ref=lesson.source_ref, + lesson_type=lesson.lesson_type, + symbol=lesson.symbol, + session_code=lesson.session_code, + setup_type=lesson.setup_type, + model_code=lesson.model_code, + title=lesson.title, + lesson_summary=lesson.lesson_summary, + tags=list(lesson.tags or []), + priority_score=float(lesson.priority_score), + payload=dict(lesson.payload or {}), + created_ts=existing.get("created_ts") if existing else observed_ts, + updated_ts=observed_ts, + ) + return { + "id": lesson_id, + "lesson_key": lesson.lesson_key, + "action": "updated" if existing else "created", + } + + def backfill_all_sources( + self, + *, + instrument_id: int, + limit: int = 100, + week_start: Optional[date] = None, + week_end: Optional[date] = None, + ) -> dict: + journal = self.backfill_from_journal(instrument_id=instrument_id, limit=limit) + weekly = self.backfill_from_weekly_review(instrument_id=instrument_id, week_start=week_start, week_end=week_end) + trust = self.backfill_from_trust(instrument_id=instrument_id, week_start=week_start, week_end=week_end) + replay = self.backfill_from_backtest(instrument_id=instrument_id, limit=min(limit, 20)) + totals = { + "requested": journal["requested"] + weekly["requested"] + trust["requested"] + replay["requested"], + "created": journal["created"] + weekly["created"] + trust["created"] + replay["created"], + "updated": journal["updated"] + weekly["updated"] + trust["updated"] + replay["updated"], + } + return { + "instrument_id": instrument_id, + "totals": totals, + "journal": journal, + "weekly_review": weekly, + "trust": trust, + "replay": replay, + } + + def backfill_from_journal(self, *, instrument_id: int, limit: int = 100) -> dict: + entries = self.journal_repository.fetch_recent_journal_entries(instrument_id=instrument_id, limit=limit) + created = 0 + updated = 0 + for entry in entries: + outcome = self.upsert_lesson(self._lesson_from_journal(entry)) + if outcome["action"] == "created": + created += 1 + else: + updated += 1 + return { + "requested": len(entries), + "created": created, + "updated": updated, + } + + def backfill_from_weekly_review( + self, + *, + instrument_id: int, + week_start: Optional[date] = None, + week_end: Optional[date] = None, + ) -> dict: + summary = self.weekly_review_service.build_summary( + instrument_id=instrument_id, + week_start=week_start, + week_end=week_end, + ) + lessons = self._lessons_from_weekly_review(instrument_id=instrument_id, summary=summary) + created = 0 + updated = 0 + for lesson in lessons: + outcome = self.upsert_lesson(lesson) + if outcome["action"] == "created": + created += 1 + else: + updated += 1 + return { + "requested": len(lessons), + "created": created, + "updated": updated, + } + + def backfill_from_trust( + self, + *, + instrument_id: int, + week_start: Optional[date] = None, + week_end: Optional[date] = None, + ) -> dict: + summary = self.trust_service.build_summary( + instrument_id=instrument_id, + week_start=week_start, + week_end=week_end, + ) + created = 0 + updated = 0 + models = list(summary.get("models") or []) + for model in models: + outcome = self.upsert_lesson(self._lesson_from_trust_bucket(instrument_id=instrument_id, model=model)) + if outcome["action"] == "created": + created += 1 + else: + updated += 1 + return { + "requested": len(models), + "created": created, + "updated": updated, + } + + def backfill_from_backtest(self, *, instrument_id: int, limit: int = 20) -> dict: + rows = self.backtest_repository.fetch_recent_backtest_run_rows(instrument_id=instrument_id, limit=limit) + created = 0 + updated = 0 + for row in rows: + outcome = self.upsert_lesson(self._lesson_from_backtest_row(instrument_id=instrument_id, row=row)) + if outcome["action"] == "created": + created += 1 + else: + updated += 1 + return { + "requested": len(rows), + "created": created, + "updated": updated, + } + + def fetch_compact_lessons( + self, + *, + instrument_id: int, + symbol: Optional[str] = None, + session_code: Optional[str] = None, + setup_type: Optional[str] = None, + model_code: Optional[str] = None, + tags: Optional[list[str]] = None, + limit: int = 5, + ) -> list[dict]: + rows = self.lesson_repository.fetch_recent_lessons(instrument_id=instrument_id, limit=200) + target_tags = {str(item).strip().lower() for item in list(tags or []) if str(item).strip()} + ranked = [] + for row in rows: + score = self._similarity_score( + row=row, + symbol=symbol, + session_code=session_code, + setup_type=setup_type, + model_code=model_code, + tags=target_tags, + ) + ranked.append((score, row)) + ranked.sort(key=lambda item: (item[0], float(item[1].get("priority_score") or 0.0), str(item[1].get("updated_ts") or "")), reverse=True) + compact = [] + for score, row in ranked[:limit]: + compact.append( + { + "lesson_key": row.get("lesson_key"), + "lesson_type": row.get("lesson_type"), + "title": row.get("title"), + "lesson_summary": row.get("lesson_summary"), + "session_code": row.get("session_code"), + "setup_type": row.get("setup_type"), + "model_code": row.get("model_code"), + "tags": list(row.get("tags") or []), + "priority_score": float(row.get("priority_score") or 0.0), + "similarity_score": round(score, 4), + } + ) + return compact + + def _lesson_from_journal(self, entry: dict) -> CouncilLesson: + journal_id = int(entry.get("id") or 0) + session_code = str(entry.get("session_code") or "OFF_HOURS") + model_code = str(entry.get("model_code") or "") + review_tags = [str(item) for item in list(entry.get("review_tags") or []) if str(item)] + next_actions = [str(item) for item in list(entry.get("next_actions") or []) if str(item)] + lesson_summary = ( + f"Journal entry shows framework_execution={bool(entry.get('framework_execution'))}, " + f"model_compliance={bool(entry.get('model_compliance'))}, risk_violation={bool(entry.get('risk_violation'))}, " + f"execution_violation={bool(entry.get('execution_violation'))}, plan_violation={bool(entry.get('plan_violation'))}." + ) + return CouncilLesson( + lesson_key=f"journal:{journal_id}", + instrument_id=int(entry.get("instrument_id") or 0), + source_type="journal", + source_ref=str(journal_id), + lesson_type="journal_pattern", + title=f"Journal lesson {session_code} {model_code or 'unknown_model'}", + lesson_summary=lesson_summary, + session_code=session_code, + setup_type=model_code or None, + model_code=model_code or None, + tags=_dedupe(review_tags + next_actions), + priority_score=_priority_from_journal(entry), + payload={ + "journal_id": journal_id, + "framework_execution": bool(entry.get("framework_execution")), + "model_compliance": bool(entry.get("model_compliance")), + "risk_violation": bool(entry.get("risk_violation")), + "execution_violation": bool(entry.get("execution_violation")), + "plan_violation": bool(entry.get("plan_violation")), + "next_actions": next_actions, + "review_tags": review_tags, + }, + ) + + def _lessons_from_weekly_review(self, *, instrument_id: int, summary: dict) -> list[CouncilLesson]: + lessons: list[CouncilLesson] = [] + week_start = str(summary.get("week_start") or "") + week_end = str(summary.get("week_end") or "") + low_session = dict(summary.get("low_quality_session") or {}) + high_session = dict(summary.get("high_quality_session") or {}) + if low_session: + lessons.append( + CouncilLesson( + lesson_key=f"weekly:{instrument_id}:{week_start}:{week_end}:low:{low_session.get('session_code')}", + instrument_id=instrument_id, + source_type="weekly_review", + source_ref=f"{week_start}:{week_end}", + lesson_type="low_quality_session", + title=f"Low-quality session {low_session.get('session_code')}", + lesson_summary=( + f"Weekly review marked session {low_session.get('session_code')} as low quality with " + f"framework_execution_rate={low_session.get('framework_execution_rate')} and " + f"avg_r_multiple={low_session.get('avg_r_multiple')}." + ), + session_code=str(low_session.get("session_code") or "") or None, + tags=["weekly_review", "low_quality_session"], + priority_score=0.85, + payload=low_session, + ) + ) + if high_session: + lessons.append( + CouncilLesson( + lesson_key=f"weekly:{instrument_id}:{week_start}:{week_end}:high:{high_session.get('session_code')}", + instrument_id=instrument_id, + source_type="weekly_review", + source_ref=f"{week_start}:{week_end}", + lesson_type="high_quality_session", + title=f"High-quality session {high_session.get('session_code')}", + lesson_summary=( + f"Weekly review marked session {high_session.get('session_code')} as high quality with " + f"framework_execution_rate={high_session.get('framework_execution_rate')} and " + f"avg_r_multiple={high_session.get('avg_r_multiple')}." + ), + session_code=str(high_session.get("session_code") or "") or None, + tags=["weekly_review", "high_quality_session"], + priority_score=0.65, + payload=high_session, + ) + ) + for action in list(summary.get("next_week_actions") or []): + action_text = str(action).strip() + if not action_text: + continue + lessons.append( + CouncilLesson( + lesson_key=f"weekly:{instrument_id}:{week_start}:{week_end}:action:{action_text}", + instrument_id=instrument_id, + source_type="weekly_review", + source_ref=f"{week_start}:{week_end}", + lesson_type="next_action", + title=f"Weekly action {action_text}", + lesson_summary=f"Weekly review recommends follow-up action: {action_text}.", + tags=["weekly_review", "next_action", action_text], + priority_score=0.75, + payload={"action": action_text, "summary": summary}, + ) + ) + return lessons + + def _lesson_from_trust_bucket(self, *, instrument_id: int, model: dict) -> CouncilLesson: + model_code = str(model.get("model_code") or "") + classification = str(model.get("classification") or "unknown") + reasons = [str(item) for item in list(model.get("reasons") or []) if str(item)] + return CouncilLesson( + lesson_key=f"trust:{instrument_id}:{model_code}", + instrument_id=instrument_id, + source_type="trust", + source_ref=model_code, + lesson_type="trust_bucket", + title=f"Trust bucket {model_code}", + lesson_summary=( + f"Trust model {model_code} is classified as {classification} with score={model.get('score')} " + f"and execution_allowed={bool(model.get('execution_allowed'))}." + ), + setup_type=model_code or None, + model_code=model_code or None, + tags=_dedupe(["trust", classification] + reasons), + priority_score=float(model.get("score") or 0.0) / 100.0, + payload=model, + ) + + def _lesson_from_backtest_row(self, *, instrument_id: int, row: dict) -> CouncilLesson: + summary = dict(row.get("summary") or {}) + quality_gate = dict(summary.get("quality_gate") or {}) + model_code = str(row.get("candidate_model_code") or row.get("model_code") or "") + subject_type = str(row.get("subject_type") or "unknown") + return CouncilLesson( + lesson_key=f"backtest:{row.get('id')}", + instrument_id=instrument_id, + source_type="backtest", + source_ref=str(row.get("id")), + lesson_type=subject_type, + title=f"Backtest lesson {row.get('id')}", + lesson_summary=( + f"Backtest subject={subject_type}, model={model_code or 'unknown'}, " + f"quality_gate_passed={bool(quality_gate.get('passed'))}." + ), + setup_type=model_code or None, + model_code=model_code or None, + tags=_dedupe(["backtest", subject_type, "quality_gate_passed" if quality_gate.get("passed") else "quality_gate_failed"]), + priority_score=0.8 if quality_gate.get("passed") else 0.55, + payload={ + "backtest_id": row.get("id"), + "subject_type": subject_type, + "model_code": model_code, + "summary": summary, + }, + ) + + def _similarity_score( + self, + *, + row: dict, + symbol: Optional[str], + session_code: Optional[str], + setup_type: Optional[str], + model_code: Optional[str], + tags: set[str], + ) -> float: + score = float(row.get("priority_score") or 0.0) + if symbol and str(row.get("symbol") or "") == str(symbol): + score += 0.35 + if session_code and str(row.get("session_code") or "") == str(session_code): + score += 0.5 + if setup_type and str(row.get("setup_type") or "") == str(setup_type): + score += 0.45 + if model_code and str(row.get("model_code") or "") == str(model_code): + score += 0.55 + row_tags = {str(item).strip().lower() for item in list(row.get("tags") or []) if str(item).strip()} + score += min(len(tags & row_tags) * 0.1, 0.3) + lesson_type = str(row.get("lesson_type") or "") + if lesson_type in {"low_quality_session", "next_action"}: + score += 0.15 + return score + + +def _priority_from_journal(entry: dict) -> float: + if bool(entry.get("risk_violation")) or bool(entry.get("execution_violation")): + return 0.9 + if bool(entry.get("plan_violation")) or not bool(entry.get("model_compliance")): + return 0.8 + if bool(entry.get("framework_execution")): + return 0.65 + return 0.55 + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + return result diff --git a/src/services/research_council/council_optimization_review_service.py b/src/services/research_council/council_optimization_review_service.py new file mode 100644 index 0000000..97dc9ef --- /dev/null +++ b/src/services/research_council/council_optimization_review_service.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.services.research_council.council_optimization_service import CouncilOptimizationService + + +DEFAULT_OPTIMIZATION_REVIEW_LIMIT = 5 +DEFAULT_STALE_AFTER_HOURS = 24.0 + + +class CouncilOptimizationReviewService: + """Summarize recent P3 optimization cycles for operator governance.""" + + def __init__(self, *, optimization_service: Optional[CouncilOptimizationService] = None) -> None: + self.optimization_service = optimization_service or CouncilOptimizationService() + + def build_review( + self, + *, + instrument_id: int, + limit: int = DEFAULT_OPTIMIZATION_REVIEW_LIMIT, + stale_after_hours: float = DEFAULT_STALE_AFTER_HOURS, + now: Optional[datetime] = None, + ) -> dict: + observed_ts = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + cycles = self.optimization_service.get_recent_cycles(instrument_id=instrument_id, limit=limit) + if not cycles: + return { + "present": False, + "instrument_id": instrument_id, + "generated_ts": observed_ts.isoformat(), + "cycle_count": 0, + "health_state": "missing", + "stale": True, + "operator_summary": "No Research Council optimization cycle has been recorded yet.", + "recommended_actions": ["run_research_council_optimization_loop"], + "can_open_bounded_slice": False, + "boundary_notes": _boundary_notes(), + } + + latest = dict(cycles[0] or {}) + previous = dict(cycles[1] or {}) if len(cycles) > 1 else None + latest_metrics = _cycle_metrics(latest) + previous_metrics = _cycle_metrics(previous) if previous else {} + finished_ts = _parse_ts(latest.get("finished_ts") or (latest.get("payload") or {}).get("finished_ts")) + age_hours = _age_hours(finished_ts=finished_ts, now=observed_ts) + stale = age_hours is None or age_hours > stale_after_hours + non_rerun_actions = [ + action + for action in _next_actions(latest) + if action != "rerun_optimization_cycle_after_next_market_sample" + ] + health_state = _health_state( + stale=stale, + latest_metrics=latest_metrics, + non_rerun_actions=non_rerun_actions, + ) + recommended_actions = _recommended_actions( + stale=stale, + non_rerun_actions=non_rerun_actions, + ) + return { + "present": True, + "instrument_id": instrument_id, + "generated_ts": observed_ts.isoformat(), + "cycle_count": len(cycles), + "latest_cycle_id": latest.get("id"), + "latest_cycle_key": latest.get("cycle_key"), + "latest_status": latest.get("status"), + "latest_finished_ts": finished_ts.isoformat() if finished_ts else None, + "age_hours": round(age_hours, 4) if age_hours is not None else None, + "stale_after_hours": stale_after_hours, + "stale": stale, + "health_state": health_state, + "latest_metrics": latest_metrics, + "metric_deltas": _metric_deltas(latest_metrics=latest_metrics, previous_metrics=previous_metrics), + "arena_best_model_code": latest_metrics.get("arena_best_model_code"), + "recommended_actions": recommended_actions, + "can_open_bounded_slice": bool(non_rerun_actions) and not stale, + "operator_summary": _operator_summary( + health_state=health_state, + stale=stale, + cycle_count=len(cycles), + recommended_actions=recommended_actions, + ), + "boundary_notes": _boundary_notes(), + } + + +def _cycle_metrics(cycle: Optional[dict]) -> dict: + if not cycle: + return {} + eval_summary = dict(cycle.get("eval_summary") or ((cycle.get("payload") or {}).get("eval_summary") or {})) + arena_summary = dict(cycle.get("arena_summary") or ((cycle.get("payload") or {}).get("arena_summary") or {})) + return { + "run_count": int(eval_summary.get("run_count") or 0), + "guidance_delta": float(eval_summary.get("guidance_delta") or 0.0), + "lesson_usage_rate": float(eval_summary.get("lesson_usage_rate") or 0.0), + "disagreement_hint_rate": float(eval_summary.get("disagreement_hint_rate") or 0.0), + "avg_follow_up_check_count": float(eval_summary.get("avg_follow_up_check_count") or 0.0), + "avg_operator_question_count": float(eval_summary.get("avg_operator_question_count") or 0.0), + "arena_row_count": len(list(arena_summary.get("rows") or [])), + "arena_best_model_code": arena_summary.get("best_model_code"), + } + + +def _metric_deltas(*, latest_metrics: dict, previous_metrics: dict) -> dict: + if not previous_metrics: + return {} + keys = [ + "guidance_delta", + "lesson_usage_rate", + "disagreement_hint_rate", + "avg_follow_up_check_count", + "avg_operator_question_count", + ] + return { + key: round(float(latest_metrics.get(key) or 0.0) - float(previous_metrics.get(key) or 0.0), 4) + for key in keys + } + + +def _next_actions(cycle: dict) -> list[str]: + values = cycle.get("next_actions") + if values is None: + values = (cycle.get("payload") or {}).get("next_actions") + return [str(item) for item in list(values or []) if str(item)] + + +def _recommended_actions(*, stale: bool, non_rerun_actions: list[str]) -> list[str]: + actions = [] + if stale: + actions.append("run_research_council_optimization_loop") + actions.extend(non_rerun_actions) + if not actions: + actions.append("continue_periodic_optimization_cycle") + return _dedupe(actions) + + +def _health_state(*, stale: bool, latest_metrics: dict, non_rerun_actions: list[str]) -> str: + if stale: + return "stale" + if int(latest_metrics.get("run_count") or 0) < 3: + return "needs_more_samples" + if non_rerun_actions: + return "needs_attention" + return "healthy" + + +def _operator_summary(*, health_state: str, stale: bool, cycle_count: int, recommended_actions: list[str]) -> str: + if stale: + return "Optimization review is stale; run a fresh Research Council optimization cycle before judging trends." + if health_state == "needs_more_samples": + return "Optimization review has too few samples; collect more council runs before changing prompts or policies." + if health_state == "needs_attention": + return f"Optimization review found {len(recommended_actions)} bounded follow-up action(s)." + return f"Optimization review is healthy across {cycle_count} recent cycle(s)." + + +def _parse_ts(value) -> Optional[datetime]: + if not value: + return None + if isinstance(value, datetime): + return value.astimezone(timezone.utc) + text = str(value).replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _age_hours(*, finished_ts: Optional[datetime], now: datetime) -> Optional[float]: + if finished_ts is None: + return None + return max((now - finished_ts).total_seconds() / 3600.0, 0.0) + + +def _boundary_notes() -> list[str]: + return [ + "optimization_review_does_not_change_execution_gate", + "optimization_review_does_not_change_live_readiness", + "optimization_review_does_not_change_trust_bucket", + ] + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + return result diff --git a/src/services/research_council/council_optimization_service.py b/src/services/research_council/council_optimization_service.py new file mode 100644 index 0000000..a16deeb --- /dev/null +++ b/src/services/research_council/council_optimization_service.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from src.repositories.research_council_optimization_repository import ResearchCouncilOptimizationRepository +from src.services.backtest import detect_confirmed_swings_for_replay, iter_historical_replay, load_replay_candles +from src.services.decision.decision_packet_service import DEFAULT_MODEL_CODE +from src.services.research_council.research_council_service import ResearchCouncilService +from src.services.trust import ModelTrustService + + +MIN_REPLAY_CONFIRMED_SWINGS = 2 + + +class CouncilOptimizationService: + """Run and persist the long-running Research Council optimization loop.""" + + def __init__( + self, + *, + research_council_service: Optional[ResearchCouncilService] = None, + optimization_repository: Optional[ResearchCouncilOptimizationRepository] = None, + trust_service: Optional[ModelTrustService] = None, + ) -> None: + self.research_council_service = research_council_service or ResearchCouncilService() + self.optimization_repository = optimization_repository or ResearchCouncilOptimizationRepository() + self.trust_service = trust_service or ModelTrustService() + + def run_cycle( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + replay_file: Optional[str] = None, + eval_limit: int = 20, + lesson_limit: int = 100, + arena_model_codes: Optional[list[str]] = None, + cycle_key: Optional[str] = None, + now: Optional[datetime] = None, + ) -> dict: + started_ts = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + resolved_cycle_key = cycle_key or _build_cycle_key( + instrument_id=instrument_id, + model_code=model_code, + started_ts=started_ts, + ) + + lesson_summary = self.research_council_service.backfill_lessons( + instrument_id=instrument_id, + limit=lesson_limit, + ) + fresh_run = self.research_council_service.build_and_persist( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + now=started_ts, + ) + replay_summary = build_replay_summary(replay_file) if replay_file else {"status": "skipped", "reason": "replay_file_missing"} + runs = self.research_council_service.get_recent_runs(instrument_id=instrument_id, limit=eval_limit) + eval_summary = build_eval_summary(instrument_id=instrument_id, runs=runs) + arena_summary = self.build_arena_summary( + instrument_id=instrument_id, + timeframe=timeframe, + model_codes=arena_model_codes, + ) + next_actions = choose_next_actions( + lesson_summary=lesson_summary, + replay_summary=replay_summary, + eval_summary=eval_summary, + arena_summary=arena_summary, + ) + finished_ts = datetime.now(timezone.utc) + payload = { + "cycle_key": resolved_cycle_key, + "instrument_id": instrument_id, + "timeframe": timeframe, + "model_code": model_code, + "fresh_run_id": fresh_run.get("id"), + "lesson_summary": lesson_summary, + "replay_summary": replay_summary, + "eval_summary": eval_summary, + "arena_summary": arena_summary, + "next_actions": next_actions, + "started_ts": started_ts.isoformat(), + "finished_ts": finished_ts.isoformat(), + "boundary_notes": [ + "research_council_optimization_does_not_change_execution_gate", + "research_council_optimization_does_not_change_live_readiness", + "research_council_optimization_outputs_operator_follow_up_only", + ], + } + cycle_id = self.optimization_repository.upsert_cycle( + cycle_key=resolved_cycle_key, + instrument_id=instrument_id, + status="completed", + timeframe=timeframe, + model_code=model_code, + replay_file=replay_file, + lesson_summary=lesson_summary, + replay_summary=replay_summary, + eval_summary=eval_summary, + arena_summary=arena_summary, + next_actions=next_actions, + payload=payload, + started_ts=started_ts, + finished_ts=finished_ts, + ) + payload["id"] = cycle_id + payload["status"] = "completed" + payload["passed"] = bool(eval_summary.get("run_count")) and bool(arena_summary.get("rows")) + return payload + + def build_arena_summary( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_codes: Optional[list[str]] = None, + ) -> dict: + rows = [] + for arena_model_code in self._resolve_model_codes( + instrument_id=instrument_id, + model_codes=model_codes, + ): + preview = self.research_council_service.build_preview( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=arena_model_code, + ) + decision = dict(preview.get("decision") or {}) + context = dict(preview.get("context") or {}) + rows.append( + { + "model_code": arena_model_code, + "overall_stance": decision.get("overall_stance"), + "committee_confidence": float(decision.get("committee_confidence") or 0.0), + "follow_up_check_count": len(list(decision.get("follow_up_checks") or [])), + "disagreement_count": len(list(decision.get("disagreement_points") or [])), + "lesson_count": len(list(context.get("compact_lessons") or [])), + } + ) + best_row = max(rows, key=lambda item: float(item.get("committee_confidence") or 0.0), default=None) + return { + "instrument_id": instrument_id, + "timeframe": timeframe, + "rows": rows, + "best_model_code": best_row.get("model_code") if best_row else None, + "passed": bool(rows), + } + + def get_latest_cycle(self, *, instrument_id: int) -> Optional[dict]: + return self.optimization_repository.fetch_latest_cycle(instrument_id=instrument_id) + + def get_recent_cycles(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + return self.optimization_repository.fetch_recent_cycles(instrument_id=instrument_id, limit=limit) + + def _resolve_model_codes(self, *, instrument_id: int, model_codes: Optional[list[str]]) -> list[str]: + explicit = [str(item).strip() for item in list(model_codes or []) if str(item).strip()] + if explicit: + return explicit + trust_summary = self.trust_service.build_summary(instrument_id=instrument_id) + models = [ + str(item.get("model_code") or "").strip() + for item in list(trust_summary.get("models") or []) + if str(item.get("model_code") or "").strip() + ] + if models: + return models[:3] + return [DEFAULT_MODEL_CODE] + + +def build_replay_summary(path: str | Path) -> dict: + replay_path = str(path) + candles = load_replay_candles(replay_path) + frames = list(iter_historical_replay(candles)) + swings = detect_confirmed_swings_for_replay(candles) + swing_count = len(swings) + return { + "status": "completed", + "path": replay_path, + "candle_count": len(candles), + "frame_count": len(frames), + "confirmed_swing_count": swing_count, + "required_confirmed_swing_count": MIN_REPLAY_CONFIRMED_SWINGS, + "confirmed_swing_gate_passed": swing_count >= MIN_REPLAY_CONFIRMED_SWINGS, + "swing_counts": { + "swing_high": sum(1 for swing in swings if swing.kind == "swing_high"), + "swing_low": sum(1 for swing in swings if swing.kind == "swing_low"), + }, + "confirmed_swings": [_serialize_replay_swing(swing) for swing in swings], + "first_ts": candles[0].get("ts_open") if candles else None, + "last_ts": candles[-1].get("ts_close") if candles else None, + } + + +def build_eval_summary(*, instrument_id: int, runs: list[dict]) -> dict: + if not runs: + return { + "instrument_id": instrument_id, + "run_count": 0, + "error": "no_research_council_runs", + } + baseline_counts = [] + follow_up_counts = [] + question_counts = [] + disagreement_count = 0 + lesson_usage_count = 0 + session_scores: dict[str, list[float]] = {} + setup_scores: dict[str, list[float]] = {} + + for row in runs: + payload = dict(row.get("payload") or {}) + context = dict(payload.get("context") or {}) + decision = dict(payload.get("decision") or {}) + candidate = dict(context.get("latest_candidate") or {}) + trust_policy = dict(context.get("trust_policy") or {}) + risk_result = dict(context.get("risk_result") or {}) + baseline = ( + len(list(candidate.get("missing_confirmations") or [])) + + len(list(trust_policy.get("blocking_reasons") or [])) + + len(list(risk_result.get("reasons") or [])) + ) + baseline_counts.append(baseline) + follow_up_counts.append(len(list(decision.get("follow_up_checks") or []))) + question_counts.append(len(list(decision.get("operator_questions") or []))) + disagreement_count += 1 if list(decision.get("disagreement_points") or []) else 0 + lessons = list(context.get("compact_lessons") or []) + if lessons: + lesson_usage_count += 1 + session_code = str(candidate.get("session_code") or "UNKNOWN") + setup_type = str(candidate.get("model_code") or "UNKNOWN") + session_scores.setdefault(session_code, []).append(float(decision.get("committee_confidence") or 0.0)) + setup_scores.setdefault(setup_type, []).append(float(decision.get("committee_confidence") or 0.0)) + + return { + "instrument_id": instrument_id, + "run_count": len(runs), + "avg_baseline_issue_count": _avg(baseline_counts), + "avg_follow_up_check_count": _avg(follow_up_counts), + "avg_operator_question_count": _avg(question_counts), + "guidance_delta": round(_avg(follow_up_counts) - _avg(baseline_counts), 4), + "disagreement_hint_rate": round(disagreement_count / len(runs), 4), + "lesson_usage_rate": round(lesson_usage_count / len(runs), 4), + "session_confidence": {key: round(_avg(values), 4) for key, values in session_scores.items()}, + "setup_confidence": {key: round(_avg(values), 4) for key, values in setup_scores.items()}, + } + + +def choose_next_actions(*, lesson_summary: dict, replay_summary: dict, eval_summary: dict, arena_summary: dict) -> list[str]: + actions: list[str] = [] + totals = dict(lesson_summary.get("totals") or {}) + if int(totals.get("requested") or 0) == 0: + actions.append("seed_lessons_from_journal_weekly_review_or_replay") + if eval_summary.get("run_count", 0) < 3: + actions.append("collect_more_research_council_runs_before_trend_judgment") + if float(eval_summary.get("lesson_usage_rate") or 0.0) < 0.5: + actions.append("increase_lesson_retrieval_or_backfill_coverage") + if float(eval_summary.get("avg_follow_up_check_count") or 0.0) < float(eval_summary.get("avg_baseline_issue_count") or 0.0): + actions.append("strengthen_follow_up_generation_for_baseline_issues") + if float(eval_summary.get("disagreement_hint_rate") or 0.0) == 0.0: + actions.append("review_bull_bear_disagreement_prompts") + if replay_summary.get("status") == "completed" and int(replay_summary.get("confirmed_swing_count") or 0) < MIN_REPLAY_CONFIRMED_SWINGS: + actions.append("add_replay_fixture_with_confirmed_swings") + rows = list(arena_summary.get("rows") or []) + if len(rows) > 1: + sorted_rows = sorted(rows, key=lambda item: float(item.get("committee_confidence") or 0.0), reverse=True) + confidence_gap = float(sorted_rows[0].get("committee_confidence") or 0.0) - float(sorted_rows[-1].get("committee_confidence") or 0.0) + if confidence_gap >= 0.15: + actions.append("compare_top_arena_model_against_default_in_next_eval") + actions.append("rerun_optimization_cycle_after_next_market_sample") + return _dedupe(actions) + + +def _build_cycle_key(*, instrument_id: int, model_code: str, started_ts: datetime) -> str: + normalized_model = "".join(char if char.isalnum() else "_" for char in model_code).strip("_") or "model" + return f"research_council_opt:{instrument_id}:{normalized_model}:{started_ts.strftime('%Y%m%dT%H%M%SZ')}" + + +def _avg(values: list[float]) -> float: + return round(sum(values) / len(values), 4) if values else 0.0 + + +def _serialize_replay_swing(swing) -> dict: + return { + "kind": swing.kind, + "price": swing.price, + "ts": swing.ts, + "source_index": swing.source_index, + "confirmed_at_index": swing.confirmed_at_index, + "left_bars": swing.left_bars, + "right_bars": swing.right_bars, + "confirmed": swing.confirmed, + } + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + return result diff --git a/src/services/research_council/council_orchestrator.py b/src/services/research_council/council_orchestrator.py new file mode 100644 index 0000000..7603a4d --- /dev/null +++ b/src/services/research_council/council_orchestrator.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional + +from src.services.research_council.agents.bear_researcher import BearResearcher +from src.services.research_council.agents.bull_researcher import BullResearcher +from src.services.research_council.agents.news_event_analyst import NewsEventAnalyst +from src.services.research_council.agents.research_manager import ResearchManager +from src.services.research_council.agents.risk_analyst import RiskAnalyst +from src.services.research_council.agents.structure_analyst import StructureAnalyst +from src.services.research_council.agents.visual_analyst import VisualAnalyst +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase, CouncilDecision +from src.services.research_council.debate_service import build_debate_summary +from src.services.research_council.tradingagents_adapter import run_council_analyst + + +@dataclass(frozen=True) +class CouncilRun: + context: CouncilContext + structure_analysis: CouncilCase + risk_analysis: CouncilCase + news_event_analysis: CouncilCase + visual_analysis: CouncilCase + bull_case: CouncilCase + bear_case: CouncilCase + debate_summary: object + decision: CouncilDecision + + def to_dict(self) -> dict: + return dict(asdict(self)) + + +class CouncilOrchestrator: + def __init__( + self, + *, + structure_analyst: Optional[StructureAnalyst] = None, + risk_analyst: Optional[RiskAnalyst] = None, + news_event_analyst: Optional[NewsEventAnalyst] = None, + visual_analyst: Optional[VisualAnalyst] = None, + bull_researcher: Optional[BullResearcher] = None, + bear_researcher: Optional[BearResearcher] = None, + research_manager: Optional[ResearchManager] = None, + ) -> None: + self.structure_analyst = structure_analyst or StructureAnalyst() + self.risk_analyst = risk_analyst or RiskAnalyst() + self.news_event_analyst = news_event_analyst or NewsEventAnalyst() + self.visual_analyst = visual_analyst or VisualAnalyst() + self.bull_researcher = bull_researcher or BullResearcher() + self.bear_researcher = bear_researcher or BearResearcher() + self.research_manager = research_manager or ResearchManager() + + def build_run(self, context: CouncilContext) -> CouncilRun: + structure_analysis = run_council_analyst(self.structure_analyst, context) + risk_analysis = run_council_analyst(self.risk_analyst, context) + news_event_analysis = run_council_analyst(self.news_event_analyst, context) + visual_analysis = run_council_analyst(self.visual_analyst, context) + bull_case = run_council_analyst(self.bull_researcher, context) + bear_case = run_council_analyst(self.bear_researcher, context) + debate_summary = build_debate_summary( + context=context, + structure_analysis=structure_analysis, + risk_analysis=risk_analysis, + news_event_analysis=news_event_analysis, + visual_analysis=visual_analysis, + bull_case=bull_case, + bear_case=bear_case, + ) + decision = self.research_manager.build_decision( + context=context, + structure_analysis=structure_analysis, + risk_analysis=risk_analysis, + news_event_analysis=news_event_analysis, + visual_analysis=visual_analysis, + bull_case=bull_case, + bear_case=bear_case, + debate_summary=debate_summary, + ) + return CouncilRun( + context=context, + structure_analysis=structure_analysis, + risk_analysis=risk_analysis, + news_event_analysis=news_event_analysis, + visual_analysis=visual_analysis, + bull_case=bull_case, + bear_case=bear_case, + debate_summary=debate_summary, + decision=decision, + ) + + def build_decision(self, context: CouncilContext) -> CouncilDecision: + return self.build_run(context).decision diff --git a/src/services/research_council/council_output_formatter.py b/src/services/research_council/council_output_formatter.py new file mode 100644 index 0000000..70e7a1e --- /dev/null +++ b/src/services/research_council/council_output_formatter.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from src.services.research_council.council_schema import CouncilCase, CouncilDecision + + +def render_council_case(case: CouncilCase) -> str: + lines = [f"**{case.title or 'Case'}**: {case.summary}"] + if case.evidence: + lines.append("") + lines.append("**Evidence**:") + lines.extend(f"- {item}" for item in case.evidence) + return "\n".join(lines) + + +def render_council_list(title: str, items: list[str]) -> str: + lines = [f"**{title}**:"] + if not items: + lines.append("- none") + else: + lines.extend(f"- {item}" for item in items) + return "\n".join(lines) + + +def render_council_records(title: str, items: list[dict]) -> str: + lines = [f"**{title}**:"] + if not items: + lines.append("- none") + else: + for item in items: + code = item.get("code") or "item" + source = item.get("source") or "unknown" + priority = item.get("priority") or "normal" + lines.append(f"- {code} ({source}, {priority})") + return "\n".join(lines) + + +def render_council_decision(decision: CouncilDecision) -> str: + sections = [ + f"**Research Summary**: {decision.research_summary}", + "", + f"**Operator Summary**: {decision.operator_summary}", + ] + if decision.overall_stance: + sections.extend( + [ + "", + f"**Overall Stance**: {decision.overall_stance}", + ] + ) + for analysis in ( + decision.structure_analysis, + decision.risk_analysis, + decision.news_event_analysis, + decision.visual_analysis, + ): + if analysis.has_content(): + sections.extend( + [ + "", + render_council_case(analysis), + ] + ) + sections.extend( + [ + "", + render_council_case(decision.bull_case), + "", + render_council_case(decision.bear_case), + "", + render_council_list("Agreement Points", decision.agreement_points), + "", + render_council_list("Disagreement Points", decision.disagreement_points), + ] + ) + if decision.missing_information: + sections.extend( + [ + "", + render_council_list("Missing Information", decision.missing_information), + ] + ) + if decision.follow_up_checks: + sections.extend( + [ + "", + render_council_list("Follow-up Checks", decision.follow_up_checks), + ] + ) + if decision.follow_up_check_details: + sections.extend( + [ + "", + render_council_records("Follow-up Check Details", decision.follow_up_check_details), + ] + ) + if decision.next_confirmation_targets: + sections.extend( + [ + "", + render_council_list("Next Confirmation Targets", decision.next_confirmation_targets), + ] + ) + if decision.recommended_watch_items: + sections.extend( + [ + "", + render_council_list("Recommended Watch Items", decision.recommended_watch_items), + ] + ) + if decision.operator_questions: + sections.extend( + [ + "", + render_council_list("Operator Questions", decision.operator_questions), + ] + ) + if decision.execution_veto_signals: + sections.extend( + [ + "", + render_council_list("Execution Veto Signals", decision.execution_veto_signals), + ] + ) + sections.extend( + [ + "", + f"**Net Assessment**: {decision.net_assessment}", + "", + f"**Committee Confidence**: {decision.committee_confidence:.2f}", + ] + ) + if decision.source_snapshot_meta: + sections.extend( + [ + "", + f"**Source Snapshot Meta**: {decision.source_snapshot_meta}", + ] + ) + return "\n".join(sections) diff --git a/src/services/research_council/council_schema.py b/src/services/research_council/council_schema.py new file mode 100644 index 0000000..d2a3cec --- /dev/null +++ b/src/services/research_council/council_schema.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class CouncilCase: + title: str = "" + summary: str = "" + evidence: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return dict(asdict(self)) + + def has_content(self) -> bool: + return bool(self.summary or self.evidence) + + +@dataclass(frozen=True) +class CouncilDecision: + research_summary: str + operator_summary: str + bull_case: CouncilCase + bear_case: CouncilCase + net_assessment: str + committee_confidence: float + overall_stance: str = "" + structure_analysis: CouncilCase = field(default_factory=lambda: CouncilCase(title="Structure Analysis")) + risk_analysis: CouncilCase = field(default_factory=lambda: CouncilCase(title="Risk Analysis")) + news_event_analysis: CouncilCase = field(default_factory=lambda: CouncilCase(title="News/Event Analysis")) + visual_analysis: CouncilCase = field(default_factory=lambda: CouncilCase(title="Visual Analysis")) + agreement_points: list[str] = field(default_factory=list) + disagreement_points: list[str] = field(default_factory=list) + missing_information: list[str] = field(default_factory=list) + follow_up_checks: list[str] = field(default_factory=list) + follow_up_check_details: list[dict[str, Any]] = field(default_factory=list) + next_confirmation_targets: list[str] = field(default_factory=list) + recommended_watch_items: list[str] = field(default_factory=list) + recommended_watch_requests: list[dict[str, Any]] = field(default_factory=list) + operator_questions: list[str] = field(default_factory=list) + execution_veto_signals: list[str] = field(default_factory=list) + disagreement_details: list[dict[str, Any]] = field(default_factory=list) + source_snapshot_meta: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return dict(asdict(self)) + + +def build_empty_council_decision() -> CouncilDecision: + empty_case = CouncilCase(summary="", evidence=[]) + return CouncilDecision( + research_summary="", + operator_summary="", + bull_case=CouncilCase(title="Bull Case", summary="", evidence=[]), + bear_case=CouncilCase(title="Bear Case", summary="", evidence=[]), + net_assessment="", + committee_confidence=0.0, + structure_analysis=CouncilCase(title="Structure Analysis", summary="", evidence=[]), + risk_analysis=CouncilCase(title="Risk Analysis", summary="", evidence=[]), + news_event_analysis=CouncilCase(title="News/Event Analysis", summary="", evidence=[]), + visual_analysis=CouncilCase(title="Visual Analysis", summary="", evidence=[]), + source_snapshot_meta={}, + ) diff --git a/src/services/research_council/debate_service.py b/src/services/research_council/debate_service.py new file mode 100644 index 0000000..4090382 --- /dev/null +++ b/src/services/research_council/debate_service.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +@dataclass(frozen=True) +class DebateSummary: + agreement_points: list[str] = field(default_factory=list) + disagreement_points: list[str] = field(default_factory=list) + disagreement_details: list[dict[str, Any]] = field(default_factory=list) + follow_up_checks: list[str] = field(default_factory=list) + follow_up_check_details: list[dict[str, Any]] = field(default_factory=list) + next_confirmation_targets: list[str] = field(default_factory=list) + recommended_watch_items: list[str] = field(default_factory=list) + recommended_watch_requests: list[dict[str, Any]] = field(default_factory=list) + operator_questions: list[str] = field(default_factory=list) + execution_veto_signals: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return dict(asdict(self)) + + +def build_debate_summary( + *, + context: CouncilContext, + structure_analysis: CouncilCase, + risk_analysis: CouncilCase, + news_event_analysis: CouncilCase, + visual_analysis: CouncilCase, + bull_case: CouncilCase, + bear_case: CouncilCase, +) -> DebateSummary: + plan = dict(context.latest_trading_plan or {}) + candidate = dict(context.latest_candidate or {}) + market_state = dict(context.market_state or {}) + trust_policy = dict(context.trust_policy or {}) + risk_result = dict(context.risk_result or {}) + weekly_review = dict(context.weekly_review_summary or {}) + + agreement_points: list[str] = [] + disagreement_points: list[str] = [] + disagreement_details: list[dict[str, Any]] = [] + follow_up_checks: list[str] = [] + follow_up_check_details: list[dict[str, Any]] = [] + next_confirmation_targets: list[str] = [] + recommended_watch_items: list[str] = [] + recommended_watch_requests: list[dict[str, Any]] = [] + operator_questions: list[str] = [] + execution_veto_signals: list[str] = [] + + if str(plan.get("status") or "") == "active": + agreement_points.append("active_plan_present") + if str((market_state.get("freshness") or {}).get("code") or "") == "market_data_fresh": + agreement_points.append("market_truth_fresh") + if structure_analysis.has_content(): + agreement_points.append("structure_context_available") + if risk_analysis.has_content(): + agreement_points.append("risk_context_available") + + if bull_case.has_content() and bear_case.has_content(): + disagreement_points.append("bull_and_bear_cases_both_present") + disagreement_points.append("structure_support_must_be_weighed_against_execution_constraints") + disagreement_details.append( + _detail( + "structure_vs_constraints", + "debate_service", + "bull and bear cases both contain usable evidence", + priority="medium", + ) + ) + + confirmation_state = dict(candidate.get("confirmation_state") or {}) + missing_confirmations = list( + candidate.get("missing_confirmations") + or confirmation_state.get("missing_conditions") + or [] + ) + follow_up_checks.extend(str(item) for item in missing_confirmations if str(item)) + next_confirmation_targets.extend(str(item) for item in missing_confirmations if str(item)) + recommended_watch_items.extend(str(item) for item in missing_confirmations if str(item)) + for item in missing_confirmations: + text = str(item) + if not text: + continue + follow_up_check_details.append(_detail(text, "confirmation_state", "missing confirmation", priority="high")) + recommended_watch_requests.append(_watch_request(text, "candidate_confirmation")) + operator_questions.append(f"Has {text} confirmed on the trigger timeframe?") + + risk_reasons = list(risk_result.get("reasons") or []) + follow_up_checks.extend(str(item) for item in risk_reasons if str(item)) + execution_veto_signals.extend(str(item) for item in risk_reasons if str(item)) + for item in risk_reasons: + text = str(item) + if text: + follow_up_check_details.append(_detail(text, "risk_governor", "risk reason remains unresolved", priority="high")) + operator_questions.append(f"Has the risk governor blocker '{text}' been rechecked with the latest market state?") + + trust_reasons = list(trust_policy.get("blocking_reasons") or []) + follow_up_checks.extend(str(item) for item in trust_reasons if str(item)) + execution_veto_signals.extend(str(item) for item in trust_reasons if str(item)) + for item in trust_reasons: + text = str(item) + if text: + follow_up_check_details.append(_detail(text, "trust_policy", "trust boundary prevents escalation", priority="high")) + operator_questions.append(f"Does the current trust policy still require '{text}'?") + + if not bool(trust_policy.get("execution_allowed", False)): + execution_veto_signals.append("execution_not_allowed_by_trust_policy") + if risk_result.get("allowed") is False: + execution_veto_signals.append("execution_blocked_by_risk_governor") + if str(candidate.get("status") or "") not in {"executable", "confirmed"}: + recommended_watch_items.append("wait_for_candidate_confirmation") + recommended_watch_requests.append(_watch_request("wait_for_candidate_confirmation", "candidate_confirmation")) + + follow_up_checks.extend(str(item) for item in list(weekly_review.get("next_week_actions") or [])) + for item in list(weekly_review.get("next_week_actions") or []): + text = str(item) + if text: + follow_up_check_details.append(_detail(text, "weekly_review", "carry forward review action", priority="medium")) + + news_window = dict(market_state.get("news_window") or market_state.get("news_state") or {}) + if bool(news_window.get("is_active")): + follow_up_checks.append("review_active_news_window") + recommended_watch_items.append("news_window_review") + execution_veto_signals.append("news_window_active_review_required") + follow_up_check_details.append(_detail("review_active_news_window", "news_event_analyst", news_event_analysis.summary, priority="high")) + recommended_watch_requests.append(_watch_request("news_window_review", "news_window")) + operator_questions.append("Is the active macro news window close enough that execution guidance should stay blocked?") + + visual_reviews = list(context.visual_reviews or []) + if not visual_reviews: + follow_up_checks.append("visual_review_missing") + recommended_watch_items.append("visual_review_missing") + follow_up_check_details.append(_detail("visual_review_missing", "visual_analyst", visual_analysis.summary, priority="medium")) + recommended_watch_requests.append(_watch_request("visual_review_missing", "visual_confirmation")) + else: + latest_visual = dict(visual_reviews[0] or {}) + visual_state = str(latest_visual.get("visual_state") or "unknown") + if visual_state not in {"aligned", "confirmed", "clear"}: + follow_up_checks.append(f"visual_state_review:{visual_state}") + recommended_watch_items.append(f"visual_state_review:{visual_state}") + follow_up_check_details.append(_detail(f"visual_state_review:{visual_state}", "visual_analyst", visual_analysis.summary, priority="medium")) + recommended_watch_requests.append(_watch_request(f"visual_state_review:{visual_state}", "visual_confirmation")) + operator_questions.append(f"Does the latest visual review state '{visual_state}' still need manual confirmation?") + + return DebateSummary( + agreement_points=_dedupe(agreement_points), + disagreement_points=_dedupe(disagreement_points), + disagreement_details=_dedupe_details(disagreement_details), + follow_up_checks=_dedupe(follow_up_checks), + follow_up_check_details=_dedupe_details(follow_up_check_details), + next_confirmation_targets=_dedupe(next_confirmation_targets), + recommended_watch_items=_dedupe(recommended_watch_items), + recommended_watch_requests=_dedupe_details(recommended_watch_requests), + operator_questions=_dedupe(operator_questions), + execution_veto_signals=_dedupe(execution_veto_signals), + ) + + +def _dedupe(items: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + value = str(item).strip() + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _detail(code: str, source: str, reason: str, *, priority: str) -> dict[str, str]: + return { + "code": str(code), + "source": str(source), + "reason": str(reason or ""), + "priority": str(priority), + } + + +def _watch_request(code: str, source: str) -> dict[str, str]: + return { + "code": str(code), + "source": str(source), + "scope": "visual_watch", + } + + +def _dedupe_details(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for item in items: + code = str((item or {}).get("code") or "").strip() + source = str((item or {}).get("source") or "").strip() + key = (code, source) + if not code or key in seen: + continue + seen.add(key) + result.append(dict(item)) + return result diff --git a/src/services/research_council/research_council_service.py b/src/services/research_council/research_council_service.py new file mode 100644 index 0000000..872afb8 --- /dev/null +++ b/src/services/research_council/research_council_service.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.research_council_link_repository import ResearchCouncilLinkRepository +from src.repositories.research_council_message_repository import ResearchCouncilMessageRepository +from src.repositories.research_council_run_repository import ResearchCouncilRunRepository +from src.services.decision.decision_packet_service import DEFAULT_MODEL_CODE +from src.services.research_council.council_checkpoint_service import CouncilCheckpointService +from src.services.research_council.council_context_builder import CouncilContextBuilder +from src.services.research_council.council_memory_service import CouncilMemoryService +from src.services.research_council.council_orchestrator import CouncilOrchestrator, CouncilRun + + +class ResearchCouncilService: + def __init__( + self, + *, + context_builder: Optional[CouncilContextBuilder] = None, + orchestrator: Optional[CouncilOrchestrator] = None, + run_repository: Optional[ResearchCouncilRunRepository] = None, + message_repository: Optional[ResearchCouncilMessageRepository] = None, + link_repository: Optional[ResearchCouncilLinkRepository] = None, + checkpoint_service: Optional[CouncilCheckpointService] = None, + memory_service: Optional[CouncilMemoryService] = None, + ) -> None: + self.context_builder = context_builder or CouncilContextBuilder() + self.orchestrator = orchestrator or CouncilOrchestrator() + self.run_repository = run_repository or ResearchCouncilRunRepository() + self.message_repository = message_repository or ResearchCouncilMessageRepository() + self.link_repository = link_repository or ResearchCouncilLinkRepository() + self.checkpoint_service = checkpoint_service or CouncilCheckpointService() + self.memory_service = memory_service or CouncilMemoryService() + + def build_run( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + now: Optional[datetime] = None, + ) -> CouncilRun: + context = self.context_builder.build_context( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + now=now, + ) + return self.orchestrator.build_run(context) + + def build_preview( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + now: Optional[datetime] = None, + ) -> dict: + run = self.build_run( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + now=now, + ) + return run.to_dict() + + def build_and_persist( + self, + *, + instrument_id: int, + timeframe: str = "1m", + model_code: str = DEFAULT_MODEL_CODE, + now: Optional[datetime] = None, + ) -> dict: + run = self.build_run( + instrument_id=instrument_id, + timeframe=timeframe, + model_code=model_code, + now=now, + ) + generated_ts = datetime.fromisoformat(run.context.generated_ts) + run_id = self.run_repository.insert_run( + instrument_id=instrument_id, + trading_plan_id=run.context.latest_trading_plan.get("id"), + candidate_id=run.context.latest_candidate.get("id"), + signal_id=run.context.latest_signal.get("id"), + decision_packet_id=run.context.latest_signal.get("decision_packet_id"), + status="completed", + timeframe=timeframe, + model_code=model_code, + committee_confidence=float(run.decision.committee_confidence), + summary_text=str(run.decision.research_summary or ""), + payload=run.to_dict(), + generated_ts=generated_ts, + ) + self.checkpoint_service.save_checkpoint( + run_id=run_id, + instrument_id=instrument_id, + stage="run_persisted", + status="in_progress", + payload={"run_id": run_id, "message_count": 0}, + now=generated_ts, + ) + for message in _build_messages(run): + self.message_repository.insert_message( + run_id=run_id, + agent_role=message["agent_role"], + message_type=message["message_type"], + payload=message["payload"], + created_ts=generated_ts, + ) + self.link_repository.insert_link( + run_id=run_id, + trading_plan_id=run.context.latest_trading_plan.get("id"), + candidate_id=run.context.latest_candidate.get("id"), + signal_id=run.context.latest_signal.get("id"), + decision_packet_id=run.context.latest_signal.get("decision_packet_id"), + execution_ticket_id=None, + created_ts=generated_ts, + ) + self.checkpoint_service.save_checkpoint( + run_id=run_id, + instrument_id=instrument_id, + stage="run_completed", + status="completed", + payload={ + "run_id": run_id, + "message_count": len(_build_messages(run)), + "overall_stance": run.decision.overall_stance, + "committee_confidence": run.decision.committee_confidence, + }, + now=generated_ts, + ) + payload = self.get_latest_run(instrument_id=instrument_id) or run.to_dict() + payload["id"] = run_id + return payload + + def get_latest_run(self, *, instrument_id: int) -> Optional[dict]: + row = self.run_repository.fetch_latest_run(instrument_id=instrument_id) + if row is None: + return None + run_id = int(row["id"]) + row["messages"] = self.message_repository.fetch_messages_for_run(run_id=run_id, limit=50) + return row + + def get_recent_runs(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + return self.run_repository.fetch_recent_runs(instrument_id=instrument_id, limit=limit) + + def get_resume_state(self, *, instrument_id: int) -> dict: + return self.checkpoint_service.build_resume_state(instrument_id=instrument_id) + + def backfill_lessons(self, *, instrument_id: int, limit: int = 100) -> dict: + return self.memory_service.backfill_all_sources(instrument_id=instrument_id, limit=limit) + + def get_compact_lessons( + self, + *, + instrument_id: int, + symbol: Optional[str] = None, + session_code: Optional[str] = None, + setup_type: Optional[str] = None, + model_code: Optional[str] = None, + tags: Optional[list[str]] = None, + limit: int = 5, + ) -> list[dict]: + return self.memory_service.fetch_compact_lessons( + instrument_id=instrument_id, + symbol=symbol, + session_code=session_code, + setup_type=setup_type, + model_code=model_code, + tags=tags, + limit=limit, + ) + + +def _build_messages(run: CouncilRun) -> list[dict]: + return [ + { + "agent_role": "structure_analyst", + "message_type": "analysis", + "payload": run.structure_analysis.to_dict(), + }, + { + "agent_role": "risk_analyst", + "message_type": "analysis", + "payload": run.risk_analysis.to_dict(), + }, + { + "agent_role": "news_event_analyst", + "message_type": "analysis", + "payload": run.news_event_analysis.to_dict(), + }, + { + "agent_role": "visual_analyst", + "message_type": "analysis", + "payload": run.visual_analysis.to_dict(), + }, + { + "agent_role": "bull_researcher", + "message_type": "analysis", + "payload": run.bull_case.to_dict(), + }, + { + "agent_role": "bear_researcher", + "message_type": "analysis", + "payload": run.bear_case.to_dict(), + }, + { + "agent_role": "debate_service", + "message_type": "summary", + "payload": run.debate_summary.to_dict(), + }, + { + "agent_role": "research_manager", + "message_type": "decision", + "payload": run.decision.to_dict(), + }, + ] diff --git a/src/services/research_council/tradingagents_adapter.py b/src/services/research_council/tradingagents_adapter.py new file mode 100644 index 0000000..e2f1e2d --- /dev/null +++ b/src/services/research_council/tradingagents_adapter.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Protocol + +from src.services.research_council.council_context_builder import CouncilContext +from src.services.research_council.council_schema import CouncilCase + + +class CouncilAnalyst(Protocol): + role_name: str + + def analyze(self, context: CouncilContext) -> CouncilCase: + ... + + +def run_council_analyst(agent: CouncilAnalyst, context: CouncilContext) -> CouncilCase: + """Thin compatibility layer for future TradingAgents-backed analyst runners. + + P0 keeps the behavior deterministic and local. Later phases can swap the + implementation details without changing the entrypoint shape. + """ + + return agent.analyze(context) diff --git a/src/services/runtime/trading_runtime_service.py b/src/services/runtime/trading_runtime_service.py index c20ca8e..72064a5 100644 --- a/src/services/runtime/trading_runtime_service.py +++ b/src/services/runtime/trading_runtime_service.py @@ -7,12 +7,15 @@ import time from typing import Optional from src.config.settings import settings +from src.services.alerts import AlertDispatcherService from src.services.broker import BROKER_STATE_OK, BrokerReadModelService from src.services.decision import DecisionPacketService from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.reconciliation import BrokerExecutionReconciliationService +from src.services.research_council import ResearchCouncilService from src.services.trade_plan_candidates import SetupWatcherService from src.services.trading_plans import TradingPlanService +from src.services.visual_watch import VisualReviewService @dataclass @@ -57,6 +60,9 @@ class TradingRuntimeScheduler: broker_read_model_service: Optional[BrokerReadModelService] = None, reconciliation_service: Optional[BrokerExecutionReconciliationService] = None, decision_packet_service: Optional[DecisionPacketService] = None, + alert_dispatcher_service: Optional[AlertDispatcherService] = None, + research_council_service: Optional[ResearchCouncilService] = None, + visual_review_service: Optional[VisualReviewService] = None, ) -> None: self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor()) self.trading_plan_service = trading_plan_service or TradingPlanService() @@ -66,6 +72,9 @@ class TradingRuntimeScheduler: broker_read_model_service=self.broker_read_model_service, ) self.decision_packet_service = decision_packet_service or DecisionPacketService() + self.alert_dispatcher_service = alert_dispatcher_service or AlertDispatcherService() + self.research_council_service = research_council_service or ResearchCouncilService() + self.visual_review_service = visual_review_service or VisualReviewService() self.symbols = [item.strip() for item in settings.trading_runtime_symbols.split(",") if item.strip()] self.bias_tf = settings.trading_runtime_bias_timeframe self.setup_tf = settings.trading_runtime_setup_timeframe @@ -75,24 +84,36 @@ class TradingRuntimeScheduler: "broker_sync": RuntimeTaskState("broker_sync", settings.trading_runtime_broker_sync_interval_seconds), "reconciliation": RuntimeTaskState("reconciliation", settings.trading_runtime_reconciliation_interval_seconds), "watch": RuntimeTaskState("watch", settings.trading_runtime_watch_interval_seconds), + "research_council": RuntimeTaskState("research_council", settings.trading_runtime_research_council_interval_seconds), "decision_packet": RuntimeTaskState("decision_packet", settings.trading_runtime_decision_packet_interval_seconds), + "visual_watch": RuntimeTaskState("visual_watch", settings.trading_runtime_decision_packet_interval_seconds), + "alert_dispatch": RuntimeTaskState("alert_dispatch", settings.alert_dispatch_interval_seconds), "plan": RuntimeTaskState("plan", settings.trading_runtime_plan_interval_seconds), } self.current_blockers: list[str] = [] + self.current_warnings: list[str] = [] + self.current_noisy_incidents: list[str] = [] self.degraded_mode_state: dict = {} self.incidents: list[dict] = [] self.latest_market_state_by_symbol: dict[str, dict] = {} self.latest_broker_state_by_symbol: dict[str, dict] = {} + self.runtime_health: dict = {} def run_cycle(self) -> dict: if not settings.trading_runtime_enabled: self.current_blockers = ["trading_runtime_disabled"] + self.current_warnings = [] + self.current_noisy_incidents = [] + self.runtime_health = _build_runtime_health(self.current_blockers, self.current_warnings, self.current_noisy_incidents) self.degraded_mode_state = { "is_degraded": True, "codes": list(self.current_blockers), } return self.status_snapshot() + blockers: list[str] = [] + warnings: list[str] = [] + noisy_incidents: list[str] = [] now = datetime.now(timezone.utc) for symbol in self.symbols: instrument = self.trading_plan_service.repository.fetch_instrument_by_symbol(symbol) @@ -101,15 +122,54 @@ class TradingRuntimeScheduler: continue instrument_id = int(instrument["id"]) self._run_plan_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) - market_payload = self._run_market_state_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) - self._run_broker_sync_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) - self._run_reconciliation_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) - self._run_watch_task(symbol=symbol, instrument_id=instrument_id, now=now, market_payload=market_payload, blockers=blockers) + market_payload = self._run_market_state_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + blockers=blockers, + warnings=warnings, + noisy_incidents=noisy_incidents, + ) + self._run_broker_sync_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + blockers=blockers, + warnings=warnings, + ) + self._run_reconciliation_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + blockers=blockers, + warnings=warnings, + ) + self._run_watch_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + market_payload=market_payload, + blockers=blockers, + warnings=warnings, + noisy_incidents=noisy_incidents, + ) + self._run_research_council_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + warnings=warnings, + ) self._run_decision_packet_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) + self._run_visual_watch_task(symbol=symbol, instrument_id=instrument_id, now=now, warnings=warnings) + self._run_alert_dispatch_task(now=now, blockers=blockers, warnings=warnings) + self.current_blockers = _dedupe(blockers) + self.current_warnings = _dedupe(warnings) + self.current_noisy_incidents = _dedupe(noisy_incidents) + self.runtime_health = _build_runtime_health(self.current_blockers, self.current_warnings, self.current_noisy_incidents) self.degraded_mode_state = { - "is_degraded": bool(self.current_blockers), - "codes": list(self.current_blockers), + "is_degraded": bool(self.current_blockers or self.current_warnings), + "codes": [*self.current_blockers, *self.current_warnings], } return self.status_snapshot() @@ -125,6 +185,9 @@ class TradingRuntimeScheduler: }, "tasks": {name: task.to_dict() for name, task in self.tasks.items()}, "current_blockers": list(self.current_blockers), + "current_warnings": list(self.current_warnings), + "current_noisy_incidents": list(self.current_noisy_incidents), + "runtime_health": dict(self.runtime_health), "degraded_mode_state": dict(self.degraded_mode_state), "incidents": list(self.incidents[-50:]), "latest_market_state_by_symbol": dict(self.latest_market_state_by_symbol), @@ -151,9 +214,18 @@ class TradingRuntimeScheduler: _task_failure(task, now, str(exc)) code = f"plan_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) - def _run_market_state_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> dict: + def _run_market_state_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + blockers: list[str], + warnings: list[str], + noisy_incidents: list[str], + ) -> dict: task = self.tasks["market_state"] if not _task_due(task, now): return self.latest_market_state_by_symbol.get(symbol, {}) @@ -171,25 +243,33 @@ class TradingRuntimeScheduler: _task_success(task, now) if payload.get("freshness", {}).get("code") != "market_data_fresh": code = f"market_state_degraded:{symbol}:{payload.get('freshness', {}).get('code')}" - blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, observed_ts=now) + warnings.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", observed_ts=now) if payload.get("stream_state", {}).get("code") != "market_stream_connected": code = f"market_stream_degraded:{symbol}:{payload.get('stream_state', {}).get('code')}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", observed_ts=now) if payload.get("data_gap_state", {}).get("code") != "market_data_gap_clear": code = f"market_gap_degraded:{symbol}:{payload.get('data_gap_state', {}).get('code')}" - blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, observed_ts=now) + warnings.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", observed_ts=now) return payload except Exception as exc: _task_failure(task, now, str(exc)) code = f"market_state_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) return self.latest_market_state_by_symbol.get(symbol, {}) - def _run_broker_sync_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None: + def _run_broker_sync_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + blockers: list[str], + warnings: list[str], + ) -> None: task = self.tasks["broker_sync"] if not _task_due(task, now): return @@ -204,15 +284,25 @@ class TradingRuntimeScheduler: if snapshot.status != BROKER_STATE_OK: code = f"broker_sync_degraded:{symbol}:{snapshot.error or snapshot.status}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=snapshot.error, observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=snapshot.error, observed_ts=now) _task_success(task, now) except Exception as exc: _task_failure(task, now, str(exc)) code = f"broker_sync_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) - def _run_watch_task(self, *, symbol: str, instrument_id: int, now: datetime, market_payload: dict, blockers: list[str]) -> None: + def _run_watch_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + market_payload: dict, + blockers: list[str], + warnings: list[str], + noisy_incidents: list[str], + ) -> None: task = self.tasks["watch"] if not _task_due(task, now): return @@ -224,8 +314,8 @@ class TradingRuntimeScheduler: task.details = {"symbol": symbol, "status": "skipped_market_degraded"} _task_success(task, now) code = f"watch_skipped_market_degraded:{symbol}" - blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, observed_ts=now) + noisy_incidents.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="noisy_incident", observed_ts=now) return try: candidate = self.setup_watcher_service.build_candidate( @@ -246,9 +336,17 @@ class TradingRuntimeScheduler: _task_failure(task, now, str(exc)) code = f"watch_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) - def _run_reconciliation_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None: + def _run_reconciliation_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + blockers: list[str], + warnings: list[str], + ) -> None: task = self.tasks["reconciliation"] if not _task_due(task, now): return @@ -265,14 +363,14 @@ class TradingRuntimeScheduler: } if summary.get("status") != "ok": code = f"reconciliation_degraded:{symbol}:{summary.get('error') or summary.get('status')}" - blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(summary.get("error")), observed_ts=now) + warnings.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(summary.get("error")), observed_ts=now) _task_success(task, now) except Exception as exc: _task_failure(task, now, str(exc)) code = f"reconciliation_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) def _run_decision_packet_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None: task = self.tasks["decision_packet"] @@ -281,6 +379,13 @@ class TradingRuntimeScheduler: _task_start(task, now) try: packet = self.decision_packet_service.build_persist_and_alert(instrument_id=instrument_id) + visual_trigger_types: list[str] = [] + for item in list(packet.get("alerts") or []): + payload = dict(item.get("payload") or {}) + visual_trigger_types.extend(str(value) for value in list(payload.get("visual_trigger_types") or []) if value) + if payload.get("visual_trigger_type"): + visual_trigger_types.append(str(payload.get("visual_trigger_type"))) + visual_trigger_types = _dedupe(visual_trigger_types) task.details = { "symbol": symbol, "instrument_id": instrument_id, @@ -288,20 +393,130 @@ class TradingRuntimeScheduler: "status": packet.get("status"), "action_recommendation": packet.get("action_recommendation"), "alerts": len(packet.get("alerts") or []), + "visual_trigger_types": visual_trigger_types, + "visual_watch_reserved": bool(visual_trigger_types), + "research_council_present": bool((packet.get("research_council") or {}).get("present")), } _task_success(task, now) except Exception as exc: _task_failure(task, now, str(exc)) code = f"decision_packet_task_failed:{symbol}" blockers.append(code) - self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) - def _record_incident(self, *, task_name: str, symbol: str, code: str, observed_ts: datetime, error: Optional[str] = None) -> None: + def _run_research_council_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + warnings: list[str], + ) -> None: + task = self.tasks["research_council"] + if not _task_due(task, now): + return + _task_start(task, now) + try: + payload = self.research_council_service.build_and_persist( + instrument_id=instrument_id, + timeframe=self.trigger_tf, + now=now, + ) + decision = dict((payload.get("payload") or {}).get("decision") or payload.get("decision") or {}) + task.details = { + "symbol": symbol, + "instrument_id": instrument_id, + "run_id": payload.get("id"), + "status": payload.get("status") or "completed", + "overall_stance": decision.get("overall_stance"), + "committee_confidence": decision.get("committee_confidence"), + "recommended_watch_items": list(decision.get("recommended_watch_items") or []), + } + _task_success(task, now) + except Exception as exc: + _task_failure(task, now, str(exc)) + code = f"research_council_task_failed:{symbol}" + warnings.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(exc), observed_ts=now) + + def _run_visual_watch_task(self, *, symbol: str, instrument_id: int, now: datetime, warnings: list[str]) -> None: + task = self.tasks["visual_watch"] + if not _task_due(task, now): + return + _task_start(task, now) + try: + payload = self.visual_review_service.review_pending_watch_runs(limit=1, instrument_id=instrument_id) + items = list(payload.get("items") or []) + latest = items[-1] if items else {} + task.details = { + "symbol": symbol, + "instrument_id": instrument_id, + "status": payload.get("status"), + "attempted": int(payload.get("attempted") or 0), + "visual_reviews_created": int(payload.get("reviewed") or 0), + "last_visual_state": latest.get("visual_state"), + "last_snapshot_source": ( + latest.get("snapshot_source_name") or "snapshot_repository" + if latest.get("snapshot_id") + else "none" + ), + "last_snapshot_image_path": latest.get("snapshot_image_path"), + "last_snapshot_artifact_path": latest.get("snapshot_artifact_path"), + "last_watch_run_id": latest.get("watch_run_id"), + } + _task_success(task, now) + except Exception as exc: + _task_failure(task, now, str(exc)) + code = f"visual_watch_task_failed:{symbol}" + warnings.append(code) + self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(exc), observed_ts=now) + + def _run_alert_dispatch_task(self, *, now: datetime, blockers: list[str], warnings: list[str]) -> None: + task = self.tasks["alert_dispatch"] + if not _task_due(task, now): + return + if not settings.alert_dispatch_enabled: + task.details = {"status": "disabled"} + _task_success(task, now) + return + _task_start(task, now) + try: + payload = self.alert_dispatcher_service.dispatch_pending(limit=settings.alert_dispatch_max_per_run) + items = list(payload.get("items") or []) + failed = [item for item in items if item.get("status") == "failed"] + task.details = { + "attempted": int(payload.get("attempted") or 0), + "delivered": sum(1 for item in items if item.get("status") == "delivered"), + "skipped": sum(1 for item in items if item.get("status") == "skipped"), + "failed": len(failed), + } + if failed: + code = "alert_dispatch_failed" + warnings.append(code) + self._record_incident(task_name=task.name, symbol="runtime", code=code, severity="warning", error=str(failed[0].get("error") or "dispatch_failed"), observed_ts=now) + _task_success(task, now) + except Exception as exc: + _task_failure(task, now, str(exc)) + code = "alert_dispatch_task_failed" + blockers.append(code) + self._record_incident(task_name=task.name, symbol="runtime", code=code, severity="blocker", error=str(exc), observed_ts=now) + + def _record_incident( + self, + *, + task_name: str, + symbol: str, + code: str, + severity: str, + observed_ts: datetime, + error: Optional[str] = None, + ) -> None: self.incidents.append( { "task_name": task_name, "symbol": symbol, "code": code, + "severity": severity, "error": error, "observed_ts": observed_ts.isoformat(), } @@ -427,3 +642,20 @@ def _dedupe(values: list[str]) -> list[str]: seen.add(text) result.append(text) return result + + +def _build_runtime_health(blockers: list[str], warnings: list[str], noisy_incidents: list[str]) -> dict: + if blockers: + level = "blocked" + elif warnings: + level = "warning" + elif noisy_incidents: + level = "noisy" + else: + level = "ok" + return { + "level": level, + "blocker_count": len(blockers), + "warning_count": len(warnings), + "noisy_incident_count": len(noisy_incidents), + } diff --git a/src/services/trust/model_trust_service.py b/src/services/trust/model_trust_service.py index 513c217..be07b8b 100644 --- a/src/services/trust/model_trust_service.py +++ b/src/services/trust/model_trust_service.py @@ -23,6 +23,7 @@ class TrustBucket: quality_gate_passed: bool recommendation: str analysis_only_allowed: bool + restricted_decision_support_allowed: bool decision_support_allowed: bool execution_allowed: bool operator_mode: str @@ -64,6 +65,9 @@ class ModelTrustService: observe_list = [bucket["model_code"] for bucket in ranked if bucket["classification"] == "observe"] blacklist = [bucket["model_code"] for bucket in ranked if bucket["classification"] == "blacklist"] analysis_only_list = [bucket["model_code"] for bucket in ranked if bucket.get("analysis_only_allowed")] + restricted_decision_support_list = [ + bucket["model_code"] for bucket in ranked if bucket.get("restricted_decision_support_allowed") + ] decision_support_list = [bucket["model_code"] for bucket in ranked if bucket.get("decision_support_allowed")] execution_candidate_list = [bucket["model_code"] for bucket in ranked if bucket.get("execution_allowed")] next_actions = recommend_trust_actions(ranked) @@ -76,6 +80,7 @@ class ModelTrustService: "observe_list": observe_list, "blacklist": blacklist, "analysis_only_list": analysis_only_list, + "restricted_decision_support_list": restricted_decision_support_list, "decision_support_list": decision_support_list, "execution_candidate_list": execution_candidate_list, "next_actions": next_actions, @@ -120,7 +125,11 @@ def build_model_buckets(*, backtest_runs: list[dict], journal_entries: list[dict expectancy_r=expectancy_r, real_trade_count=real_trade_count, ) - classification = classify_model(score=score, quality_gate_passed=bool(quality_gate.get("passed")), sample_size=sample_size) + classification = classify_model( + score=score, + quality_gate_passed=bool(quality_gate.get("passed")), + sample_size=sample_size, + ) allowances = build_trust_allowances(classification) result[model_code] = TrustBucket( model_code=model_code, @@ -135,6 +144,7 @@ def build_model_buckets(*, backtest_runs: list[dict], journal_entries: list[dict quality_gate_passed=bool(quality_gate.get("passed")), recommendation=recommend_model_action(classification), analysis_only_allowed=allowances["analysis_only_allowed"], + restricted_decision_support_allowed=allowances["restricted_decision_support_allowed"], decision_support_allowed=allowances["decision_support_allowed"], execution_allowed=allowances["execution_allowed"], operator_mode=allowances["operator_mode"], @@ -202,6 +212,7 @@ def build_trust_allowances(classification: str) -> dict: if classification == "whitelist": return { "analysis_only_allowed": True, + "restricted_decision_support_allowed": True, "decision_support_allowed": True, "execution_allowed": True, "operator_mode": "trade_inside_framework", @@ -209,12 +220,14 @@ def build_trust_allowances(classification: str) -> dict: if classification == "observe": return { "analysis_only_allowed": True, + "restricted_decision_support_allowed": True, "decision_support_allowed": True, "execution_allowed": False, "operator_mode": "decision_support_only", } return { "analysis_only_allowed": True, + "restricted_decision_support_allowed": True, "decision_support_allowed": False, "execution_allowed": False, "operator_mode": "observe_only", diff --git a/src/services/visual_watch/__init__.py b/src/services/visual_watch/__init__.py new file mode 100644 index 0000000..fecfa4d --- /dev/null +++ b/src/services/visual_watch/__init__.py @@ -0,0 +1,29 @@ +from src.services.visual_watch.visual_trigger_policy_service import ( + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, + VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, + VISUAL_TRIGGER_NEWS_WINDOW_REVIEW, + VISUAL_TRIGGER_RECONCILIATION_REVIEW, + VISUAL_TRIGGER_SESSION_FOCUS_WATCH, + build_visual_watch_request_stub, + suggest_visual_trigger, + suggest_visual_triggers_from_council, +) +from src.services.visual_watch.snapshot_capture_service import VisualSnapshotCaptureService +from src.services.visual_watch.visual_review_service import VisualReviewService +from src.services.visual_watch.visual_watch_service import VisualWatchService + +__all__ = [ + "VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH", + "VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW", + "VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW", + "VISUAL_TRIGGER_NEWS_WINDOW_REVIEW", + "VISUAL_TRIGGER_RECONCILIATION_REVIEW", + "VISUAL_TRIGGER_SESSION_FOCUS_WATCH", + "build_visual_watch_request_stub", + "suggest_visual_trigger", + "suggest_visual_triggers_from_council", + "VisualSnapshotCaptureService", + "VisualReviewService", + "VisualWatchService", +] diff --git a/src/services/visual_watch/snapshot_capture_service.py b/src/services/visual_watch/snapshot_capture_service.py new file mode 100644 index 0000000..c796ada --- /dev/null +++ b/src/services/visual_watch/snapshot_capture_service.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Optional, TYPE_CHECKING +import json +import re + +if TYPE_CHECKING: + from src.services.operator import ReviewPacketService + + +ROOT = Path(__file__).resolve().parents[3] +DEFAULT_CAPTURE_ROOT = ROOT / ".claude" / "visual_watch" / "captures" +CAPTURE_MODE_REVIEW_PACKET_HTML = "review_packet_html" + + +class VisualSnapshotCaptureService: + def __init__( + self, + *, + review_packet_service: Optional[object] = None, + output_root: str | Path | None = None, + ) -> None: + self.review_packet_service = review_packet_service + self.output_root = _resolve_output_dir(output_root) + + def capture_watch_run_snapshot( + self, + *, + watch_run: dict, + source_name: str | None = None, + output_dir: str | Path | None = None, + ) -> dict: + watch_payload = dict(watch_run or {}) + if watch_payload.get("id") is None: + return {"status": "ignored", "reason": "watch_run_id_missing"} + if watch_payload.get("instrument_id") is None: + return { + "status": "ignored", + "reason": "instrument_id_missing", + "watch_run_id": int(watch_payload["id"]), + } + + try: + captured_ts = datetime.now(timezone.utc) + resolved_source_name = str(source_name or "auto_visual_watch_capture") + review_packet, review_packet_error = self._build_review_packet(watch_payload) + output_root = _resolve_output_dir(output_dir) if output_dir is not None else self.output_root + output_root.mkdir(parents=True, exist_ok=True) + + slug = _build_capture_slug(watch_payload=watch_payload, captured_ts=captured_ts) + html_path = output_root / f"{slug}.html" + artifact_path = output_root / f"{slug}.json" + payload = _build_capture_payload( + watch_payload=watch_payload, + review_packet=review_packet, + review_packet_error=review_packet_error, + captured_ts=captured_ts, + source_name=resolved_source_name, + html_path=html_path, + artifact_path=artifact_path, + ) + html_path.write_text( + _render_capture_html( + watch_payload=watch_payload, + payload=payload, + review_packet=review_packet, + review_packet_error=review_packet_error, + ), + encoding="utf-8", + ) + artifact_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str), encoding="utf-8") + return { + "status": "captured", + "watch_run_id": int(watch_payload["id"]), + "instrument_id": int(watch_payload["instrument_id"]), + "source_name": resolved_source_name, + "image_path": str(html_path), + "artifact_path": str(artifact_path), + "captured_ts": captured_ts.isoformat(), + "capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML, + "review_packet_status": payload["review_packet_status"], + "review_packet_error": review_packet_error, + "payload": payload, + } + except Exception as exc: + return { + "status": "failed", + "reason": "snapshot_capture_failed", + "watch_run_id": int(watch_payload["id"]), + "error_type": exc.__class__.__name__, + "error": str(exc), + } + + def _build_review_packet(self, watch_payload: dict) -> tuple[dict | None, str | None]: + try: + review_packet_service = self.review_packet_service + if review_packet_service is None: + from src.services.operator import ReviewPacketService + + review_packet_service = ReviewPacketService() + return review_packet_service.build_packet(instrument_id=int(watch_payload["instrument_id"])), None + except Exception as exc: + return None, f"{exc.__class__.__name__}: {exc}" + + +def _resolve_output_dir(path: str | Path | None) -> Path: + if path is None or str(path).strip() == "": + return DEFAULT_CAPTURE_ROOT + output_dir = Path(path) + if not output_dir.is_absolute(): + output_dir = ROOT / output_dir + return output_dir + + +def _build_capture_slug(*, watch_payload: dict, captured_ts: datetime) -> str: + timestamp = captured_ts.strftime("%Y%m%dT%H%M%SZ") + trigger_type = str(watch_payload.get("trigger_type") or "visual_watch") + return _slugify(f"watch-{watch_payload.get('id')}-{trigger_type}-{timestamp}") + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-_.").lower() + return slug or "visual-watch-capture" + + +def _build_capture_payload( + *, + watch_payload: dict, + review_packet: dict | None, + review_packet_error: str | None, + captured_ts: datetime, + source_name: str, + html_path: Path, + artifact_path: Path, +) -> dict: + return { + "capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML, + "captured_ts": captured_ts.isoformat(), + "source_name": source_name, + "image_path": str(html_path), + "artifact_path": str(artifact_path), + "watch_run": _watch_summary(watch_payload), + "review_packet_status": "captured" if review_packet is not None else "unavailable", + "review_packet_error": review_packet_error, + "review_packet_summary": _review_packet_summary(review_packet), + "review_packet": review_packet or {}, + } + + +def _watch_summary(watch_payload: dict) -> dict: + return { + "id": watch_payload.get("id"), + "trigger_type": watch_payload.get("trigger_type"), + "instrument_id": watch_payload.get("instrument_id"), + "candidate_id": watch_payload.get("candidate_id"), + "signal_id": watch_payload.get("signal_id"), + "decision_packet_id": watch_payload.get("decision_packet_id"), + "symbol": watch_payload.get("symbol"), + "timeframe": watch_payload.get("timeframe"), + "status": watch_payload.get("status"), + "created_ts": watch_payload.get("created_ts"), + } + + +def _review_packet_summary(review_packet: dict | None) -> dict: + if not review_packet: + return {"present": False} + decision_packet = dict(review_packet.get("decision_packet") or {}) + research_summary = dict(review_packet.get("research_council_summary") or {}) + optimization_review = dict(review_packet.get("research_council_optimization_review") or {}) + return { + "present": True, + "generated_ts": review_packet.get("generated_ts"), + "decision_packet_id": decision_packet.get("id"), + "decision_status": decision_packet.get("status"), + "action_recommendation": decision_packet.get("action_recommendation"), + "recent_alert_count": len(list(review_packet.get("recent_alerts") or [])), + "recent_note_count": len(list(review_packet.get("recent_notes") or [])), + "recent_execution_count": len(list(review_packet.get("recent_executions") or [])), + "council_present": bool(research_summary.get("present")), + "council_stance": research_summary.get("overall_stance"), + "optimization_health": optimization_review.get("health_state"), + } + + +def _render_capture_html( + *, + watch_payload: dict, + payload: dict, + review_packet: dict | None, + review_packet_error: str | None, +) -> str: + watch_json = _json_block(watch_payload) + summary_json = _json_block(payload.get("review_packet_summary") or {}) + review_json = _json_block(review_packet or {"error": review_packet_error or "review_packet_unavailable"}) + title = f"Visual Watch Snapshot #{escape(str(watch_payload.get('id') or 'unknown'))}" + trigger = escape(str(watch_payload.get("trigger_type") or "unknown")) + symbol = escape(str(watch_payload.get("symbol") or "unknown")) + timeframe = escape(str(watch_payload.get("timeframe") or "unknown")) + status = escape(str(watch_payload.get("status") or "unknown")) + captured_ts = escape(str(payload.get("captured_ts") or "")) + return f""" + + + + {title} + + + +
+

{title}

+

This artifact is an advisory review-packet capture for visual watch. It does not change execution gates.

+
+
trigger{trigger}
+
symbol{symbol}
+
timeframe{timeframe}
+
status{status}
+
captured{captured_ts}
+
+
Watch Run
{watch_json}
+
Review Packet Summary
{summary_json}
+
Review Packet JSON
{review_json}
+
+ + +""" + + +def _json_block(payload: dict) -> str: + return escape(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str)) diff --git a/src/services/visual_watch/visual_review_service.py b/src/services/visual_watch/visual_review_service.py new file mode 100644 index 0000000..f9e480b --- /dev/null +++ b/src/services/visual_watch/visual_review_service.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.visual_snapshot_repository import VisualSnapshotRepository +from src.services.visual_watch.snapshot_capture_service import VisualSnapshotCaptureService +from src.services.visual_watch.visual_trigger_policy_service import ( + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, + VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, + VISUAL_TRIGGER_NEWS_WINDOW_REVIEW, + VISUAL_TRIGGER_RECONCILIATION_REVIEW, + VISUAL_TRIGGER_SESSION_FOCUS_WATCH, +) +from src.services.visual_watch.visual_watch_service import VisualWatchService + + +class VisualReviewService: + def __init__( + self, + repository: Optional[VisualSnapshotRepository] = None, + snapshot_capture_service: Optional[VisualSnapshotCaptureService] = None, + visual_watch_service: Optional[VisualWatchService] = None, + ) -> None: + self.repository = repository or VisualSnapshotRepository() + self.snapshot_capture_service = snapshot_capture_service or VisualSnapshotCaptureService() + self.visual_watch_service = visual_watch_service or VisualWatchService(repository=self.repository) + + def review_watch_run( + self, + *, + watch_run_id: int, + snapshot_id: int | None = None, + image_path: str | None = None, + source_name: str | None = None, + visual_state: str | None = None, + visual_confidence: float | None = None, + visual_notes: str = "", + ui_anomalies: list | None = None, + reviewed_ts=None, + payload: dict | None = None, + ) -> dict: + watch_run = self.repository.fetch_watch_run_by_id(watch_run_id) + if not watch_run: + return {"status": "ignored", "reason": "watch_run_missing", "watch_run_id": watch_run_id} + + snapshot = self._resolve_snapshot( + watch_run=watch_run, + snapshot_id=snapshot_id, + image_path=image_path, + source_name=source_name, + ) + review_payload = self._build_review_payload( + watch_run=watch_run, + snapshot=snapshot, + visual_state=visual_state, + visual_confidence=visual_confidence, + visual_notes=visual_notes, + ui_anomalies=ui_anomalies, + payload=payload, + reviewed_ts=reviewed_ts, + ) + review_result = self.visual_watch_service.record_visual_review(review_payload) + completed_payload = dict(watch_run.get("payload") or {}) + completed_payload.update( + { + "review_status": "reviewed", + "review_id": review_result.get("review_id"), + "snapshot_id": review_result.get("snapshot_id"), + "visual_state": review_result.get("visual_state"), + "reviewed_ts": review_result.get("reviewed_ts"), + "visual_notes": review_payload.get("visual_notes") or "", + } + ) + self.repository.mark_visual_watch_run_completed( + watch_run_id=watch_run_id, + status="reviewed", + payload=completed_payload, + completed_ts=_parse_iso(review_result.get("reviewed_ts")), + ) + return { + "status": "reviewed", + "watch_run_id": watch_run_id, + "trigger_type": watch_run.get("trigger_type"), + "candidate_id": watch_run.get("candidate_id"), + "decision_packet_id": watch_run.get("decision_packet_id"), + "snapshot_id": review_result.get("snapshot_id"), + "snapshot_source_name": snapshot.get("source_name") if snapshot else None, + "snapshot_image_path": snapshot.get("image_path") if snapshot else None, + "snapshot_artifact_path": _snapshot_artifact_path(snapshot), + "review_id": review_result.get("review_id"), + "visual_state": review_result.get("visual_state"), + "visual_confidence": review_payload.get("visual_confidence"), + "visual_notes": review_payload.get("visual_notes"), + "reviewed_ts": review_result.get("reviewed_ts"), + } + + def review_pending_watch_runs( + self, + *, + limit: int = 10, + instrument_id: int | None = None, + trigger_type: str | None = None, + image_path: str | None = None, + source_name: str | None = None, + ) -> dict: + pending = self.repository.fetch_pending_watch_runs( + limit=limit, + instrument_id=instrument_id, + trigger_type=trigger_type, + ) + items = [] + for watch_run in pending: + items.append( + self.review_watch_run( + watch_run_id=int(watch_run["id"]), + image_path=image_path, + source_name=source_name, + ) + ) + status = "completed" if items else "idle" + return { + "status": status, + "attempted": len(pending), + "reviewed": len(items), + "items": items, + "pending_watch_run_ids": [int(item["id"]) for item in pending], + } + + def _resolve_snapshot( + self, + *, + watch_run: dict, + snapshot_id: int | None, + image_path: str | None, + source_name: str | None, + ) -> dict | None: + if snapshot_id is not None: + snapshot = self.repository.fetch_snapshot_by_id(int(snapshot_id)) + if snapshot: + return snapshot + if image_path: + capture = self.visual_watch_service.record_snapshot( + { + "watch_run_id": watch_run["id"], + "image_path": image_path, + "source_name": source_name or str(watch_run.get("source_name") or "auto_visual_review"), + "payload": { + "auto_created": True, + "watch_run_id": watch_run["id"], + "trigger_type": watch_run.get("trigger_type"), + }, + } + ) + if capture.get("snapshot_id") is not None: + return self.repository.fetch_snapshot_by_id(int(capture["snapshot_id"])) + candidate_id = watch_run.get("candidate_id") + if candidate_id is not None: + return self.repository.fetch_latest_snapshot_for_candidate(int(candidate_id)) + capture = self.snapshot_capture_service.capture_watch_run_snapshot( + watch_run=watch_run, + source_name=source_name or "auto_visual_watch_capture", + ) + if capture.get("status") != "captured": + return None + try: + snapshot_result = self.visual_watch_service.record_snapshot( + { + "watch_run_id": watch_run["id"], + "image_path": capture.get("image_path"), + "source_name": capture.get("source_name") or source_name or "auto_visual_watch_capture", + "captured_ts": capture.get("captured_ts"), + "status": capture.get("status") or "captured", + "payload": capture.get("payload") or {}, + } + ) + except Exception: + return None + if snapshot_result.get("snapshot_id") is not None: + return self.repository.fetch_snapshot_by_id(int(snapshot_result["snapshot_id"])) + return None + + def _build_review_payload( + self, + *, + watch_run: dict, + snapshot: dict | None, + visual_state: str | None, + visual_confidence: float | None, + visual_notes: str, + ui_anomalies: list | None, + payload: dict | None, + reviewed_ts=None, + ) -> dict: + state, confidence, notes = self._default_review_state(watch_run=watch_run, snapshot=snapshot) + resolved_state = str(visual_state or state) + resolved_confidence = float(visual_confidence if visual_confidence is not None else confidence) + resolved_notes = str(visual_notes or notes) + if snapshot is not None and not visual_notes: + resolved_notes = f"{resolved_notes} snapshot={snapshot.get('id') or snapshot.get('watch_run_id')}" + payload = dict(payload or {}) + payload.update( + { + "watch_run_id": watch_run.get("id"), + "trigger_type": watch_run.get("trigger_type"), + "candidate_id": watch_run.get("candidate_id"), + "decision_packet_id": watch_run.get("decision_packet_id"), + "snapshot_id": snapshot.get("id") if snapshot else None, + "visual_state": resolved_state, + "visual_confidence": resolved_confidence, + "visual_notes": resolved_notes, + } + ) + return { + "watch_run_id": watch_run.get("id"), + "snapshot_id": snapshot.get("id") if snapshot else None, + "visual_state": resolved_state, + "visual_confidence": resolved_confidence, + "visual_notes": resolved_notes, + "ui_anomalies": list(ui_anomalies or []), + "reviewed_ts": reviewed_ts or datetime.now(timezone.utc), + "payload": payload, + } + + def _default_review_state(self, *, watch_run: dict, snapshot: dict | None) -> tuple[str, float, str]: + trigger_type = str(watch_run.get("trigger_type") or "") + if snapshot is None: + return ( + "unavailable", + 0.0, + f"No snapshot available for trigger={trigger_type or 'unknown'}; recorded structured review only.", + ) + if trigger_type == VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH: + return "confirm", 0.86, "Actionable candidate watch confirmed against the structured packet." + if trigger_type == VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW: + return "caution", 0.68, "Blocked candidate watch retained for operator review." + if trigger_type == VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW: + return "mismatch", 0.56, "Market anomaly watch highlighted a structural mismatch." + if trigger_type == VISUAL_TRIGGER_RECONCILIATION_REVIEW: + return "confirm", 0.74, "Reconciliation watch aligns with current broker state." + if trigger_type == VISUAL_TRIGGER_NEWS_WINDOW_REVIEW: + return "caution", 0.64, "News-window watch remains advisory." + if trigger_type == VISUAL_TRIGGER_SESSION_FOCUS_WATCH: + return "caution", 0.62, "Session focus watch is structurally consistent but still advisory." + return "caution", 0.6, f"Structured visual review completed for trigger={trigger_type or 'unknown'}." + + +def _parse_iso(value): + if value is None: + return None + if isinstance(value, datetime): + return value.astimezone(timezone.utc) + text = str(value).strip() + if not text: + return None + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _snapshot_artifact_path(snapshot: dict | None): + if not snapshot: + return None + payload = dict(snapshot.get("payload") or {}) + nested = payload.get("payload") + return payload.get("artifact_path") or (dict(nested or {}).get("artifact_path") if isinstance(nested, dict) else None) diff --git a/src/services/visual_watch/visual_trigger_policy_service.py b/src/services/visual_watch/visual_trigger_policy_service.py new file mode 100644 index 0000000..8a81931 --- /dev/null +++ b/src/services/visual_watch/visual_trigger_policy_service.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH = "candidate_actionable_watch" +VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW = "candidate_block_reason_review" +VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW = "market_anomaly_review" +VISUAL_TRIGGER_SESSION_FOCUS_WATCH = "session_focus_watch" +VISUAL_TRIGGER_NEWS_WINDOW_REVIEW = "news_window_review" +VISUAL_TRIGGER_RECONCILIATION_REVIEW = "reconciliation_review" + + +def suggest_visual_trigger(*, action_recommendation: str, status: str, why_blocked: list[str] | None = None) -> str | None: + blocked = list(why_blocked or []) + if action_recommendation in {"build_execution_ticket", "confirm_ticket", "manage_ticket"}: + return VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH + if action_recommendation in {"restricted_decision_support", "wait_for_confirmations", "do_not_execute"}: + return VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW + if status == "monitoring": + return VISUAL_TRIGGER_SESSION_FOCUS_WATCH + if blocked: + return VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW + return None + + +def suggest_visual_triggers_from_council( + *, + recommended_watch_items: list[str] | None = None, + action_recommendation: str = "", + status: str = "", + why_blocked: list[str] | None = None, +) -> list[str]: + triggers: list[str] = [] + base_trigger = suggest_visual_trigger( + action_recommendation=action_recommendation, + status=status, + why_blocked=why_blocked, + ) + if base_trigger: + triggers.append(base_trigger) + for item in list(recommended_watch_items or []): + normalized = str(item or "").strip().lower() + if not normalized: + continue + if "news" in normalized: + triggers.append(VISUAL_TRIGGER_NEWS_WINDOW_REVIEW) + elif "reconciliation" in normalized or "broker" in normalized or "fill" in normalized: + triggers.append(VISUAL_TRIGGER_RECONCILIATION_REVIEW) + elif "market" in normalized or "stream" in normalized or "gap" in normalized: + triggers.append(VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW) + elif "candidate" in normalized or "confirmation" in normalized or "visual" in normalized: + triggers.append(VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW) + else: + triggers.append(VISUAL_TRIGGER_SESSION_FOCUS_WATCH) + return _dedupe(triggers) + + +def build_visual_watch_request_stub( + *, + trigger_type: str | None, + instrument_id: int, + candidate_id: int | None, + signal_id: int | None, + decision_packet_id: int | None, + symbol: str, + timeframe: str, +) -> dict | None: + if not trigger_type: + return None + return { + "trigger_type": trigger_type, + "instrument_id": instrument_id, + "candidate_id": candidate_id, + "signal_id": signal_id, + "decision_packet_id": decision_packet_id, + "symbol": symbol, + "timeframe": timeframe, + "source_name": "reserved_for_visual_watch", + "status": "not_implemented", + } + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + return result diff --git a/src/services/visual_watch/visual_watch_service.py b/src/services/visual_watch/visual_watch_service.py new file mode 100644 index 0000000..8ca51bd --- /dev/null +++ b/src/services/visual_watch/visual_watch_service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.visual_snapshot_repository import VisualSnapshotRepository + + +class VisualWatchService: + def __init__(self, repository: Optional[VisualSnapshotRepository] = None) -> None: + self.repository = repository or VisualSnapshotRepository() + + def reserve_watch_request(self, request: dict) -> dict: + payload = dict(request or {}) + if not payload.get("trigger_type"): + return {"status": "ignored", "reason": "trigger_type_missing"} + if payload.get("decision_packet_id") is None: + return {"status": "ignored", "reason": "decision_packet_id_missing"} + watch_run_id = self.repository.insert_visual_watch_run( + trigger_type=str(payload.get("trigger_type")), + source_name=str(payload.get("source_name") or "reserved_for_visual_watch"), + instrument_id=int(payload.get("instrument_id")), + candidate_id=payload.get("candidate_id"), + signal_id=payload.get("signal_id"), + decision_packet_id=payload.get("decision_packet_id"), + symbol=str(payload.get("symbol") or ""), + timeframe=str(payload.get("timeframe") or "1m"), + status="reserved", + payload=payload, + ) + payload["watch_run_id"] = watch_run_id + payload["status"] = "reserved" + payload["reserved_ts"] = datetime.now(timezone.utc).isoformat() + self.repository.update_visual_watch_run_payload(watch_run_id=watch_run_id, payload=payload) + return { + "status": "reserved", + "watch_run_id": watch_run_id, + "trigger_type": payload.get("trigger_type"), + "reserved_ts": payload["reserved_ts"], + } + + def record_snapshot(self, request: dict) -> dict: + payload = dict(request or {}) + if payload.get("watch_run_id") is None: + return {"status": "ignored", "reason": "watch_run_id_missing"} + if not payload.get("image_path"): + return {"status": "ignored", "reason": "image_path_missing"} + captured_ts = payload.get("captured_ts") or datetime.now(timezone.utc) + if isinstance(captured_ts, str): + captured_ts = datetime.fromisoformat(captured_ts.replace("Z", "+00:00")) + snapshot_id = self.repository.insert_snapshot( + watch_run_id=int(payload.get("watch_run_id")), + source_name=str(payload.get("source_name") or "manual_capture"), + image_path=str(payload.get("image_path")), + captured_ts=captured_ts, + status=str(payload.get("status") or "captured"), + payload=payload, + ) + return { + "status": "captured", + "snapshot_id": snapshot_id, + "watch_run_id": int(payload.get("watch_run_id")), + "captured_ts": captured_ts.astimezone(timezone.utc).isoformat(), + "image_path": str(payload.get("image_path")), + } + + def record_visual_review(self, request: dict) -> dict: + payload = dict(request or {}) + if payload.get("watch_run_id") is None: + return {"status": "ignored", "reason": "watch_run_id_missing"} + if not payload.get("visual_state"): + return {"status": "ignored", "reason": "visual_state_missing"} + reviewed_ts = payload.get("reviewed_ts") or datetime.now(timezone.utc) + if isinstance(reviewed_ts, str): + reviewed_ts = datetime.fromisoformat(reviewed_ts.replace("Z", "+00:00")) + review_id = self.repository.insert_visual_review( + watch_run_id=int(payload.get("watch_run_id")), + snapshot_id=payload.get("snapshot_id"), + visual_state=str(payload.get("visual_state")), + visual_confidence=payload.get("visual_confidence"), + visual_notes=str(payload.get("visual_notes") or ""), + ui_anomalies=list(payload.get("ui_anomalies") or []), + reviewed_ts=reviewed_ts, + payload=payload, + ) + return { + "status": "reviewed", + "review_id": review_id, + "watch_run_id": int(payload.get("watch_run_id")), + "snapshot_id": payload.get("snapshot_id"), + "visual_state": str(payload.get("visual_state")), + "reviewed_ts": reviewed_ts.astimezone(timezone.utc).isoformat(), + } + + def fetch_reviews_for_packet(self, *, decision_packet_id: int, limit: int = 10) -> list[dict]: + return self.repository.fetch_reviews_for_packet(decision_packet_id=decision_packet_id, limit=limit) diff --git a/src/web/app.js b/src/web/app.js index 6a8d3c1..12b89d3 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -22,6 +22,7 @@ const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); const executionTicketsPanel = document.getElementById('executionTicketsPanel'); const alertHistoryPanel = document.getElementById('alertHistoryPanel'); +const visualReviewsPanel = document.getElementById('visualReviewsPanel'); const positionManagementPanel = document.getElementById('positionManagementPanel'); const operatorConsolePanel = document.getElementById('operatorConsolePanel'); const postTradeReviewPanel = document.getElementById('postTradeReviewPanel'); @@ -328,6 +329,8 @@ function renderDecisionPacket(packet, alertRows) { buildInfoGrid([ ['status', packet.status || 'unknown'], ['recommendation', packet.action_recommendation || 'monitor'], + ['operator mode', packet.operator_mode || 'unknown'], + ['trust classification', packet.trust_classification || 'unknown'], ['bias', packet.bias || 'unknown'], ['setup', packet.setup_type || 'n/a'], ['candidate', packet.candidate_id || 'n/a'], @@ -336,6 +339,10 @@ function renderDecisionPacket(packet, alertRows) { ['why allowed', formatList(packet.why_allowed || [])], ['why blocked', formatList(packet.why_blocked || [])], ['ai explanation', packet.ai_explanation || 'n/a'], + ['council summary', packet.research_council?.research_summary || 'n/a'], + ['council confidence', packet.research_council?.committee_confidence != null ? formatNumber(packet.research_council.committee_confidence, 2) : 'n/a'], + ['council follow-up', formatList(packet.research_council?.follow_up_checks || [])], + ['council watch', formatList(packet.research_council?.recommended_watch_items || [])], ]) ); const grid = createElement('div', 'cards'); @@ -449,10 +456,15 @@ function renderLaunchReadiness(payload) { launchReadinessPanel.appendChild( buildInfoGrid([ ['analysis only ready', String(Boolean(payload?.analysis_only_ready))], + ['restricted decision support ready', String(Boolean(payload?.restricted_decision_support_ready))], ['decision support ready', String(Boolean(payload?.decision_support_ready))], + ['execution assist ready', String(Boolean(payload?.execution_assist_ready))], ['real trade loop ready', String(Boolean(payload?.real_trade_loop_ready))], ['assisted trading ready', String(Boolean(payload?.assisted_trading_ready))], ['real live ready', String(Boolean(payload?.real_live_ready))], + ['product mode', payload?.product_operating_mode || 'unknown'], + ['product capabilities', formatJsonInline(payload?.product_capabilities || {})], + ['boundary notes', formatList(payload?.product_boundary_notes || [])], ['blocking reasons', formatList(payload?.blocking_reasons || [])], ['warning reasons', formatList(payload?.warning_reasons || [])], ['next actions', formatList(payload?.next_actions || [])], @@ -533,6 +545,100 @@ function renderAlertHistory(alertRows) { alertHistoryPanel.appendChild(grid); } +function getVisualSnapshotArtifactPath(review) { + const snapshotPayload = review?.snapshot_payload || {}; + return snapshotPayload.artifact_path || snapshotPayload?.payload?.artifact_path || review?.snapshot_artifact_path || 'n/a'; +} + +function getVisualFeedbackSummary(review) { + const summary = review?.feedback_summary || {}; + const labels = summary.labels || {}; + const labelParts = Object.entries(labels).map(([label, count]) => `${label}:${count}`); + return { + count: summary.count ?? (Array.isArray(review?.feedback) ? review.feedback.length : 0), + latestLabel: summary.latest_label || 'n/a', + labels: labelParts.length ? labelParts.join(', ') : 'none', + usefulCount: summary.useful_count ?? 0, + misleadingCount: summary.misleading_count ?? 0, + actionableCount: summary.actionable_count ?? 0, + }; +} + +function renderVisualReviews(reviewRows, decisionPacketId) { + clearNode(visualReviewsPanel); + const items = (reviewRows?.items || []).slice(0, 10); + const latest = items[0] || null; + const latestFeedback = getVisualFeedbackSummary(latest); + visualReviewsPanel.appendChild( + buildInfoGrid([ + ['decision packet', decisionPacketId || 'n/a'], + ['reviews', String(items.length)], + ['latest state', latest?.visual_state || 'n/a'], + ['latest source', latest?.snapshot_source_name || 'n/a'], + ['latest artifact', getVisualSnapshotArtifactPath(latest)], + ['latest feedback', `${latestFeedback.count} | ${latestFeedback.latestLabel}`], + ['feedback labels', latestFeedback.labels], + ]) + ); + if (!items.length) { + visualReviewsPanel.appendChild(createElement('div', 'empty-state', 'No visual reviews recorded for the current decision packet.')); + return; + } + const grid = createElement('div', 'cards'); + items.forEach((review) => { + const feedbackSummary = getVisualFeedbackSummary(review); + grid.appendChild( + buildItemCard( + `Watch ${review.watch_run_id || 'n/a'}`, + review.visual_state || 'unknown', + [ + ['snapshot', review.snapshot_id || 'n/a'], + ['source', review.snapshot_source_name || 'n/a'], + ['image path', review.snapshot_image_path || 'n/a'], + ['artifact path', getVisualSnapshotArtifactPath(review)], + ['reviewed', formatTimestamp(review.reviewed_ts)], + ['confidence', formatNumber(review.visual_confidence, 2)], + ['anomalies', formatList(review.ui_anomalies || [])], + ['feedback', `${feedbackSummary.count} | ${feedbackSummary.latestLabel}`], + ['feedback labels', feedbackSummary.labels], + ['useful / misleading / actionable', `${feedbackSummary.usefulCount} / ${feedbackSummary.misleadingCount} / ${feedbackSummary.actionableCount}`], + ], + [ + { + label: 'Useful', + onClick: () => runAction(`/trading/visual-watch/reviews/${review.id}/feedback`, { + label: 'visual_helpful', + note: 'marked from workbench', + created_by: 'local_operator', + payload: { source: 'workbench', feedback_type: 'visual_helpful' }, + }), + }, + { + label: 'Misleading', + onClick: () => runAction(`/trading/visual-watch/reviews/${review.id}/feedback`, { + label: 'visual_misleading', + note: 'marked from workbench', + created_by: 'local_operator', + payload: { source: 'workbench', feedback_type: 'visual_misleading' }, + }), + }, + { + label: 'Actionable', + onClick: () => runAction(`/trading/visual-watch/reviews/${review.id}/feedback`, { + label: 'actionable', + note: 'marked from workbench', + created_by: 'local_operator', + payload: { source: 'workbench', feedback_type: 'actionable' }, + }), + }, + ], + review.visual_notes || '' + ) + ); + }); + visualReviewsPanel.appendChild(grid); +} + function renderOperatorConsole(noteRows, reviewPacket) { clearNode(operatorConsolePanel); const authorInput = createElement('input'); @@ -583,8 +689,43 @@ function renderOperatorConsole(noteRows, reviewPacket) { ['packet executions', String((reviewPacket?.recent_executions || []).length)], ['packet alerts', String((reviewPacket?.recent_alerts || []).length)], ['packet notes', String((reviewPacket?.recent_notes || []).length)], + ['council stance', reviewPacket?.research_council?.payload?.decision?.overall_stance || 'n/a'], + ['council confidence', reviewPacket?.research_council?.payload?.decision?.committee_confidence != null + ? formatNumber(reviewPacket?.research_council?.payload?.decision?.committee_confidence, 2) + : 'n/a'], + ['optimization health', reviewPacket?.research_council_optimization_review?.health_state || 'n/a'], + ['optimization summary', reviewPacket?.research_council_optimization_review?.operator_summary || 'n/a'], + ['optimization next actions', formatList(reviewPacket?.research_council_optimization_review?.recommended_actions || [])], ]) ); + const councilDecision = reviewPacket?.research_council?.payload?.decision || null; + if (councilDecision) { + const bullCase = councilDecision.bull_case || {}; + const bearCase = councilDecision.bear_case || {}; + const newsAnalysis = councilDecision.news_event_analysis || {}; + const visualAnalysis = councilDecision.visual_analysis || {}; + operatorConsolePanel.appendChild( + buildItemCard( + 'Research Council', + councilDecision.overall_stance || 'observe_only', + [ + ['research summary', councilDecision.research_summary || 'n/a'], + ['operator summary', councilDecision.operator_summary || 'n/a'], + ['committee confidence', formatNumber(councilDecision.committee_confidence, 2)], + ['agreement', formatList(councilDecision.agreement_points || [])], + ['disagreement', formatList(councilDecision.disagreement_points || [])], + ['follow-up checks', formatList(councilDecision.follow_up_checks || [])], + ['recommended watch', formatList(councilDecision.recommended_watch_items || [])], + ['operator questions', formatList(councilDecision.operator_questions || [])], + ['execution veto', formatList(councilDecision.execution_veto_signals || [])], + ['news/event', newsAnalysis.summary || 'n/a'], + ['visual', visualAnalysis.summary || 'n/a'], + ], + [], + `Bull: ${bullCase.summary || 'n/a'} | Bear: ${bearCase.summary || 'n/a'}` + ) + ); + } const notes = noteRows?.items || []; const grid = createElement('div', 'cards'); notes.slice(0, 6).forEach((note) => { @@ -987,6 +1128,7 @@ function showErrorPanel(message) { tradeCandidatesPanel, executionTicketsPanel, alertHistoryPanel, + visualReviewsPanel, positionManagementPanel, operatorConsolePanel, postTradeReviewPanel, @@ -1008,6 +1150,8 @@ function renderDashboardData({ runtimeStatus, decisionPacket, decisionAlerts, + visualReviews, + decisionPacketId, trustScore, liveReadiness, launchReadiness, @@ -1035,6 +1179,7 @@ function renderDashboardData({ renderCandidates(tradeCandidates.items || []); renderTickets(executionTickets.items || []); renderAlertHistory(decisionAlerts); + renderVisualReviews(visualReviews, decisionPacketId); renderExecutions(tradingExecutions.items || []); renderOperatorConsole(operatorNotes, reviewPacket); renderPostTradeReview(journalSummary, journalItems.items || [], weeklyReview); @@ -1070,6 +1215,10 @@ async function refreshDashboard() { fetchJson(`/trading/journal?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/research/weekly-review?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), ]); + const decisionPacketId = decisionPacket?.id || reviewPacket?.decision_packet?.id || null; + const visualReviews = decisionPacketId + ? await fetchJson(`/trading/visual-watch/reviews?${queryString({ decision_packet_id: decisionPacketId, limit: 10 })}`).catch(() => ({ items: [] })) + : ({ items: [] }); renderDashboardData({ health, @@ -1079,6 +1228,8 @@ async function refreshDashboard() { runtimeStatus, decisionPacket, decisionAlerts, + visualReviews, + decisionPacketId, trustScore, liveReadiness, launchReadiness, diff --git a/src/web/index.html b/src/web/index.html index 13e12aa..278729c 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -127,6 +127,11 @@
+
+

Visual Reviews

+
+
+

Position Management

diff --git a/tests/unit/test_alert_feedback_service.py b/tests/unit/test_alert_feedback_service.py new file mode 100644 index 0000000..63b7ed2 --- /dev/null +++ b/tests/unit/test_alert_feedback_service.py @@ -0,0 +1,215 @@ +import unittest +from unittest.mock import patch + +from src.services.alerts import AlertDispatcherService +from src.services.alerts.message_template_service import build_alert_message +from src.services.feedback import AlertFeedbackService + + +class StubFeedbackRepository: + def __init__(self) -> None: + self.insert_calls = [] + self.alert_rows = [] + self.visual_rows = [] + + def insert_feedback(self, **kwargs): + self.insert_calls.append(kwargs) + return len(self.insert_calls) + 100 + + def fetch_feedback_for_alert(self, alert_id: int, limit: int = 20): + return [row for row in self.alert_rows if row.get("alert_id") == alert_id][:limit] + + def fetch_feedback_for_visual_review(self, visual_review_id: int, limit: int = 20): + return [row for row in self.visual_rows if row.get("visual_review_id") == visual_review_id][:limit] + + +class StubDecisionRepository: + def __init__(self) -> None: + self.alerts = {} + self.packets = {} + self.status_updates = [] + + def fetch_decision_alert_by_id(self, alert_id: int): + return self.alerts.get(alert_id) + + def fetch_decision_packet_by_id(self, packet_id: int): + return self.packets.get(packet_id) + + def update_decision_alert_status(self, **kwargs): + self.status_updates.append(kwargs) + + +class StubVisualRepository: + def __init__(self) -> None: + self.reviews = {} + + def fetch_visual_review_by_id(self, visual_review_id: int): + return self.reviews.get(visual_review_id) + + +class StubDeliveryRepository: + def __init__(self) -> None: + self.delivery_calls = [] + + def fetch_latest_delivery(self, **kwargs): + return None + + def fetch_latest_delivery_by_dedupe_scope(self, **kwargs): + return None + + def insert_delivery(self, **kwargs): + self.delivery_calls.append(kwargs) + return len(self.delivery_calls) + 200 + + +class StubVisualWatchService: + def fetch_reviews_for_packet(self, *, decision_packet_id: int, limit: int = 1): + return [ + { + "id": 33, + "watch_run_id": 44, + "visual_state": "confirm", + "visual_confidence": 0.86, + "visual_notes": "structure aligns", + "snapshot_source_name": "auto_visual_watch_capture", + "snapshot_image_path": "captures/watch-44.html", + "snapshot_payload": {"artifact_path": "captures/watch-44.json"}, + } + ] + + +class StubFeedbackService: + def list_visual_review_feedback(self, visual_review_id: int, limit: int = 10): + return [{"visual_review_id": visual_review_id, "label": "visual_helpful", "created_ts": "2026-05-11T00:00:00+00:00"}] + + def summarize_feedback(self, rows): + return { + "count": len(rows), + "labels": {"visual_helpful": len(rows)}, + "latest_label": "visual_helpful" if rows else None, + "useful_count": len(rows), + "misleading_count": 0, + "actionable_count": 0, + } + + +class AlertFeedbackServiceTests(unittest.TestCase): + def test_records_alert_feedback_with_allowed_label(self) -> None: + repository = StubFeedbackRepository() + decision_repository = StubDecisionRepository() + decision_repository.alerts[10] = {"id": 10, "status": "queued"} + service = AlertFeedbackService( + repository=repository, + decision_repository=decision_repository, + visual_repository=StubVisualRepository(), + ) + + result = service.record_alert_feedback(alert_id=10, label="useful", note="timely", created_by="operator") + + self.assertEqual(result["status"], "created") + self.assertEqual(result["feedback_id"], 101) + self.assertEqual(repository.insert_calls[0]["subject_type"], "alert") + self.assertEqual(repository.insert_calls[0]["alert_id"], 10) + self.assertEqual(repository.insert_calls[0]["label"], "useful") + + def test_records_visual_review_feedback(self) -> None: + repository = StubFeedbackRepository() + visual_repository = StubVisualRepository() + visual_repository.reviews[22] = {"id": 22, "visual_state": "confirm"} + service = AlertFeedbackService( + repository=repository, + decision_repository=StubDecisionRepository(), + visual_repository=visual_repository, + ) + + result = service.record_visual_review_feedback(visual_review_id=22, label="actionable") + + self.assertEqual(result["status"], "created") + self.assertEqual(repository.insert_calls[0]["subject_type"], "visual_review") + self.assertEqual(repository.insert_calls[0]["visual_review_id"], 22) + self.assertEqual(repository.insert_calls[0]["label"], "actionable") + + def test_rejects_unknown_label(self) -> None: + decision_repository = StubDecisionRepository() + decision_repository.alerts[10] = {"id": 10, "status": "queued"} + service = AlertFeedbackService( + repository=StubFeedbackRepository(), + decision_repository=decision_repository, + visual_repository=StubVisualRepository(), + ) + + with self.assertRaisesRegex(ValueError, "invalid_feedback_label"): + service.record_alert_feedback(alert_id=10, label="rewrite_gate") + + def test_summarizes_visual_feedback(self) -> None: + service = AlertFeedbackService( + repository=StubFeedbackRepository(), + decision_repository=StubDecisionRepository(), + visual_repository=StubVisualRepository(), + ) + + summary = service.summarize_feedback( + [ + {"label": "visual_helpful", "created_ts": "2026-05-11T00:00:00+00:00"}, + {"label": "visual_misleading", "created_ts": "2026-05-11T00:01:00+00:00"}, + {"label": "actionable", "created_ts": "2026-05-11T00:02:00+00:00"}, + ] + ) + + self.assertEqual(summary["count"], 3) + self.assertEqual(summary["useful_count"], 1) + self.assertEqual(summary["misleading_count"], 1) + self.assertEqual(summary["actionable_count"], 1) + self.assertEqual(summary["latest_label"], "actionable") + + +class AlertVisualDispatchTests(unittest.TestCase): + def test_alert_message_includes_visual_context(self) -> None: + message = build_alert_message( + alert={"alert_type": "candidate_actionable", "payload": {"action_recommendation": "confirm_ticket", "status": "actionable"}}, + packet={"payload": {"symbol": "BTC-USDT"}}, + visual={ + "visual_state": "confirm", + "visual_confidence": 0.86, + "snapshot_image_path": "captures/watch-44.html", + "feedback_summary": {"count": 1, "latest_label": "visual_helpful"}, + "visual_notes": "structure aligns", + }, + ) + + self.assertIn("visual=confirm", message) + self.assertIn("snapshot=captures/watch-44.html", message) + self.assertIn("feedback=1:visual_helpful", message) + + def test_dispatcher_attaches_visual_context_to_delivery_payload(self) -> None: + decision_repository = StubDecisionRepository() + decision_repository.alerts[9] = { + "id": 9, + "decision_packet_id": 55, + "alert_type": "candidate_actionable", + "status": "queued", + "payload": {"action_recommendation": "confirm_ticket", "status": "actionable"}, + } + decision_repository.packets[55] = {"id": 55, "payload": {"symbol": "BTC-USDT"}} + delivery_repository = StubDeliveryRepository() + dispatcher = AlertDispatcherService( + decision_repository=decision_repository, + delivery_repository=delivery_repository, + visual_watch_service=StubVisualWatchService(), + feedback_service=StubFeedbackService(), + ) + + with patch("src.services.alerts.channel_router_service.settings.alert_default_channel", "workbench"), patch( + "src.services.alerts.channel_router_service.settings.alert_webhook_url", + "", + ): + result = dispatcher.dispatch_alert(9) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["visual"]["visual_state"], "confirm") + self.assertEqual(delivery_repository.delivery_calls[0]["payload"]["visual"]["visual_state"], "confirm") + self.assertIn("visual=confirm", delivery_repository.delivery_calls[0]["payload"]["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_analysis_decision_monitor.py b/tests/unit/test_analysis_decision_monitor.py new file mode 100644 index 0000000..77a36e9 --- /dev/null +++ b/tests/unit/test_analysis_decision_monitor.py @@ -0,0 +1,123 @@ +import contextlib +import io +import json +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_analysis_decision_monitor + + +class AnalysisDecisionMonitorTests(unittest.TestCase): + def test_evaluate_passes_analysis_decision_only_state_without_real_fills(self) -> None: + payload = run_analysis_decision_monitor.evaluate_analysis_decision_monitor_payloads( + payloads=_healthy_payloads(), + base_url="http://example.test", + instrument_id=1, + timeframe="1m", + ) + + self.assertTrue(payload["passed"]) + self.assertEqual(payload["blockers"], []) + self.assertEqual(payload["observed"]["broker_fills"], 0) + self.assertFalse(payload["observed"]["real_trade_sample_gate_required"]) + self.assertEqual(payload["observed"]["real_trade_sample_gate_status"], "deferred_analysis_decision_only") + + def test_evaluate_blocks_when_real_fill_gate_returns_to_current_actions(self) -> None: + payloads = _healthy_payloads() + payloads["launch_readiness"] = { + **payloads["launch_readiness"], + "real_trade_sample_gate_required": True, + "real_trade_sample_gate_status": "waiting_for_real_fill_sample", + "warning_reasons": ["real_trade_sample_verified"], + "next_actions": ["wait_for_and_verify_real_fill_sample"], + } + + payload = run_analysis_decision_monitor.evaluate_analysis_decision_monitor_payloads( + payloads=payloads, + base_url="http://example.test", + instrument_id=1, + timeframe="1m", + ) + + self.assertFalse(payload["passed"]) + self.assertIn("real_trade_sample_gate_deferred", payload["blockers"]) + self.assertIn("real_trade_sample_not_current_action", payload["blockers"]) + + def test_main_outputs_json_from_monitor_payload(self) -> None: + monitor_payload = { + "passed": True, + "generated_ts": "2026-05-11T00:00:00+00:00", + "blockers": [], + "warnings": [], + } + output = io.StringIO() + + with patch("scripts.run_analysis_decision_monitor.run_monitor", return_value=monitor_payload), contextlib.redirect_stdout(output): + exit_code = run_analysis_decision_monitor.main(env={"ANALYSIS_DECISION_MONITOR_OUTPUT_JSON": "1"}) + + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(output.getvalue())["passed"], True) + + +def _healthy_payloads() -> dict: + return { + "health": {"app": "ai-ict", "database_configured": True}, + "launch_readiness": { + "analysis_only_ready": True, + "restricted_decision_support_ready": True, + "decision_support_ready": False, + "operator_soft_rollout_ready": True, + "operator_soft_rollout_mode": "restricted_decision_support", + "real_trade_sample_gate_required": False, + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "blocking_reasons": [], + "warning_reasons": ["real_live_path_ready"], + "next_actions": ["start_restricted_decision_support_soft_rollout", "keep_real_live_disabled_until_adapter_ready"], + "market_state": { + "stream_state": {"code": "market_stream_connected"}, + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + }, + }, + "broker_state": {"status": "broker_state_ok", "fills": [], "positions": [], "orders": [], "error": None}, + "runtime_status": { + "runtime_health": {"level": "ok"}, + "current_blockers": [], + "current_warnings": [], + "degraded_mode_state": {"is_degraded": False}, + "latest_market_state_by_symbol": { + "BTC-USDT": { + "stream_state": {"code": "market_stream_connected"}, + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + }, + }, + "decision_packet": { + "status": "monitoring", + "action_recommendation": "restricted_decision_support", + "operator_mode": "observe_only", + "trust_classification": "blacklist", + "execution_allowed": None, + "why_allowed": ["restricted_decision_support_allowed", "execution_assist_not_allowed"], + "why_blocked": ["trust_model_decision_support_restricted"], + }, + "review_packet": { + "decision_packet": {"status": "monitoring"}, + "trust_score": {"restricted_decision_support_list": ["SWEEP_MSS_FVG"]}, + "research_council_summary": {"overall_stance": "observe_only"}, + }, + "p6_governance_tick": { + "status": "no_op_healthy", + "effective_health_state": "healthy", + "requires_cycle_run": False, + "requires_bounded_slice": False, + }, + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index 9616137..0fd8b7f 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -116,6 +116,135 @@ class ApiTests(unittest.TestCase): dependencies={"trade_plan_candidates": lambda instrument_id, limit: []}, ) + def test_route_request_visual_snapshot_create(self) -> None: + with patch("src.api.server.create_visual_snapshot", return_value={"snapshot_id": 12, "status": "captured", "watch_run_id": 9}): + status, payload = route_request( + "/trading/visual-watch/snapshots", + {}, + method="POST", + body={"watch_run_id": 9, "image_path": "captures/btc.png", "source_name": "capture_bot"}, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["snapshot_id"], 12) + self.assertEqual(payload["watch_run_id"], 9) + + def test_route_request_visual_snapshot_capture(self) -> None: + status, payload = route_request( + "/trading/visual-watch/capture", + {}, + method="POST", + body={"watch_run_id": 9, "source_name": "capture_bot"}, + dependencies={ + "trading_visual_capture_snapshot": lambda **kwargs: { + "status": "captured", + "watch_run_id": kwargs["watch_run_id"], + "snapshot_id": 21, + "image_path": "captures/watch-9.html", + "artifact_path": "captures/watch-9.json", + } + }, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["snapshot_id"], 21) + self.assertEqual(payload["image_path"], "captures/watch-9.html") + + def test_route_request_visual_review_create(self) -> None: + with patch("src.api.server.create_visual_review", return_value={"review_id": 14, "status": "reviewed", "watch_run_id": 9, "visual_state": "aligned"}): + status, payload = route_request( + "/trading/visual-watch/reviews", + {}, + method="POST", + body={"watch_run_id": 9, "snapshot_id": 12, "visual_state": "aligned", "visual_confidence": 0.9}, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["review_id"], 14) + self.assertEqual(payload["visual_state"], "aligned") + + def test_route_request_visual_review_run(self) -> None: + status, payload = route_request( + "/trading/visual-watch/review", + {}, + method="POST", + body={"watch_run_id": 9, "visual_state": "caution"}, + dependencies={ + "trading_visual_review_run": lambda **kwargs: { + "status": "reviewed", + "watch_run_id": kwargs["watch_run_id"], + "visual_state": kwargs["visual_state"], + } + }, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["watch_run_id"], 9) + self.assertEqual(payload["visual_state"], "caution") + + def test_route_request_visual_review_pending(self) -> None: + status, payload = route_request( + "/trading/visual-watch/review-pending", + {}, + method="POST", + body={"limit": 1, "instrument_id": 1}, + dependencies={ + "trading_visual_review_pending": lambda **kwargs: { + "status": "completed", + "attempted": 1, + "reviewed": 1, + "items": [{"watch_run_id": 11, "visual_state": "confirm"}], + **kwargs, + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["reviewed"], 1) + self.assertEqual(payload["instrument_id"], 1) + + def test_route_request_alert_feedback_create(self) -> None: + status, payload = route_request( + "/trading/alerts/7/feedback", + {}, + method="POST", + body={"label": "useful", "note": "timely", "created_by": "local_operator"}, + dependencies={ + "trading_alert_feedback_create": lambda **kwargs: { + "status": "created", + "feedback_id": 91, + "alert_id": kwargs["alert_id"], + "label": kwargs["label"], + } + }, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["feedback_id"], 91) + self.assertEqual(payload["alert_id"], 7) + self.assertEqual(payload["label"], "useful") + + def test_route_request_visual_review_feedback_create(self) -> None: + status, payload = route_request( + "/trading/visual-watch/reviews/14/feedback", + {}, + method="POST", + body={"label": "visual_helpful", "note": "helped confirm"}, + dependencies={ + "trading_visual_review_feedback_create": lambda **kwargs: { + "status": "created", + "feedback_id": 92, + "visual_review_id": kwargs["visual_review_id"], + "label": kwargs["label"], + } + }, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["feedback_id"], 92) + self.assertEqual(payload["visual_review_id"], 14) + self.assertEqual(payload["label"], "visual_helpful") + class FakeSessionGenerator: timezone = __import__("datetime").timezone.utc diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 9d436e2..11c671a 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -123,6 +123,98 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(packet_status, 200) self.assertEqual(packet_payload["instrument_id"], 1) + def test_routes_trading_research_council_run_and_queries(self) -> None: + status_run, payload_run = route_request( + "/trading/research-council/run", + {}, + method="POST", + body={"instrument_id": 1, "timeframe": "5m", "model_code": "SWEEP_MSS_FVG"}, + dependencies={"trading_research_council_run": lambda **kwargs: {"id": 41, **kwargs}}, + ) + status_latest, payload_latest = route_request( + "/trading/research-council/latest", + {"instrument_id": ["1"]}, + dependencies={"trading_research_council_latest": lambda **kwargs: {"id": 41, "instrument_id": kwargs["instrument_id"]}}, + ) + status_runs, payload_runs = route_request( + "/trading/research-council/runs", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={"trading_research_council_runs": lambda **kwargs: [{"id": 41}, {"id": 40}]}, + ) + status_cycle, payload_cycle = route_request( + "/trading/research-council/optimization-cycle", + {}, + method="POST", + body={"instrument_id": 1, "timeframe": "5m", "eval_limit": 5, "lesson_limit": 10}, + dependencies={ + "trading_research_council_optimization_run": lambda **kwargs: { + "id": 51, + "status": "completed", + **kwargs, + } + }, + ) + status_latest_cycle, payload_latest_cycle = route_request( + "/trading/research-council/optimization-cycle/latest", + {"instrument_id": ["1"]}, + dependencies={"trading_research_council_optimization_latest": lambda **kwargs: {"id": 51, "instrument_id": kwargs["instrument_id"]}}, + ) + status_cycles, payload_cycles = route_request( + "/trading/research-council/optimization-cycles", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={"trading_research_council_optimization_cycles": lambda **kwargs: [{"id": 51}]}, + ) + status_review, payload_review = route_request( + "/trading/research-council/optimization-review", + {"instrument_id": ["1"], "limit": ["2"], "stale_after_hours": ["12"]}, + dependencies={ + "trading_research_council_optimization_review": lambda **kwargs: { + "instrument_id": kwargs["instrument_id"], + "limit": kwargs["limit"], + "stale_after_hours": kwargs["stale_after_hours"], + "health_state": "healthy", + } + }, + ) + status_tick, payload_tick = route_request( + "/trading/research-council/governance-tick", + {}, + method="POST", + body={"instrument_id": 1, "limit": 2, "stale_after_hours": 12, "force_cycle": False}, + dependencies={ + "trading_research_council_governance_tick": lambda **kwargs: { + "instrument_id": kwargs["instrument_id"], + "limit": kwargs["limit"], + "stale_after_hours": kwargs["stale_after_hours"], + "force_cycle": kwargs["force_cycle"], + "status": "no_op_healthy", + } + }, + ) + + self.assertEqual(status_run, 200) + self.assertEqual(payload_run["id"], 41) + self.assertEqual(payload_run["timeframe"], "5m") + self.assertEqual(status_latest, 200) + self.assertEqual(payload_latest["instrument_id"], 1) + self.assertEqual(status_runs, 200) + self.assertEqual(len(payload_runs["items"]), 2) + self.assertEqual(status_cycle, 200) + self.assertEqual(payload_cycle["id"], 51) + self.assertEqual(payload_cycle["eval_limit"], 5) + self.assertEqual(payload_cycle["lesson_limit"], 10) + self.assertEqual(status_latest_cycle, 200) + self.assertEqual(payload_latest_cycle["instrument_id"], 1) + self.assertEqual(status_cycles, 200) + self.assertEqual(payload_cycles["items"], [{"id": 51}]) + self.assertEqual(status_review, 200) + self.assertEqual(payload_review["health_state"], "healthy") + self.assertEqual(payload_review["limit"], 2) + self.assertEqual(payload_review["stale_after_hours"], 12.0) + self.assertEqual(status_tick, 200) + self.assertEqual(payload_tick["status"], "no_op_healthy") + self.assertFalse(payload_tick["force_cycle"]) + def test_routes_trading_live_readiness(self) -> None: status, payload = route_request( "/trading/live-readiness", diff --git a/tests/unit/test_backtest_replay.py b/tests/unit/test_backtest_replay.py index 2d049ab..8d70d91 100644 --- a/tests/unit/test_backtest_replay.py +++ b/tests/unit/test_backtest_replay.py @@ -143,6 +143,15 @@ class HistoricalReplayTests(unittest.TestCase): self.assertEqual(len(candles), 5) self.assertEqual([(s.kind, s.source_index, s.confirmed_at_index) for s in swings], [("swing_high", 1, 2), ("swing_low", 3, 4)]) + def test_p5_replay_fixture_has_confirmed_swings_with_default_window(self) -> None: + fixture = Path(__file__).resolve().parents[2] / "data" / "replay" / "research_council_confirmed_swings_v1.jsonl" + + candles = load_replay_candles(fixture) + swings = detect_confirmed_swings_for_replay(candles) + + self.assertEqual(len(candles), 9) + self.assertEqual([(s.kind, s.source_index, s.confirmed_at_index) for s in swings], [("swing_high", 2, 4), ("swing_low", 6, 8)]) + def test_load_replay_candles_rejects_unsupported_format(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "candles.txt" diff --git a/tests/unit/test_broker_read_model_service.py b/tests/unit/test_broker_read_model_service.py index 24286f2..dc0b67f 100644 --- a/tests/unit/test_broker_read_model_service.py +++ b/tests/unit/test_broker_read_model_service.py @@ -68,11 +68,19 @@ class BrokerReadModelServiceTests(unittest.TestCase): self.assertAlmostEqual(state.daily_pnl_pct, -0.05) def test_fetch_snapshot_returns_unavailable_when_credentials_or_request_fail(self) -> None: - service = BrokerReadModelService() - snapshot = service.fetch_snapshot(instrument_id=1) + from unittest.mock import patch + + with patch("src.services.broker.broker_read_model_service.settings.okx_api_key", ""), patch( + "src.services.broker.broker_read_model_service.settings.okx_api_secret", "" + ), patch( + "src.services.broker.broker_read_model_service.settings.okx_api_passphrase", "" + ): + service = BrokerReadModelService() + snapshot = service.fetch_snapshot(instrument_id=1) self.assertEqual(snapshot.status, BROKER_STATE_UNAVAILABLE) self.assertTrue(snapshot.error) + self.assertIn("okx_read_credentials_missing", snapshot.error) def test_fetch_snapshot_surfaces_http_status_payload_message(self) -> None: from unittest.mock import patch diff --git a/tests/unit/test_decision_packet_service.py b/tests/unit/test_decision_packet_service.py index 0ed2ea8..48b2a56 100644 --- a/tests/unit/test_decision_packet_service.py +++ b/tests/unit/test_decision_packet_service.py @@ -4,13 +4,30 @@ import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") +from unittest.mock import patch + from src.services.decision.decision_packet_service import ( _select_candidate, build_alert_candidates, build_decision_packet_from_rows, + DecisionPacketService, ) +class StubVisualSnapshotRepository: + def __init__(self): + self.snapshot_calls = [] + self.review_calls = [] + + def insert_snapshot(self, **kwargs): + self.snapshot_calls.append(kwargs) + return 333 + + def insert_visual_review(self, **kwargs): + self.review_calls.append(kwargs) + return 444 + + class DecisionPacketServiceTests(unittest.TestCase): def test_build_packet_recommends_confirm_ticket_when_risk_passes(self) -> None: packet = build_decision_packet_from_rows( @@ -123,6 +140,126 @@ class DecisionPacketServiceTests(unittest.TestCase): self.assertEqual(selected["id"], 2) + def test_build_persist_and_alert_attaches_visual_watch_reservation(self) -> None: + packet = build_decision_packet_from_rows( + instrument_id=1, + observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc), + plan={"id": 10, "status": "active"}, + candidate={ + "id": 20, + "trading_plan_id": 10, + "status": "executable", + "missing_confirmations": [], + "model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE", + "evidence": {"symbol": "BTC-USDT"}, + "trigger_timeframe": "5m", + }, + signal={"id": 30, "trigger_timeframe": "5m"}, + ticket={"id": 40, "status": "awaiting_confirmation"}, + risk_result={"allowed": True, "status": "allowed", "reasons": []}, + trust_policy={ + "classification": "allowlist", + "analysis_only_allowed": True, + "restricted_decision_support_allowed": True, + "decision_support_allowed": True, + "execution_allowed": True, + "operator_mode": "execution_assist", + "blocking_reasons": [], + }, + ) + + class StubDecisionRepository: + def __init__(self): + self.alert_payloads = [] + + def insert_decision_packet(self, **kwargs): + self.packet_payload = kwargs["payload"] + return 901 + + def insert_decision_alert(self, **kwargs): + self.alert_payloads.append(kwargs) + return 902 + + class StubVisualWatchService: + def __init__(self): + self.requests = [] + + def reserve_watch_request(self, request): + self.requests.append(dict(request)) + return { + "status": "reserved", + "watch_run_id": 777, + "trigger_type": request["trigger_type"], + "reserved_ts": "2026-05-08T01:00:00+00:00", + } + + repository = StubDecisionRepository() + visual_watch_service = StubVisualWatchService() + service = DecisionPacketService(decision_repository=repository) + + with patch.object(service, "build_packet", return_value=packet), patch( + "src.services.decision.decision_packet_service.VisualWatchService", + return_value=visual_watch_service, + ): + result = service.build_persist_and_alert(instrument_id=1) + + self.assertEqual(result["id"], 901) + self.assertEqual(len(result["alerts"]), 1) + alert_payload = result["alerts"][0]["payload"] + self.assertEqual(alert_payload["visual_watch_reservation"]["watch_run_id"], 777) + self.assertEqual(visual_watch_service.requests[0]["decision_packet_id"], 901) + self.assertEqual(visual_watch_service.requests[0]["signal_id"], 30) + self.assertEqual(repository.alert_payloads[0]["payload"]["visual_watch_reservation"]["status"], "reserved") + + def test_record_snapshot_returns_snapshot_payload(self) -> None: + from src.services.visual_watch.visual_watch_service import VisualWatchService + + repository = StubVisualSnapshotRepository() + service = VisualWatchService(repository=repository) + + result = service.record_snapshot( + { + "watch_run_id": 44, + "image_path": "captures/btc-1.png", + "source_name": "capture_bot", + "captured_ts": "2026-05-10T12:30:00+00:00", + "status": "captured", + "payload": {"screen": "main"}, + } + ) + + self.assertEqual(result["snapshot_id"], 333) + self.assertEqual(result["watch_run_id"], 44) + self.assertEqual(result["image_path"], "captures/btc-1.png") + self.assertEqual(repository.snapshot_calls[0]["source_name"], "capture_bot") + self.assertEqual(repository.snapshot_calls[0]["status"], "captured") + + def test_record_visual_review_returns_review_payload(self) -> None: + from src.services.visual_watch.visual_watch_service import VisualWatchService + + repository = StubVisualSnapshotRepository() + service = VisualWatchService(repository=repository) + + result = service.record_visual_review( + { + "watch_run_id": 44, + "snapshot_id": 333, + "visual_state": "aligned", + "visual_confidence": 0.91, + "visual_notes": "structure intact", + "ui_anomalies": ["minor_overlay"], + "reviewed_ts": "2026-05-10T12:35:00+00:00", + "payload": {"operator": "vision_bot"}, + } + ) + + self.assertEqual(result["review_id"], 444) + self.assertEqual(result["watch_run_id"], 44) + self.assertEqual(result["snapshot_id"], 333) + self.assertEqual(result["visual_state"], "aligned") + self.assertEqual(repository.review_calls[0]["visual_notes"], "structure intact") + self.assertEqual(repository.review_calls[0]["ui_anomalies"], ["minor_overlay"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_deploy_with_password.py b/tests/unit/test_deploy_with_password.py new file mode 100644 index 0000000..bfc115a --- /dev/null +++ b/tests/unit/test_deploy_with_password.py @@ -0,0 +1,33 @@ +import unittest +from pathlib import Path + +from scripts import deploy_with_password + + +class DeployWithPasswordTests(unittest.TestCase): + def test_deploy_payload_includes_automation_anchor_files(self) -> None: + self.assertIn(".dockerignore", deploy_with_password.DEPLOY_PATHS) + self.assertIn("docs", deploy_with_password.DEPLOY_PATHS) + self.assertIn("SESSION.md", deploy_with_password.DEPLOY_PATHS) + self.assertIn("TASKS.md", deploy_with_password.DEPLOY_PATHS) + + def test_docker_image_includes_automation_anchor_files(self) -> None: + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + + self.assertIn("COPY docs /app/docs", dockerfile) + self.assertIn("COPY SESSION.md /app/SESSION.md", dockerfile) + self.assertIn("COPY TASKS.md /app/TASKS.md", dockerfile) + + def test_dockerignore_does_not_exclude_automation_docs(self) -> None: + dockerignore = Path(".dockerignore").read_text(encoding="utf-8").splitlines() + + self.assertNotIn("docs/", dockerignore) + + def test_compose_persists_automation_monitor_state(self) -> None: + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + + self.assertIn("./.claude:/app/.claude", compose) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index bb739a6..9cf49b7 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -18,10 +18,27 @@ TRAINING_HTML = ROOT / "src" / "web" / "training.html" TRAINING_JS = ROOT / "src" / "web" / "training.js" OLD_TRAINING_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md" OPTIONAL_DORMANT_DOCS = { + "AI_ICT_TradingAgents_P0自动任务清单_v1.md", + "AI_ICT_TradingAgents_P1自动任务清单_v1.md", + "AI_ICT_TradingAgents_P2自动任务清单_v1.md", + "AI_ICT_TradingAgents_P3自动任务清单_v1.md", + "AI_ICT_TradingAgents_P4自动任务清单_v1.md", + "AI_ICT_TradingAgents_P5自动任务清单_v1.md", + "AI_ICT_TradingAgents_P6自动任务清单_v1.md", + "AI_ICT_TradingAgents_P0-P6合理性审查与优化结论_v1.md", + "AI_ICT_TradingAgents融合实施方案_v1.md", + "AI_ICT_TradingAgents融合实施方案_最终版.md", "AI_ICT_盯盘告警开发任务清单_v1.md", "AI_ICT_盯盘告警技术设计稿_v1.md", "AI_ICT_盯盘告警架构图_v1.md", "AI_ICT_下一阶段开发计划_v1.md", + "AI_ICT_下一阶段开发任务清单_v1.md", + "AI_ICT_自动化流程使用教程_v1.md", + "AI_ICT_外部repair_agent接入说明_v1.md", + "AI_ICT_repair_bridge_protocol_v1.md", + "AI_ICT_research_council脚手架与数据表草案_v1.md", + "AI_ICT_方案一系统边界图与模块融合图_v1.md", + "AI_ICT_自动化执行层_P7监控成熟度与晋级路由_v1.md", } diff --git a/tests/unit/test_launch_readiness_service.py b/tests/unit/test_launch_readiness_service.py index a30d075..54374fc 100644 --- a/tests/unit/test_launch_readiness_service.py +++ b/tests/unit/test_launch_readiness_service.py @@ -17,16 +17,48 @@ class LaunchReadinessServiceTests(unittest.TestCase): live_readiness_service=_LiveReadinessService(), ) - with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()): + with ( + patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()), + patch("src.services.readiness.launch_readiness_service.settings.launch_require_real_trade_sample", True), + ): payload = service.build_summary(instrument_id=1, timeframe="1m") self.assertTrue(payload["analysis_only_ready"]) self.assertTrue(payload["assisted_trading_ready"]) + self.assertTrue(payload["operator_soft_rollout_ready"]) + self.assertEqual(payload["operator_soft_rollout_mode"], "execution_assist") + self.assertTrue(payload["real_trade_sample_gate_required"]) + self.assertEqual(payload["real_trade_sample_gate_status"], "waiting_for_real_fill_sample") + self.assertIn("wait_for_and_verify_real_fill_sample", payload["next_actions"]) self.assertFalse(payload["real_trade_loop_ready"]) self.assertIn("real_trade_sample_verified", payload["warning_reasons"]) + self.assertIn("real_trade_sample_verified", payload["external_waiting_items"]) self.assertFalse(payload["real_live_ready"]) - def test_build_summary_keeps_decision_support_false_when_only_blacklist_models_exist(self) -> None: + def test_build_summary_defers_real_trade_sample_gate_for_analysis_decision_phase(self) -> None: + service = LaunchReadinessService( + market_state_service=_MarketStateService(), + broker_read_model_service=_BrokerReadService(), + weekly_review_service=_WeeklyReviewService(), + trust_service=_TrustService(), + live_readiness_service=_LiveReadinessService(), + ) + + with ( + patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()), + patch("src.services.readiness.launch_readiness_service.settings.launch_require_real_trade_sample", False), + ): + payload = service.build_summary(instrument_id=1, timeframe="1m") + + self.assertTrue(payload["analysis_only_ready"]) + self.assertFalse(payload["real_trade_sample_gate_required"]) + self.assertEqual(payload["real_trade_sample_gate_status"], "deferred_analysis_decision_only") + self.assertEqual(payload["real_trade_sample_gate_phase"], "deferred_analysis_decision_only") + self.assertNotIn("real_trade_sample_verified", payload["warning_reasons"]) + self.assertNotIn("real_trade_sample_verified", payload["external_waiting_items"]) + self.assertNotIn("wait_for_and_verify_real_fill_sample", payload["next_actions"]) + + def test_build_summary_exposes_restricted_soft_rollout_when_only_blacklist_models_exist(self) -> None: service = LaunchReadinessService( market_state_service=_MarketStateService(), broker_read_model_service=_BrokerReadService(), @@ -40,7 +72,12 @@ class LaunchReadinessServiceTests(unittest.TestCase): self.assertTrue(payload["analysis_only_ready"]) self.assertFalse(payload["decision_support_ready"]) - self.assertIn("trust_model_available", payload["warning_reasons"]) + self.assertTrue(payload["restricted_decision_support_ready"]) + self.assertTrue(payload["operator_soft_rollout_ready"]) + self.assertEqual(payload["operator_soft_rollout_mode"], "restricted_decision_support") + self.assertIn("start_restricted_decision_support_soft_rollout", payload["next_actions"]) + self.assertIn("execution_assist_remains_disabled", payload["operator_soft_rollout_boundary_notes"]) + self.assertNotIn("trust_model_available", payload["warning_reasons"]) class _MarketState: @@ -86,6 +123,7 @@ class _TrustService: "observe_list": [], "blacklist": [], "analysis_only_list": ["OSOK"], + "restricted_decision_support_list": ["OSOK"], "decision_support_list": ["OSOK"], "execution_candidate_list": ["OSOK"], } @@ -98,6 +136,7 @@ class _BlacklistOnlyTrustService: "observe_list": [], "blacklist": ["SWEEP_MSS_FVG"], "analysis_only_list": ["SWEEP_MSS_FVG"], + "restricted_decision_support_list": ["SWEEP_MSS_FVG"], "decision_support_list": [], "execution_candidate_list": [], } diff --git a/tests/unit/test_market_truth_runtime.py b/tests/unit/test_market_truth_runtime.py index b3ba6a2..adb8bff 100644 --- a/tests/unit/test_market_truth_runtime.py +++ b/tests/unit/test_market_truth_runtime.py @@ -23,6 +23,11 @@ from src.services.market_state.news_event_provider import NEWS_CODE_PROVIDER_CAC class MarketDataStreamServiceTests(unittest.TestCase): + def test_stream_service_defaults_to_business_websocket(self) -> None: + service = MarketDataStreamService() + + self.assertEqual(service.url, "wss://ws.okx.com:8443/ws/v5/business") + def test_stream_service_parses_ticker_and_candle_messages(self) -> None: service = MarketDataStreamService(url="wss://example.test") service.subscribe( @@ -32,16 +37,7 @@ class MarketDataStreamServiceTests(unittest.TestCase): timeframe="1m", ) - service._on_message(None, json.dumps({"event": "subscribe", "arg": {"channel": "tickers", "instId": "BTC-USDT-SWAP"}})) - service._on_message( - None, - json.dumps( - { - "arg": {"channel": "tickers", "instId": "BTC-USDT-SWAP"}, - "data": [{"last": "65000.5", "ts": "1778032800000"}], - } - ), - ) + service._on_message(None, json.dumps({"event": "subscribe", "arg": {"channel": "candle1m", "instId": "BTC-USDT-SWAP"}})) service._on_message( None, json.dumps( @@ -51,6 +47,8 @@ class MarketDataStreamServiceTests(unittest.TestCase): } ), ) + self.assertEqual(service.iter_subscriptions()[0].candle_channel, "candle1m") + self.assertEqual(service.iter_subscriptions()[0].active_channels, ["candle1m"]) snapshot = service.get_snapshot(instrument_id=1, timeframe="1m") @@ -62,6 +60,29 @@ class MarketDataStreamServiceTests(unittest.TestCase): self.assertEqual(snapshot["latest_candle_close"], 65000.5) self.assertIsNotNone(snapshot["last_confirmed_candle_close_ts"]) + service_no_ticker = MarketDataStreamService(url="wss://example.test") + service_no_ticker.subscribe( + instrument_id=1, + symbol="BTC-USDT", + inst_id="BTC-USDT-SWAP", + timeframe="1m", + ) + service_no_ticker._on_message(None, json.dumps({"event": "subscribe", "arg": {"channel": "candle1m", "instId": "BTC-USDT-SWAP"}})) + service_no_ticker._on_message( + None, + json.dumps( + { + "arg": {"channel": "candle1m", "instId": "BTC-USDT-SWAP"}, + "data": [["1778032800000", "64990.1", "65010.0", "64980.0", "65000.5", "12.3", "0", "0", "1"]], + } + ), + ) + snapshot_no_ticker = service_no_ticker.get_snapshot(instrument_id=1, timeframe="1m") + self.assertEqual(snapshot_no_ticker["code"], STREAM_CODE_CONNECTED) + self.assertEqual(snapshot_no_ticker["latest_price"], 65000.5) + self.assertTrue(snapshot_no_ticker["subscribed"]) + self.assertTrue(snapshot_no_ticker["connected"]) + def test_build_data_gap_state_detects_missing_candle_interval(self) -> None: now = datetime(2026, 5, 7, 12, 5, tzinfo=timezone.utc) gap_state = build_data_gap_state( diff --git a/tests/unit/test_monitoring_alert_rollout.py b/tests/unit/test_monitoring_alert_rollout.py new file mode 100644 index 0000000..0de4ffc --- /dev/null +++ b/tests/unit/test_monitoring_alert_rollout.py @@ -0,0 +1,163 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_monitoring_alert_rollout + + +class MonitoringAlertRolloutTests(unittest.TestCase): + def test_seed_rollout_env_uses_env_example_database_url_when_missing(self) -> None: + original_env_local_path = run_monitoring_alert_rollout.ENV_LOCAL_PATH + original_env_path = run_monitoring_alert_rollout.ENV_PATH + original_env_example_path = run_monitoring_alert_rollout.ENV_EXAMPLE_PATH + try: + run_monitoring_alert_rollout.ENV_LOCAL_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.local.does-not-exist") + run_monitoring_alert_rollout.ENV_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.does-not-exist") + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.example") + seeded = run_monitoring_alert_rollout._seed_rollout_env({}) + finally: + run_monitoring_alert_rollout.ENV_LOCAL_PATH = original_env_local_path + run_monitoring_alert_rollout.ENV_PATH = original_env_path + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = original_env_example_path + + self.assertIn("DATABASE_URL", seeded) + self.assertIn("postgresql+psycopg://postgres:postgres@localhost:5432/ai_ict", seeded["DATABASE_URL"]) + + def test_seed_rollout_env_prefers_env_local_database_url(self) -> None: + original_env_local_path = run_monitoring_alert_rollout.ENV_LOCAL_PATH + original_env_path = run_monitoring_alert_rollout.ENV_PATH + original_env_example_path = run_monitoring_alert_rollout.ENV_EXAMPLE_PATH + try: + run_monitoring_alert_rollout.ENV_LOCAL_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.example") + run_monitoring_alert_rollout.ENV_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.does-not-exist") + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.does-not-exist") + seeded = run_monitoring_alert_rollout._seed_rollout_env({}) + finally: + run_monitoring_alert_rollout.ENV_LOCAL_PATH = original_env_local_path + run_monitoring_alert_rollout.ENV_PATH = original_env_path + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = original_env_example_path + + self.assertIn("DATABASE_URL", seeded) + self.assertIn("postgresql+psycopg://postgres:postgres@localhost:5432/ai_ict", seeded["DATABASE_URL"]) + + def test_seed_rollout_env_prefers_local_components_over_env_example_url(self) -> None: + original_env_local_path = run_monitoring_alert_rollout.ENV_LOCAL_PATH + original_env_path = run_monitoring_alert_rollout.ENV_PATH + original_env_example_path = run_monitoring_alert_rollout.ENV_EXAMPLE_PATH + try: + local_path = run_monitoring_alert_rollout.Path("D:/ai-exchange/.tmp_rollout_local_env") + local_path.write_text( + "DATABASE_HOST=192.168.3.90\n" + "DATABASE_PORT=5432\n" + "DATABASE_NAME=ai_ict\n" + "DATABASE_USER=yuqei\n" + "DATABASE_PASSWORD=abc123\n", + encoding="utf-8", + ) + run_monitoring_alert_rollout.ENV_LOCAL_PATH = local_path + run_monitoring_alert_rollout.ENV_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.does-not-exist") + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.example") + seeded = run_monitoring_alert_rollout._seed_rollout_env({}) + finally: + if 'local_path' in locals() and local_path.exists(): + local_path.unlink() + run_monitoring_alert_rollout.ENV_LOCAL_PATH = original_env_local_path + run_monitoring_alert_rollout.ENV_PATH = original_env_path + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = original_env_example_path + + self.assertEqual( + seeded["DATABASE_URL"], + "postgresql+psycopg://yuqei:abc123@192.168.3.90:5432/ai_ict", + ) + + def test_seed_rollout_env_prefers_monitoring_alert_overrides(self) -> None: + seeded = run_monitoring_alert_rollout._seed_rollout_env( + { + "MONITORING_ALERT_DATABASE_URL": "postgresql+psycopg://remote-user:pw@192.168.3.90:5432/ai_ict", + "MONITORING_ALERT_BROKER_READ_PROXY_URL": "http://192.168.3.73:10808", + "MONITORING_ALERT_OKX_API_KEY": "k", + "MONITORING_ALERT_OKX_API_SECRET": "s", + "MONITORING_ALERT_OKX_API_PASSPHRASE": "p", + } + ) + + self.assertEqual(seeded["DATABASE_URL"], "postgresql+psycopg://remote-user:pw@192.168.3.90:5432/ai_ict") + self.assertEqual(seeded["BROKER_READ_PROXY_URL"], "http://192.168.3.73:10808") + self.assertEqual(seeded["OKX_API_KEY"], "k") + self.assertEqual(seeded["OKX_API_SECRET"], "s") + self.assertEqual(seeded["OKX_API_PASSPHRASE"], "p") + + def test_seed_rollout_env_composes_database_url_from_components(self) -> None: + original_env_local_path = run_monitoring_alert_rollout.ENV_LOCAL_PATH + original_env_path = run_monitoring_alert_rollout.ENV_PATH + original_env_example_path = run_monitoring_alert_rollout.ENV_EXAMPLE_PATH + try: + missing = run_monitoring_alert_rollout.Path("D:/ai-exchange/.env.does-not-exist") + run_monitoring_alert_rollout.ENV_LOCAL_PATH = missing + run_monitoring_alert_rollout.ENV_PATH = missing + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = missing + seeded = run_monitoring_alert_rollout._seed_rollout_env( + { + "DATABASE_URL": "", + "MONITORING_ALERT_DATABASE_HOST": "192.168.3.90", + "MONITORING_ALERT_DATABASE_PORT": "5432", + "MONITORING_ALERT_DATABASE_NAME": "ai_ict", + "MONITORING_ALERT_DATABASE_USER": "yuqei", + "MONITORING_ALERT_DATABASE_PASSWORD": "abc123", + } + ) + finally: + run_monitoring_alert_rollout.ENV_LOCAL_PATH = original_env_local_path + run_monitoring_alert_rollout.ENV_PATH = original_env_path + run_monitoring_alert_rollout.ENV_EXAMPLE_PATH = original_env_example_path + + self.assertEqual( + seeded["DATABASE_URL"], + "postgresql+psycopg://yuqei:abc123@192.168.3.90:5432/ai_ict", + ) + + def test_detect_major_blocker_classifies_missing_database_url_as_runtime_env_missing(self) -> None: + run = { + "stdout": "", + "stderr": "ValidationError: 1 validation error for Settings\ndatabase_url\n Field required [type=missing, input_value={}, input_type=dict]", + } + + blocker = run_monitoring_alert_rollout._detect_major_blocker(run) + + self.assertEqual(blocker, "runtime_env_missing") + + def test_finalize_phase_result_downgrades_runtime_exit_with_blockers_to_needs_work(self) -> None: + phase = {"code": "N4", "tasks": ["N4-1"], "files": ["src/services/runtime/trading_runtime_service.py"], "validation_commands": ["cmd"]} + phase_result = { + "phase": "N4", + "title": "title", + "goal": "goal", + "started_ts": "t1", + "finished_ts": "t2", + "result": "needs_work", + "blocker": None, + "summary": "old", + "tasks": ["N4-1"], + "files": ["src/services/runtime/trading_runtime_service.py"], + "commands": [ + { + "command": "cmd", + "returncode": 2, + "stdout": "Trading runtime: running / stopped\nBlockers: market_stream_degraded", + "stderr": "", + } + ], + "deliverables": [], + "failed_command": "cmd", + } + + result = run_monitoring_alert_rollout._finalize_phase_result(phase=phase, phase_result=phase_result) + + self.assertEqual(result["result"], "needs_work") + self.assertIsNone(result["blocker"]) + self.assertIn("Validation failed for phase N4", result["summary"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 72bc776..17e27fc 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -47,6 +47,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): trust_service=_TrustService(), weekly_review_service=_WeeklyReviewService(), decision_repository=_DecisionRepo(), + research_council_service=_ResearchCouncilService(), + optimization_review_service=_OptimizationReviewService(), ) packet = service.build_packet(instrument_id=1) @@ -55,6 +57,30 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("recent_notes", packet) self.assertIn("weekly_review", packet) self.assertIn("trust_score", packet) + self.assertIn("research_council", packet) + self.assertIn("research_council_optimization_review", packet) + self.assertEqual(packet["research_council"]["id"], 31) + self.assertEqual(packet["research_council_optimization_review"]["health_state"], "healthy") + + def test_review_packet_allows_absent_research_council_without_regression(self) -> None: + service = ReviewPacketService( + note_service=_NoteService(), + decision_service=_DecisionService(), + execution_service=_ExecutionService(), + trust_service=_TrustService(), + weekly_review_service=_WeeklyReviewService(), + decision_repository=_DecisionRepo(), + research_council_service=_EmptyResearchCouncilService(), + optimization_review_service=_OptimizationReviewService(), + ) + + packet = service.build_packet(instrument_id=1) + + self.assertIn("decision_packet", packet) + self.assertIn("recent_notes", packet) + self.assertIn("weekly_review", packet) + self.assertIn("trust_score", packet) + self.assertIsNone(packet["research_council"]) class _NoteRepo: @@ -109,6 +135,25 @@ class _WeeklyReviewService: return {"entry_count": 2} +class _ResearchCouncilService: + def get_latest_run(self, **kwargs): + return {"id": 31, "payload": {"decision": {"overall_stance": "observe_only"}}} + + +class _EmptyResearchCouncilService: + def get_latest_run(self, **kwargs): + return None + + +class _OptimizationReviewService: + def build_review(self, **kwargs): + return { + "present": True, + "health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + } + + class _SessionRepo: def fetch_recent_sessions(self, **kwargs): return [ diff --git a/tests/unit/test_research_council_analysts.py b/tests/unit/test_research_council_analysts.py new file mode 100644 index 0000000..7dfce96 --- /dev/null +++ b/tests/unit/test_research_council_analysts.py @@ -0,0 +1,84 @@ +import unittest + +from src.services.research_council import ( + CouncilCase, + CouncilContextBuilder, + NewsEventAnalyst, + RiskAnalyst, + StructureAnalyst, + VisualAnalyst, + run_council_analyst, +) +from tests.unit.test_research_council_context_builder import ( + _FakeDecisionRepository, + _FakeDecisionService, + _FakeJournalRepository, + _FakeMemoryService, + _FakeMarketStateService, + _FakeVisualWatchService, + _FakeWeeklyReviewService, +) + + +class ResearchCouncilAnalystTests(unittest.TestCase): + def _build_context(self): + builder = CouncilContextBuilder( + market_state_service=_FakeMarketStateService(), + decision_service=_FakeDecisionService(), + decision_repository=_FakeDecisionRepository(), + weekly_review_service=_FakeWeeklyReviewService(), + journal_repository=_FakeJournalRepository(), + visual_watch_service=_FakeVisualWatchService(), + memory_service=_FakeMemoryService(), + ) + return builder.build_context(instrument_id=1, timeframe="1m", model_code="SWEEP_MSS_FVG") + + def test_structure_analyst_consumes_only_council_context(self) -> None: + context = self._build_context() + analyst = StructureAnalyst() + + result = analyst.analyze(context) + + self.assertIsInstance(result, CouncilCase) + self.assertEqual(result.title, "Structure Analysis") + self.assertIn("plan status", result.summary.lower()) + self.assertIn("freshness:market_data_fresh", result.evidence) + self.assertIn("candidate_status:draft", result.evidence) + + def test_risk_analyst_consumes_only_council_context(self) -> None: + context = self._build_context() + analyst = RiskAnalyst() + + result = analyst.analyze(context) + + self.assertIsInstance(result, CouncilCase) + self.assertEqual(result.title, "Risk Analysis") + self.assertIn("trust classification", result.summary.lower()) + self.assertIn("risk_status:unknown", result.evidence) + self.assertIn("decision_support_allowed:False", result.evidence) + self.assertIn("weekly_review_entry_count:2", result.evidence) + + def test_adapter_runs_analyst_through_common_entrypoint(self) -> None: + context = self._build_context() + result = run_council_analyst(StructureAnalyst(), context) + + self.assertIsInstance(result, CouncilCase) + self.assertEqual(result.title, "Structure Analysis") + + def test_news_event_analyst_reads_existing_market_state(self) -> None: + context = self._build_context() + result = NewsEventAnalyst().analyze(context) + + self.assertEqual(result.title, "News/Event Analysis") + self.assertIn("news_window_inactive", result.evidence[0]) + + def test_visual_analyst_reads_existing_visual_reviews(self) -> None: + context = self._build_context() + result = VisualAnalyst().analyze(context) + + self.assertEqual(result.title, "Visual Analysis") + self.assertIn("state=aligned", result.summary) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_checkpoint_service.py b/tests/unit/test_research_council_checkpoint_service.py new file mode 100644 index 0000000..660de74 --- /dev/null +++ b/tests/unit/test_research_council_checkpoint_service.py @@ -0,0 +1,57 @@ +import unittest + +from src.services.research_council import CouncilCheckpointService + + +class _CheckpointRepository: + def __init__(self): + self.rows = [] + + def insert_checkpoint(self, **kwargs): + row = {"id": len(self.rows) + 1, **kwargs} + self.rows.append(row) + return row["id"] + + def fetch_latest_checkpoint(self, *, instrument_id, checkpoint_key): + for row in reversed(self.rows): + if row["instrument_id"] == instrument_id and row["checkpoint_key"] == checkpoint_key: + return dict(row) + return None + + +class CouncilCheckpointServiceTests(unittest.TestCase): + def test_resume_state_is_true_for_in_progress_checkpoint(self) -> None: + repository = _CheckpointRepository() + service = CouncilCheckpointService(repository=repository) + + checkpoint = service.save_checkpoint( + run_id=7, + instrument_id=1, + stage="run_persisted", + status="in_progress", + payload={"run_id": 7}, + ) + resume_state = service.build_resume_state(instrument_id=1) + + self.assertEqual(checkpoint["id"], 1) + self.assertTrue(resume_state["can_resume"]) + self.assertEqual(resume_state["latest_checkpoint"]["stage"], "run_persisted") + + def test_resume_state_is_false_for_completed_checkpoint(self) -> None: + repository = _CheckpointRepository() + service = CouncilCheckpointService(repository=repository) + + service.save_checkpoint( + run_id=7, + instrument_id=1, + stage="run_completed", + status="completed", + payload={"run_id": 7}, + ) + resume_state = service.build_resume_state(instrument_id=1) + + self.assertFalse(resume_state["can_resume"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_context_builder.py b/tests/unit/test_research_council_context_builder.py new file mode 100644 index 0000000..c2ecc81 --- /dev/null +++ b/tests/unit/test_research_council_context_builder.py @@ -0,0 +1,209 @@ +import unittest + +from src.services.research_council import CouncilContext, CouncilContextBuilder + + +class _FakeMarketState: + def to_dict(self): + return { + "freshness": {"code": "market_data_fresh"}, + "stream_state": {"code": "market_stream_connected"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + "news_window": { + "code": "news_window_inactive", + "is_active": False, + "provider_name": "fake_news", + "freshness_code": "news_provider_fresh", + "active_events": [], + }, + } + + +class _FakeMarketStateService: + def __init__(self): + self.calls = [] + + def build_market_state(self, *, instrument_id, timeframe, now): + self.calls.append( + { + "instrument_id": instrument_id, + "timeframe": timeframe, + "now": now, + } + ) + return _FakeMarketState() + + +class _FakeDecisionService: + def __init__(self): + self.calls = [] + + def build_preview(self, *, instrument_id, model_code): + self.calls.append({"instrument_id": instrument_id, "model_code": model_code}) + return { + "status": "monitoring", + "action_recommendation": "restricted_decision_support", + "risk_result": {"allowed": False, "reasons": ["risk_governor_blocked"]}, + "id": 44, + "raw": { + "plan": {"id": 11, "status": "active", "bias": "bullish"}, + "candidate": {"id": 22, "model_code": model_code, "status": "draft"}, + "signal": {"id": 33, "status": "draft"}, + "trust_policy": { + "classification": "observe", + "operator_mode": "decision_support_only", + }, + }, + } + + +class _FakeDecisionRepository: + def fetch_recent_decision_alerts(self, instrument_id: int, limit: int = 10): + return [ + { + "id": 101, + "instrument_id": instrument_id, + "alert_type": "candidate_blocked", + } + ][:limit] + + +class _FakeWeeklyReviewService: + def build_summary(self, *, instrument_id): + return { + "instrument_id": instrument_id, + "entry_count": 2, + "next_week_actions": ["tighten_real_trade_reconciliation"], + } + + +class _FakeJournalRepository: + def fetch_recent_journal_entries(self, *, instrument_id=None, limit: int = 10): + return [ + { + "id": 201, + "instrument_id": instrument_id, + "session_code": "LONDON", + } + ][:limit] + + +class _FakeVisualWatchService: + def __init__(self): + self.calls = [] + + def fetch_reviews_for_packet(self, *, decision_packet_id, limit=10): + self.calls.append({"decision_packet_id": decision_packet_id, "limit": limit}) + return [ + { + "id": 301, + "decision_packet_id": decision_packet_id, + "visual_state": "aligned", + "visual_confidence": 0.88, + "visual_notes": "structure visible", + "ui_anomalies": [], + } + ][:limit] + + +class _FakeMemoryService: + def __init__(self): + self.calls = [] + + def fetch_compact_lessons(self, **kwargs): + self.calls.append(kwargs) + return [ + { + "lesson_key": "journal:201", + "title": "Journal lesson LONDON SWEEP_MSS_FVG", + "lesson_summary": "Prior London session favored tighter confirmation discipline.", + "session_code": "LONDON", + "setup_type": "SWEEP_MSS_FVG", + "model_code": "SWEEP_MSS_FVG", + "tags": ["weekly_review", "tighten_real_trade_reconciliation"], + "priority_score": 0.7, + "similarity_score": 1.2, + } + ] + + +class CouncilContextBuilderTests(unittest.TestCase): + def test_builder_reuses_existing_ai_ict_outputs(self) -> None: + market_state_service = _FakeMarketStateService() + decision_service = _FakeDecisionService() + builder = CouncilContextBuilder( + market_state_service=market_state_service, + decision_service=decision_service, + decision_repository=_FakeDecisionRepository(), + weekly_review_service=_FakeWeeklyReviewService(), + journal_repository=_FakeJournalRepository(), + visual_watch_service=_FakeVisualWatchService(), + memory_service=_FakeMemoryService(), + ) + + context = builder.build_context( + instrument_id=1, + timeframe="5m", + model_code="SWEEP_MSS_FVG", + ) + + self.assertIsInstance(context, CouncilContext) + self.assertEqual(context.instrument_id, 1) + self.assertEqual(context.timeframe, "5m") + self.assertEqual(context.model_code, "SWEEP_MSS_FVG") + self.assertEqual(context.latest_trading_plan["id"], 11) + self.assertEqual(context.latest_candidate["id"], 22) + self.assertEqual(context.latest_signal["id"], 33) + self.assertEqual(context.trust_policy["operator_mode"], "decision_support_only") + self.assertEqual(context.recent_alerts[0]["alert_type"], "candidate_blocked") + self.assertEqual(context.recent_journal_entries[0]["session_code"], "LONDON") + self.assertEqual(context.weekly_review_summary["entry_count"], 2) + self.assertEqual(context.visual_reviews[0]["visual_state"], "aligned") + self.assertEqual(context.compact_lessons[0]["lesson_key"], "journal:201") + self.assertEqual(context.source_snapshot_meta["trading_plan_id"], 11) + self.assertEqual(context.source_snapshot_meta["candidate_id"], 22) + self.assertEqual(context.source_snapshot_meta["signal_id"], 33) + self.assertEqual(context.source_snapshot_meta["decision_packet_id"], 44) + self.assertEqual(context.source_snapshot_meta["visual_review_count"], 1) + self.assertEqual(context.source_snapshot_meta["lesson_count"], 1) + + self.assertEqual(len(market_state_service.calls), 1) + self.assertEqual(len(decision_service.calls), 1) + + def test_builder_handles_sparse_preview_payloads(self) -> None: + class _SparseDecisionService: + def build_preview(self, *, instrument_id, model_code): + return {} + + builder = CouncilContextBuilder( + market_state_service=_FakeMarketStateService(), + decision_service=_SparseDecisionService(), + decision_repository=_FakeDecisionRepository(), + weekly_review_service=_FakeWeeklyReviewService(), + journal_repository=_FakeJournalRepository(), + visual_watch_service=_FakeVisualWatchService(), + memory_service=_FakeMemoryService(), + ) + + context = builder.build_context(instrument_id=7) + + self.assertEqual(context.latest_trading_plan, {}) + self.assertEqual(context.latest_candidate, {}) + self.assertEqual(context.latest_signal, {}) + self.assertEqual(context.risk_result, {}) + self.assertEqual(context.trust_policy, {}) + + +__all__ = [ + "_FakeDecisionRepository", + "_FakeDecisionService", + "_FakeJournalRepository", + "_FakeMarketStateService", + "_FakeMemoryService", + "_FakeVisualWatchService", + "_FakeWeeklyReviewService", +] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_decision_packet_fields.py b/tests/unit/test_research_council_decision_packet_fields.py new file mode 100644 index 0000000..dab4b1b --- /dev/null +++ b/tests/unit/test_research_council_decision_packet_fields.py @@ -0,0 +1,69 @@ +from datetime import datetime, timezone +import unittest + +from src.services.decision.decision_packet_service import build_alert_candidates, build_decision_packet_from_rows + + +class ResearchCouncilDecisionPacketFieldTests(unittest.TestCase): + def test_packet_absorbs_council_fields_without_changing_action(self) -> None: + packet = build_decision_packet_from_rows( + instrument_id=1, + observed_ts=datetime(2026, 5, 10, 1, 0, tzinfo=timezone.utc), + plan={"id": 10, "status": "active", "bias": "bullish"}, + candidate={"id": 20, "trading_plan_id": 10, "status": "blocked", "missing_confirmations": ["mss_confirmed"]}, + signal={}, + ticket={}, + risk_result={"allowed": None, "status": "signal_missing", "reasons": ["signal_missing"]}, + research_council={ + "id": 77, + "status": "completed", + "payload": { + "decision": { + "research_summary": "Council wants more confirmation.", + "committee_confidence": 0.62, + "agreement_points": ["active_plan_present"], + "disagreement_points": ["structure_vs_constraints"], + "follow_up_checks": ["mss_confirmed"], + "recommended_watch_items": ["news_window_review", "visual_review_missing"], + "operator_questions": ["Has MSS confirmed?"], + } + }, + }, + ) + + self.assertEqual(packet.action_recommendation, "wait_for_confirmations") + self.assertTrue(packet.research_council["present"]) + self.assertEqual(packet.research_council["run_id"], 77) + self.assertEqual(packet.research_council["research_summary"], "Council wants more confirmation.") + self.assertEqual(packet.research_council["committee_confidence"], 0.62) + + def test_council_watch_items_create_visual_watch_requests(self) -> None: + packet = build_decision_packet_from_rows( + instrument_id=1, + observed_ts=datetime(2026, 5, 10, 1, 0, tzinfo=timezone.utc), + plan={"id": 10, "status": "active"}, + candidate={"id": 20, "trading_plan_id": 10, "status": "blocked", "missing_confirmations": ["mss_confirmed"]}, + signal={"id": 30}, + ticket={}, + risk_result={"allowed": None, "status": "signal_missing", "reasons": ["signal_missing"]}, + research_council={ + "id": 77, + "payload": { + "decision": { + "recommended_watch_items": ["news_window_review", "visual_review_missing"], + } + }, + }, + ) + + alerts = build_alert_candidates(packet) + payload = alerts[0]["payload"] + + self.assertIn("news_window_review", payload["visual_trigger_types"]) + self.assertIn("candidate_block_reason_review", payload["visual_trigger_types"]) + self.assertGreaterEqual(len(payload["visual_watch_requests"]), 2) + self.assertEqual(payload["research_council"]["run_id"], 77) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_governance_tick_service.py b/tests/unit/test_research_council_governance_tick_service.py new file mode 100644 index 0000000..e8dbc83 --- /dev/null +++ b/tests/unit/test_research_council_governance_tick_service.py @@ -0,0 +1,118 @@ +import unittest + +from src.services.research_council import CouncilGovernanceTickService + + +class _ReviewService: + def __init__(self, reviews): + self.reviews = list(reviews) + self.calls = [] + + def build_review(self, **kwargs): + self.calls.append(kwargs) + if len(self.reviews) > 1: + return dict(self.reviews.pop(0)) + return dict(self.reviews[0]) + + +class _OptimizationService: + def __init__(self): + self.calls = [] + + def run_cycle(self, **kwargs): + self.calls.append(kwargs) + return { + "id": 33, + "status": "completed", + "passed": True, + "next_actions": ["rerun_optimization_cycle_after_next_market_sample"], + } + + +class CouncilGovernanceTickServiceTests(unittest.TestCase): + def test_healthy_review_noops_without_opening_bounded_slice(self) -> None: + optimization = _OptimizationService() + service = CouncilGovernanceTickService( + review_service=_ReviewService( + [ + { + "present": True, + "health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + "can_open_bounded_slice": False, + } + ] + ), + optimization_service=optimization, + ) + + payload = service.run_tick(instrument_id=1) + + self.assertEqual(payload["status"], "no_op_healthy") + self.assertTrue(payload["safe_to_auto_continue"]) + self.assertFalse(payload["requires_bounded_slice"]) + self.assertEqual(optimization.calls, []) + + def test_needs_attention_returns_bounded_slice_without_auto_mutation(self) -> None: + optimization = _OptimizationService() + service = CouncilGovernanceTickService( + review_service=_ReviewService( + [ + { + "present": True, + "health_state": "needs_attention", + "recommended_actions": ["add_replay_fixture_with_confirmed_swings"], + "can_open_bounded_slice": True, + } + ] + ), + optimization_service=optimization, + ) + + payload = service.run_tick(instrument_id=1) + + self.assertEqual(payload["status"], "bounded_slice_required") + self.assertTrue(payload["requires_bounded_slice"]) + self.assertFalse(payload["safe_to_auto_continue"]) + self.assertEqual(payload["recommended_actions"], ["add_replay_fixture_with_confirmed_swings"]) + self.assertEqual(optimization.calls, []) + + def test_auto_run_stale_cycle_reruns_then_reports_post_cycle_review(self) -> None: + optimization = _OptimizationService() + service = CouncilGovernanceTickService( + review_service=_ReviewService( + [ + { + "present": True, + "health_state": "stale", + "recommended_actions": ["run_research_council_optimization_loop"], + "can_open_bounded_slice": False, + }, + { + "present": True, + "health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + "can_open_bounded_slice": False, + }, + ] + ), + optimization_service=optimization, + ) + + payload = service.run_tick( + instrument_id=1, + auto_run_stale_cycle=True, + replay_file="data/replay/research_council_confirmed_swings_v1.jsonl", + cycle_key="cycle:test", + ) + + self.assertEqual(payload["status"], "cycle_run") + self.assertEqual(payload["effective_health_state"], "healthy") + self.assertTrue(payload["safe_to_auto_continue"]) + self.assertEqual(len(optimization.calls), 1) + self.assertEqual(optimization.calls[0]["cycle_key"], "cycle:test") + self.assertEqual(payload["recommended_actions"], ["continue_periodic_optimization_cycle"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_imports.py b/tests/unit/test_research_council_imports.py new file mode 100644 index 0000000..675aa88 --- /dev/null +++ b/tests/unit/test_research_council_imports.py @@ -0,0 +1,52 @@ +import unittest + +from src.services.research_council import ( + CouncilCase, + CouncilDecision, + CouncilLesson, + CouncilMemoryService, + build_empty_council_decision, + render_council_case, + render_council_decision, +) + + +class ResearchCouncilImportTests(unittest.TestCase): + def test_package_exports_are_importable(self) -> None: + decision = build_empty_council_decision() + + self.assertIsInstance(decision, CouncilDecision) + self.assertIsInstance(decision.bull_case, CouncilCase) + self.assertEqual(decision.research_summary, "") + self.assertTrue(CouncilMemoryService) + self.assertTrue(CouncilLesson) + + def test_formatter_outputs_expected_sections(self) -> None: + decision = CouncilDecision( + research_summary="Structure and risk views are aligned.", + operator_summary="Observe only until the next confirmation appears.", + bull_case=CouncilCase( + title="Bull Case", + summary="Liquidity sweep plus MSS supports constructive follow-through.", + evidence=["sweep_confirmed", "mss_confirmed"], + ), + bear_case=CouncilCase( + title="Bear Case", + summary="Execution remains blocked by current trust and runtime state.", + evidence=["trust_not_ready", "runtime_degraded"], + ), + net_assessment="Useful as decision support, not yet for execution assist.", + committee_confidence=0.72, + source_snapshot_meta={"instrument_id": 1}, + ) + + case_text = render_council_case(decision.bull_case) + decision_text = render_council_decision(decision) + + self.assertIn("**Bull Case**:", case_text) + self.assertIn("**Research Summary**:", decision_text) + self.assertIn("**Committee Confidence**: 0.72", decision_text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_memory_service.py b/tests/unit/test_research_council_memory_service.py new file mode 100644 index 0000000..d00c01a --- /dev/null +++ b/tests/unit/test_research_council_memory_service.py @@ -0,0 +1,161 @@ +import unittest + +from src.services.research_council import CouncilLesson, CouncilMemoryService + + +class _LessonRepo: + def __init__(self): + self.rows = {} + + def fetch_lesson_by_key(self, *, lesson_key): + row = self.rows.get(lesson_key) + return dict(row) if row else None + + def upsert_lesson(self, **kwargs): + row = dict(kwargs) + existing = self.rows.get(kwargs["lesson_key"]) + row["id"] = existing["id"] if existing else len(self.rows) + 1 + self.rows[kwargs["lesson_key"]] = row + return row["id"] + + def fetch_recent_lessons(self, *, instrument_id, limit=100, symbol=None, session_code=None, setup_type=None, model_code=None): + rows = [dict(row) for row in self.rows.values() if row["instrument_id"] == instrument_id] + if symbol is not None: + rows = [row for row in rows if row.get("symbol") == symbol] + if session_code is not None: + rows = [row for row in rows if row.get("session_code") == session_code] + if setup_type is not None: + rows = [row for row in rows if row.get("setup_type") == setup_type] + if model_code is not None: + rows = [row for row in rows if row.get("model_code") == model_code] + return rows[:limit] + + +class _JournalRepo: + def fetch_recent_journal_entries(self, *, instrument_id=None, limit=100): + return [ + { + "id": 201, + "instrument_id": instrument_id, + "session_code": "LONDON", + "model_code": "SWEEP_MSS_FVG", + "framework_execution": False, + "model_compliance": False, + "risk_violation": True, + "execution_violation": False, + "plan_violation": True, + "review_tags": ["risk_compliant", "plan_violation"], + "next_actions": ["tighten_real_trade_reconciliation"], + } + ][:limit] + + +class _WeeklyReviewService: + def build_summary(self, **kwargs): + return { + "week_start": "2026-05-05", + "week_end": "2026-05-11", + "next_week_actions": ["review_ny_am_session_playbook"], + "low_quality_session": {"session_code": "NY_AM", "framework_execution_rate": 0.0, "avg_r_multiple": -1.0}, + "high_quality_session": {"session_code": "LONDON", "framework_execution_rate": 1.0, "avg_r_multiple": 1.2}, + } + + +class _TrustService: + def build_summary(self, **kwargs): + return { + "models": [ + { + "model_code": "SWEEP_MSS_FVG", + "classification": "observe", + "score": 66.0, + "execution_allowed": False, + "reasons": ["sample_size_small"], + } + ] + } + + +class _BacktestRepo: + def fetch_recent_backtest_run_rows(self, instrument_id, limit=20): + return [ + { + "id": 401, + "subject_type": "plan_replay", + "candidate_model_code": "SWEEP_MSS_FVG", + "model_code": "SWEEP_MSS_FVG", + "summary": {"quality_gate": {"passed": True}}, + } + ][:limit] + + +class CouncilMemoryServiceTests(unittest.TestCase): + def _build_service(self): + return CouncilMemoryService( + lesson_repository=_LessonRepo(), + journal_repository=_JournalRepo(), + weekly_review_service=_WeeklyReviewService(), + trust_service=_TrustService(), + backtest_repository=_BacktestRepo(), + ) + + def test_backfill_all_sources_is_repeatable(self) -> None: + service = self._build_service() + + first = service.backfill_all_sources(instrument_id=1, limit=10) + second = service.backfill_all_sources(instrument_id=1, limit=10) + + self.assertGreater(first["totals"]["created"], 0) + self.assertEqual(second["totals"]["created"], 0) + self.assertGreater(second["totals"]["updated"], 0) + + def test_fetch_compact_lessons_prefers_scope_matches(self) -> None: + service = self._build_service() + service.upsert_lesson( + CouncilLesson( + lesson_key="manual:1", + instrument_id=1, + source_type="manual", + source_ref="1", + lesson_type="journal_pattern", + title="London setup lesson", + lesson_summary="Use tighter confirmation discipline in London.", + session_code="LONDON", + setup_type="SWEEP_MSS_FVG", + model_code="SWEEP_MSS_FVG", + tags=["tighten_real_trade_reconciliation"], + priority_score=0.8, + ) + ) + service.upsert_lesson( + CouncilLesson( + lesson_key="manual:2", + instrument_id=1, + source_type="manual", + source_ref="2", + lesson_type="journal_pattern", + title="Off-hours lesson", + lesson_summary="Do less during off hours.", + session_code="OFF_HOURS", + setup_type="OTHER_MODEL", + model_code="OTHER_MODEL", + tags=["observe"], + priority_score=0.9, + ) + ) + + lessons = service.fetch_compact_lessons( + instrument_id=1, + session_code="LONDON", + setup_type="SWEEP_MSS_FVG", + model_code="SWEEP_MSS_FVG", + tags=["tighten_real_trade_reconciliation"], + limit=1, + ) + + self.assertEqual(len(lessons), 1) + self.assertEqual(lessons[0]["lesson_key"], "manual:1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_optimization_review_service.py b/tests/unit/test_research_council_optimization_review_service.py new file mode 100644 index 0000000..f5b18ac --- /dev/null +++ b/tests/unit/test_research_council_optimization_review_service.py @@ -0,0 +1,111 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from src.services.research_council import CouncilOptimizationReviewService + + +class _OptimizationService: + def __init__(self, cycles): + self.cycles = cycles + + def get_recent_cycles(self, *, instrument_id, limit=20): + return list(self.cycles)[:limit] + + +class CouncilOptimizationReviewServiceTests(unittest.TestCase): + def test_build_review_reports_missing_when_no_cycles_exist(self) -> None: + service = CouncilOptimizationReviewService(optimization_service=_OptimizationService([])) + + payload = service.build_review( + instrument_id=1, + now=datetime(2026, 5, 11, tzinfo=timezone.utc), + ) + + self.assertFalse(payload["present"]) + self.assertEqual(payload["health_state"], "missing") + self.assertIn("run_research_council_optimization_loop", payload["recommended_actions"]) + + def test_build_review_reports_needs_attention_and_deltas(self) -> None: + now = datetime(2026, 5, 11, tzinfo=timezone.utc) + latest = _cycle( + cycle_id=2, + finished_ts=now - timedelta(hours=1), + next_actions=["add_replay_fixture_with_confirmed_swings", "rerun_optimization_cycle_after_next_market_sample"], + guidance_delta=4.0, + lesson_usage_rate=1.0, + disagreement_hint_rate=1.0, + ) + previous = _cycle( + cycle_id=1, + finished_ts=now - timedelta(hours=2), + next_actions=["rerun_optimization_cycle_after_next_market_sample"], + guidance_delta=2.5, + lesson_usage_rate=0.5, + disagreement_hint_rate=0.25, + ) + service = CouncilOptimizationReviewService(optimization_service=_OptimizationService([latest, previous])) + + payload = service.build_review(instrument_id=1, now=now) + + self.assertTrue(payload["present"]) + self.assertEqual(payload["health_state"], "needs_attention") + self.assertEqual(payload["latest_cycle_id"], 2) + self.assertEqual(payload["metric_deltas"]["guidance_delta"], 1.5) + self.assertEqual(payload["metric_deltas"]["lesson_usage_rate"], 0.5) + self.assertIn("add_replay_fixture_with_confirmed_swings", payload["recommended_actions"]) + self.assertTrue(payload["can_open_bounded_slice"]) + + def test_build_review_marks_stale_cycle(self) -> None: + now = datetime(2026, 5, 11, tzinfo=timezone.utc) + service = CouncilOptimizationReviewService( + optimization_service=_OptimizationService( + [ + _cycle( + cycle_id=1, + finished_ts=now - timedelta(hours=48), + next_actions=["rerun_optimization_cycle_after_next_market_sample"], + ) + ] + ) + ) + + payload = service.build_review(instrument_id=1, stale_after_hours=24, now=now) + + self.assertEqual(payload["health_state"], "stale") + self.assertTrue(payload["stale"]) + self.assertIn("run_research_council_optimization_loop", payload["recommended_actions"]) + self.assertFalse(payload["can_open_bounded_slice"]) + + +def _cycle( + *, + cycle_id, + finished_ts, + next_actions, + guidance_delta=1.0, + lesson_usage_rate=1.0, + disagreement_hint_rate=1.0, +): + return { + "id": cycle_id, + "cycle_key": f"cycle:{cycle_id}", + "status": "completed", + "finished_ts": finished_ts.isoformat(), + "next_actions": next_actions, + "eval_summary": { + "run_count": 20, + "guidance_delta": guidance_delta, + "lesson_usage_rate": lesson_usage_rate, + "disagreement_hint_rate": disagreement_hint_rate, + "avg_follow_up_check_count": 6.0, + "avg_operator_question_count": 2.0, + }, + "arena_summary": { + "best_model_code": "SWEEP_MSS_FVG", + "rows": [{"model_code": "SWEEP_MSS_FVG"}], + }, + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_optimization_service.py b/tests/unit/test_research_council_optimization_service.py new file mode 100644 index 0000000..a06aab7 --- /dev/null +++ b/tests/unit/test_research_council_optimization_service.py @@ -0,0 +1,172 @@ +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from src.services.research_council import CouncilOptimizationService, build_replay_summary, choose_next_actions + + +class _FakeResearchCouncilService: + def __init__(self): + self.backfill_calls = [] + self.persist_calls = [] + self.preview_calls = [] + + def backfill_lessons(self, **kwargs): + self.backfill_calls.append(kwargs) + return { + "instrument_id": kwargs["instrument_id"], + "totals": {"requested": 2, "created": 1, "updated": 1}, + } + + def build_and_persist(self, **kwargs): + self.persist_calls.append(kwargs) + return {"id": 101, "instrument_id": kwargs["instrument_id"]} + + def get_recent_runs(self, **kwargs): + return [ + { + "payload": { + "context": { + "latest_candidate": { + "session_code": "LONDON", + "model_code": "SWEEP_MSS_FVG", + "missing_confirmations": ["mss"], + }, + "trust_policy": {"blocking_reasons": ["trust_blocked"]}, + "risk_result": {"reasons": ["risk_blocked"]}, + "compact_lessons": [{"lesson_key": "journal:1"}], + }, + "decision": { + "follow_up_checks": ["mss", "risk_blocked"], + "operator_questions": ["question"], + "disagreement_points": ["debate"], + "committee_confidence": 0.6, + }, + } + } + ] + + def build_preview(self, **kwargs): + self.preview_calls.append(kwargs) + return { + "decision": { + "overall_stance": "observe_only", + "committee_confidence": 0.7, + "follow_up_checks": ["mss"], + "disagreement_points": ["debate"], + }, + "context": {"compact_lessons": [{"lesson_key": "journal:1"}]}, + } + + +class _FakeOptimizationRepository: + def __init__(self): + self.rows = [] + + def upsert_cycle(self, **kwargs): + row = {"id": 301, **kwargs} + self.rows.append(row) + return 301 + + def fetch_latest_cycle(self, *, instrument_id): + for row in reversed(self.rows): + if row["instrument_id"] == instrument_id: + return dict(row) + return None + + def fetch_recent_cycles(self, *, instrument_id, limit=20): + return [dict(row) for row in self.rows if row["instrument_id"] == instrument_id][:limit] + + +class _FakeTrustService: + def build_summary(self, *, instrument_id): + return {"models": [{"model_code": "SWEEP_MSS_FVG"}]} + + +class CouncilOptimizationServiceTests(unittest.TestCase): + def test_run_cycle_persists_full_optimization_payload(self) -> None: + repo = _FakeOptimizationRepository() + service = CouncilOptimizationService( + research_council_service=_FakeResearchCouncilService(), + optimization_repository=repo, + trust_service=_FakeTrustService(), + ) + + payload = service.run_cycle( + instrument_id=1, + timeframe="5m", + model_code="SWEEP_MSS_FVG", + eval_limit=10, + lesson_limit=5, + cycle_key="research_council_opt:test", + now=datetime(2026, 5, 11, tzinfo=timezone.utc), + ) + + self.assertTrue(payload["passed"]) + self.assertEqual(payload["id"], 301) + self.assertEqual(payload["fresh_run_id"], 101) + self.assertEqual(payload["eval_summary"]["run_count"], 1) + self.assertEqual(payload["arena_summary"]["best_model_code"], "SWEEP_MSS_FVG") + self.assertIn("rerun_optimization_cycle_after_next_market_sample", payload["next_actions"]) + self.assertIn("research_council_optimization_does_not_change_execution_gate", payload["boundary_notes"]) + self.assertEqual(repo.rows[0]["cycle_key"], "research_council_opt:test") + + def test_latest_and_recent_cycles_delegate_to_repository(self) -> None: + repo = _FakeOptimizationRepository() + service = CouncilOptimizationService( + research_council_service=_FakeResearchCouncilService(), + optimization_repository=repo, + trust_service=_FakeTrustService(), + ) + service.run_cycle(instrument_id=1, cycle_key="cycle:1", now=datetime(2026, 5, 11, tzinfo=timezone.utc)) + + latest = service.get_latest_cycle(instrument_id=1) + recent = service.get_recent_cycles(instrument_id=1, limit=5) + + self.assertEqual(latest["cycle_key"], "cycle:1") + self.assertEqual(len(recent), 1) + + def test_choose_next_actions_identifies_sparse_inputs(self) -> None: + actions = choose_next_actions( + lesson_summary={"totals": {"requested": 0}}, + replay_summary={"status": "completed", "confirmed_swing_count": 0}, + eval_summary={ + "run_count": 1, + "lesson_usage_rate": 0.0, + "avg_follow_up_check_count": 0, + "avg_baseline_issue_count": 2, + "disagreement_hint_rate": 0.0, + }, + arena_summary={"rows": []}, + ) + + self.assertIn("seed_lessons_from_journal_weekly_review_or_replay", actions) + self.assertIn("increase_lesson_retrieval_or_backfill_coverage", actions) + self.assertIn("review_bull_bear_disagreement_prompts", actions) + + def test_p5_confirmed_swing_fixture_closes_replay_follow_up(self) -> None: + replay_file = Path(__file__).resolve().parents[2] / "data" / "replay" / "research_council_confirmed_swings_v1.jsonl" + + replay_summary = build_replay_summary(replay_file) + actions = choose_next_actions( + lesson_summary={"totals": {"requested": 3}}, + replay_summary=replay_summary, + eval_summary={ + "run_count": 5, + "lesson_usage_rate": 1.0, + "avg_follow_up_check_count": 3, + "avg_baseline_issue_count": 2, + "disagreement_hint_rate": 0.5, + }, + arena_summary={"rows": [{"model_code": "SWEEP_MSS_FVG", "committee_confidence": 0.7}]}, + ) + + self.assertTrue(replay_summary["confirmed_swing_gate_passed"]) + self.assertEqual(replay_summary["confirmed_swing_count"], 2) + self.assertEqual(replay_summary["swing_counts"], {"swing_high": 1, "swing_low": 1}) + self.assertNotIn("add_replay_fixture_with_confirmed_swings", actions) + self.assertEqual(actions, ["rerun_optimization_cycle_after_next_market_sample"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_orchestrator.py b/tests/unit/test_research_council_orchestrator.py new file mode 100644 index 0000000..f13f103 --- /dev/null +++ b/tests/unit/test_research_council_orchestrator.py @@ -0,0 +1,53 @@ +import unittest + +from src.services.research_council import CouncilContextBuilder, CouncilDecision, CouncilOrchestrator +from tests.unit.test_research_council_context_builder import ( + _FakeDecisionRepository, + _FakeDecisionService, + _FakeJournalRepository, + _FakeMemoryService, + _FakeMarketStateService, + _FakeVisualWatchService, + _FakeWeeklyReviewService, +) + + +class ResearchCouncilOrchestratorTests(unittest.TestCase): + def _build_context(self): + builder = CouncilContextBuilder( + market_state_service=_FakeMarketStateService(), + decision_service=_FakeDecisionService(), + decision_repository=_FakeDecisionRepository(), + weekly_review_service=_FakeWeeklyReviewService(), + journal_repository=_FakeJournalRepository(), + visual_watch_service=_FakeVisualWatchService(), + memory_service=_FakeMemoryService(), + ) + return builder.build_context(instrument_id=1, timeframe="1m", model_code="SWEEP_MSS_FVG") + + def test_orchestrator_builds_minimal_p0_decision(self) -> None: + context = self._build_context() + orchestrator = CouncilOrchestrator() + + decision = orchestrator.build_decision(context) + + self.assertIsInstance(decision, CouncilDecision) + self.assertEqual(decision.structure_analysis.title, "Structure Analysis") + self.assertEqual(decision.risk_analysis.title, "Risk Analysis") + self.assertEqual(decision.news_event_analysis.title, "News/Event Analysis") + self.assertEqual(decision.visual_analysis.title, "Visual Analysis") + self.assertEqual(decision.bull_case.title, "Bull Case") + self.assertEqual(decision.bear_case.title, "Bear Case") + self.assertEqual(decision.overall_stance, "observe_only") + self.assertIn("active_plan_present", decision.agreement_points) + self.assertIn("execution_not_allowed_by_trust_policy", decision.execution_veto_signals) + self.assertIn("risk_governor_blocked", decision.follow_up_checks) + self.assertIn("wait_for_candidate_confirmation", decision.recommended_watch_items) + self.assertIn("state=aligned", decision.visual_analysis.summary) + self.assertTrue(decision.follow_up_check_details) + self.assertTrue(decision.operator_questions) + self.assertGreater(decision.committee_confidence, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_repositories.py b/tests/unit/test_research_council_repositories.py new file mode 100644 index 0000000..36c989e --- /dev/null +++ b/tests/unit/test_research_council_repositories.py @@ -0,0 +1,274 @@ +import unittest + +from src.repositories.research_council_link_repository import ResearchCouncilLinkRepository +from src.repositories.research_council_message_repository import ResearchCouncilMessageRepository +from src.repositories.research_council_run_repository import ResearchCouncilRunRepository +from src.repositories.research_council_checkpoint_repository import ResearchCouncilCheckpointRepository +from src.repositories.research_council_lesson_repository import ResearchCouncilLessonRepository +from src.repositories.research_council_optimization_repository import ResearchCouncilOptimizationRepository + + +class _ScalarResult: + def __init__(self, value): + self.value = value + + def scalar_one(self): + return self.value + + +class _MappingResult: + def __init__(self, rows): + self.rows = rows + + def mappings(self): + return self + + def first(self): + return self.rows[0] if self.rows else None + + def all(self): + return self.rows + + +class _Session: + def __init__(self, rows=None, scalar_value=1): + self.rows = rows or [] + self.scalar_value = scalar_value + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def execute(self, sql, params): + self.calls.append({"sql": str(sql), "params": params}) + if "returning id" in str(sql).lower(): + return _ScalarResult(self.scalar_value) + return _MappingResult(self.rows) + + +class _SessionLocalFactory: + def __init__(self, session): + self.session = session + + def __call__(self): + return self.session + + def begin(self): + return self.session + + +class ResearchCouncilRepositoryTests(unittest.TestCase): + def test_run_repository_insert_and_fetch_latest(self) -> None: + insert_session = _Session(scalar_value=7) + run_repo = ResearchCouncilRunRepository() + original_local = run_repo.insert_run.__globals__["SessionLocal"] + try: + run_repo.insert_run.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + run_id = run_repo.insert_run( + instrument_id=1, + trading_plan_id=11, + candidate_id=22, + signal_id=33, + decision_packet_id=None, + status="completed", + timeframe="1m", + model_code="SWEEP_MSS_FVG", + committee_confidence=0.7, + summary_text="summary", + payload={"decision": {"overall_stance": "observe_only"}}, + generated_ts="2026-05-10T00:00:00+00:00", + ) + finally: + run_repo.insert_run.__globals__["SessionLocal"] = original_local + + self.assertEqual(run_id, 7) + + fetch_session = _Session(rows=[{"id": 7, "payload": {"decision": {"overall_stance": "observe_only"}}}]) + original_local = run_repo.fetch_latest_run.__globals__["SessionLocal"] + try: + run_repo.fetch_latest_run.__globals__["SessionLocal"] = _SessionLocalFactory(fetch_session) + row = run_repo.fetch_latest_run(instrument_id=1) + finally: + run_repo.fetch_latest_run.__globals__["SessionLocal"] = original_local + + self.assertEqual(row["id"], 7) + self.assertEqual(row["payload"]["decision"]["overall_stance"], "observe_only") + + def test_message_repository_insert_and_fetch(self) -> None: + repo = ResearchCouncilMessageRepository() + insert_session = _Session(scalar_value=9) + original_local = repo.insert_message.__globals__["SessionLocal"] + try: + repo.insert_message.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + message_id = repo.insert_message( + run_id=7, + agent_role="structure_analyst", + message_type="analysis", + payload={"summary": "ok"}, + created_ts="2026-05-10T00:00:00+00:00", + ) + finally: + repo.insert_message.__globals__["SessionLocal"] = original_local + + self.assertEqual(message_id, 9) + + fetch_session = _Session(rows=[{"id": 9, "run_id": 7, "payload": {"summary": "ok"}}]) + original_local = repo.fetch_messages_for_run.__globals__["SessionLocal"] + try: + repo.fetch_messages_for_run.__globals__["SessionLocal"] = _SessionLocalFactory(fetch_session) + rows = repo.fetch_messages_for_run(run_id=7, limit=10) + finally: + repo.fetch_messages_for_run.__globals__["SessionLocal"] = original_local + + self.assertEqual(rows[0]["payload"]["summary"], "ok") + + def test_link_repository_insert(self) -> None: + repo = ResearchCouncilLinkRepository() + insert_session = _Session(scalar_value=13) + original_local = repo.insert_link.__globals__["SessionLocal"] + try: + repo.insert_link.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + link_id = repo.insert_link( + run_id=7, + trading_plan_id=11, + candidate_id=22, + signal_id=33, + decision_packet_id=None, + execution_ticket_id=None, + created_ts="2026-05-10T00:00:00+00:00", + ) + finally: + repo.insert_link.__globals__["SessionLocal"] = original_local + + self.assertEqual(link_id, 13) + + def test_checkpoint_repository_insert_and_fetch_latest(self) -> None: + repo = ResearchCouncilCheckpointRepository() + insert_session = _Session(scalar_value=15) + original_local = repo.insert_checkpoint.__globals__["SessionLocal"] + try: + repo.insert_checkpoint.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + checkpoint_id = repo.insert_checkpoint( + run_id=7, + instrument_id=1, + checkpoint_key="research_council_run", + status="in_progress", + stage="run_persisted", + payload={"run_id": 7}, + created_ts="2026-05-10T00:00:00+00:00", + updated_ts="2026-05-10T00:00:00+00:00", + ) + finally: + repo.insert_checkpoint.__globals__["SessionLocal"] = original_local + + self.assertEqual(checkpoint_id, 15) + + fetch_session = _Session(rows=[{"id": 15, "payload": {"run_id": 7}, "status": "in_progress"}]) + original_local = repo.fetch_latest_checkpoint.__globals__["SessionLocal"] + try: + repo.fetch_latest_checkpoint.__globals__["SessionLocal"] = _SessionLocalFactory(fetch_session) + row = repo.fetch_latest_checkpoint(instrument_id=1, checkpoint_key="research_council_run") + finally: + repo.fetch_latest_checkpoint.__globals__["SessionLocal"] = original_local + + self.assertEqual(row["id"], 15) + self.assertEqual(row["payload"]["run_id"], 7) + + def test_lesson_repository_upsert_and_fetch(self) -> None: + repo = ResearchCouncilLessonRepository() + insert_session = _Session(scalar_value=17) + original_local = repo.upsert_lesson.__globals__["SessionLocal"] + try: + repo.upsert_lesson.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + lesson_id = repo.upsert_lesson( + lesson_key="journal:201", + instrument_id=1, + source_type="journal", + source_ref="201", + lesson_type="journal_pattern", + symbol="BTC-USDT", + session_code="LONDON", + setup_type="SWEEP_MSS_FVG", + model_code="SWEEP_MSS_FVG", + title="Journal lesson", + lesson_summary="Keep tighter confirmation discipline.", + tags=["risk"], + priority_score=0.9, + payload={"x": 1}, + created_ts="2026-05-10T00:00:00+00:00", + updated_ts="2026-05-10T00:00:00+00:00", + ) + finally: + repo.upsert_lesson.__globals__["SessionLocal"] = original_local + + self.assertEqual(lesson_id, 17) + + fetch_session = _Session(rows=[{"id": 17, "lesson_key": "journal:201", "tags": ["risk"], "payload": {"x": 1}}]) + original_local = repo.fetch_lesson_by_key.__globals__["SessionLocal"] + try: + repo.fetch_lesson_by_key.__globals__["SessionLocal"] = _SessionLocalFactory(fetch_session) + row = repo.fetch_lesson_by_key(lesson_key="journal:201") + finally: + repo.fetch_lesson_by_key.__globals__["SessionLocal"] = original_local + + self.assertEqual(row["lesson_key"], "journal:201") + self.assertEqual(row["payload"]["x"], 1) + + def test_optimization_repository_upsert_and_fetch_latest(self) -> None: + repo = ResearchCouncilOptimizationRepository() + insert_session = _Session(scalar_value=21) + original_local = repo.upsert_cycle.__globals__["SessionLocal"] + try: + repo.upsert_cycle.__globals__["SessionLocal"] = _SessionLocalFactory(insert_session) + cycle_id = repo.upsert_cycle( + cycle_key="research_council_opt:1:test", + instrument_id=1, + status="completed", + timeframe="1m", + model_code="SWEEP_MSS_FVG", + replay_file=None, + lesson_summary={"totals": {"requested": 1}}, + replay_summary={"status": "skipped"}, + eval_summary={"run_count": 1}, + arena_summary={"rows": [{"model_code": "SWEEP_MSS_FVG"}]}, + next_actions=["rerun_optimization_cycle_after_next_market_sample"], + payload={"passed": True}, + started_ts="2026-05-10T00:00:00+00:00", + finished_ts="2026-05-10T00:01:00+00:00", + ) + finally: + repo.upsert_cycle.__globals__["SessionLocal"] = original_local + + self.assertEqual(cycle_id, 21) + + fetch_session = _Session( + rows=[ + { + "id": 21, + "cycle_key": "research_council_opt:1:test", + "lesson_summary": {"totals": {"requested": 1}}, + "replay_summary": {"status": "skipped"}, + "eval_summary": {"run_count": 1}, + "arena_summary": {"rows": [{"model_code": "SWEEP_MSS_FVG"}]}, + "next_actions": ["rerun_optimization_cycle_after_next_market_sample"], + "payload": {"passed": True}, + } + ] + ) + original_local = repo.fetch_latest_cycle.__globals__["SessionLocal"] + try: + repo.fetch_latest_cycle.__globals__["SessionLocal"] = _SessionLocalFactory(fetch_session) + row = repo.fetch_latest_cycle(instrument_id=1) + finally: + repo.fetch_latest_cycle.__globals__["SessionLocal"] = original_local + + self.assertEqual(row["id"], 21) + self.assertEqual(row["eval_summary"]["run_count"], 1) + self.assertEqual(row["next_actions"], ["rerun_optimization_cycle_after_next_market_sample"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_runtime_integration.py b/tests/unit/test_research_council_runtime_integration.py new file mode 100644 index 0000000..8ce878c --- /dev/null +++ b/tests/unit/test_research_council_runtime_integration.py @@ -0,0 +1,56 @@ +import unittest +from unittest.mock import patch + +from src.services.runtime import TradingRuntimeScheduler +from tests.unit.test_trading_runtime_service import ( + FakeAlertDispatcherService, + FakeBrokerReadModelService, + FakeDecisionPacketService, + FakeMarketStateService, + FakeReconciliationService, + FakeResearchCouncilService, + FakeSetupWatcherService, + FakeTradingPlanService, +) + + +class ResearchCouncilRuntimeIntegrationTests(unittest.TestCase): + def _build_scheduler(self, research_council_service): + return TradingRuntimeScheduler( + market_state_service=FakeMarketStateService(), + trading_plan_service=FakeTradingPlanService(), + setup_watcher_service=FakeSetupWatcherService(), + broker_read_model_service=FakeBrokerReadModelService(), + reconciliation_service=FakeReconciliationService(), + decision_packet_service=FakeDecisionPacketService(), + alert_dispatcher_service=FakeAlertDispatcherService(), + research_council_service=research_council_service, + ) + + def test_council_task_runs_after_watch_before_decision_packet(self) -> None: + council_service = FakeResearchCouncilService() + scheduler = self._build_scheduler(council_service) + + with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): + payload = scheduler.run_cycle() + + self.assertEqual(payload["tasks"]["watch"]["last_status"], "ok") + self.assertEqual(payload["tasks"]["research_council"]["last_status"], "ok") + self.assertEqual(payload["tasks"]["research_council"]["details"]["run_id"], 601) + self.assertEqual(payload["tasks"]["decision_packet"]["last_status"], "ok") + self.assertEqual(council_service.calls[0]["timeframe"], scheduler.trigger_tf) + + def test_council_failure_degrades_without_blocking_decision_packet(self) -> None: + scheduler = self._build_scheduler(FakeResearchCouncilService(should_fail=True)) + + with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): + payload = scheduler.run_cycle() + + self.assertEqual(payload["tasks"]["research_council"]["last_status"], "error") + self.assertEqual(payload["tasks"]["decision_packet"]["last_status"], "ok") + self.assertIn("research_council_task_failed:BTC-USDT", payload["current_warnings"]) + self.assertNotIn("research_council_task_failed:BTC-USDT", payload["current_blockers"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_schema.py b/tests/unit/test_research_council_schema.py new file mode 100644 index 0000000..97959ae --- /dev/null +++ b/tests/unit/test_research_council_schema.py @@ -0,0 +1,87 @@ +import unittest + +from src.services.research_council import ( + CouncilCase, + CouncilDecision, + build_empty_council_decision, + render_council_decision, +) + + +class ResearchCouncilSchemaTests(unittest.TestCase): + def test_empty_decision_includes_p0_contract_fields(self) -> None: + decision = build_empty_council_decision() + payload = decision.to_dict() + + for key in ( + "research_summary", + "operator_summary", + "bull_case", + "bear_case", + "net_assessment", + "committee_confidence", + "source_snapshot_meta", + "structure_analysis", + "risk_analysis", + "agreement_points", + "disagreement_points", + "follow_up_checks", + ): + self.assertIn(key, payload) + + self.assertEqual(payload["bull_case"]["title"], "Bull Case") + self.assertEqual(payload["bear_case"]["title"], "Bear Case") + + def test_formatter_is_stable_for_p0_review_packet_consumption(self) -> None: + decision = CouncilDecision( + research_summary="The committee sees aligned structural and risk constraints.", + operator_summary="Keep the setup in review mode until the next confirmation appears.", + bull_case=CouncilCase( + title="Bull Case", + summary="Price reclaimed the sweep zone and structure is constructive.", + evidence=["sweep_reclaim", "mss_alignment"], + ), + bear_case=CouncilCase( + title="Bear Case", + summary="Risk and trust state still block any execution assist.", + evidence=["risk_blocked", "trust_restricted"], + ), + net_assessment="Useful as review support only for this cycle.", + committee_confidence=0.68, + overall_stance="observe_only", + structure_analysis=CouncilCase( + title="Structure Analysis", + summary="The active plan remains intact and the candidate is still draft.", + evidence=["plan_active", "candidate_draft"], + ), + risk_analysis=CouncilCase( + title="Risk Analysis", + summary="Current execution path is blocked by risk and trust boundaries.", + evidence=["risk_governor_blocked", "execution_not_allowed"], + ), + agreement_points=["plan is active", "execution is still blocked"], + disagreement_points=["whether the next sweep is actionable"], + follow_up_checks=["wait_for_mss_confirmation", "recheck_trust_policy"], + source_snapshot_meta={"instrument_id": 1, "candidate_id": 22}, + ) + + rendered = render_council_decision(decision) + + for snippet in ( + "**Research Summary**:", + "**Operator Summary**:", + "**Structure Analysis**:", + "**Risk Analysis**:", + "**Bull Case**:", + "**Bear Case**:", + "**Agreement Points**:", + "**Disagreement Points**:", + "**Follow-up Checks**:", + "**Net Assessment**:", + "**Committee Confidence**: 0.68", + ): + self.assertIn(snippet, rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_research_council_service.py b/tests/unit/test_research_council_service.py new file mode 100644 index 0000000..0a9e10e --- /dev/null +++ b/tests/unit/test_research_council_service.py @@ -0,0 +1,155 @@ +import unittest + +from src.services.research_council import ( + CouncilContextBuilder, + CouncilOrchestrator, + ResearchCouncilService, +) +from tests.unit.test_research_council_context_builder import ( + _FakeDecisionRepository, + _FakeDecisionService, + _FakeJournalRepository, + _FakeMemoryService, + _FakeMarketStateService, + _FakeVisualWatchService, + _FakeWeeklyReviewService, +) + + +class _RunRepo: + def __init__(self): + self.rows = [] + + def insert_run(self, **kwargs): + row = {"id": 1, **kwargs} + self.rows.append(row) + return 1 + + def fetch_latest_run(self, *, instrument_id): + for row in reversed(self.rows): + if row["instrument_id"] == instrument_id: + return dict(row) + return None + + def fetch_recent_runs(self, *, instrument_id, limit=20): + return [dict(row) for row in self.rows if row["instrument_id"] == instrument_id][:limit] + + +class _MessageRepo: + def __init__(self): + self.rows = [] + + def insert_message(self, **kwargs): + self.rows.append({"id": len(self.rows) + 1, **kwargs}) + return len(self.rows) + + def fetch_messages_for_run(self, *, run_id, limit=50): + return [dict(row) for row in self.rows if row["run_id"] == run_id][:limit] + + +class _LinkRepo: + def __init__(self): + self.rows = [] + + def insert_link(self, **kwargs): + self.rows.append({"id": len(self.rows) + 1, **kwargs}) + return len(self.rows) + + +class _CheckpointService: + def __init__(self): + self.rows = [] + + def save_checkpoint(self, **kwargs): + row = {"id": len(self.rows) + 1, **kwargs} + self.rows.append(row) + return row + + def build_resume_state(self, *, instrument_id): + latest = self.rows[-1] if self.rows else None + return { + "instrument_id": instrument_id, + "checkpoint_key": "research_council_run", + "can_resume": bool(latest and latest.get("status") != "completed"), + "latest_checkpoint": latest, + } + + +class _MemoryService: + def __init__(self): + self.backfill_calls = [] + self.lesson_calls = [] + + def backfill_all_sources(self, **kwargs): + self.backfill_calls.append(kwargs) + return {"instrument_id": kwargs["instrument_id"], "totals": {"requested": 1, "created": 1, "updated": 0}} + + def fetch_compact_lessons(self, **kwargs): + self.lesson_calls.append(kwargs) + return [{"lesson_key": "journal:1", "title": "Lesson"}] + + +class ResearchCouncilServiceTests(unittest.TestCase): + def _build_service(self): + builder = CouncilContextBuilder( + market_state_service=_FakeMarketStateService(), + decision_service=_FakeDecisionService(), + decision_repository=_FakeDecisionRepository(), + weekly_review_service=_FakeWeeklyReviewService(), + journal_repository=_FakeJournalRepository(), + visual_watch_service=_FakeVisualWatchService(), + memory_service=_FakeMemoryService(), + ) + checkpoint_service = _CheckpointService() + memory_service = _MemoryService() + return ResearchCouncilService( + context_builder=builder, + orchestrator=CouncilOrchestrator(), + run_repository=_RunRepo(), + message_repository=_MessageRepo(), + link_repository=_LinkRepo(), + checkpoint_service=checkpoint_service, + memory_service=memory_service, + ) + + def test_build_preview_returns_run_payload(self) -> None: + service = self._build_service() + + payload = service.build_preview(instrument_id=1, timeframe="1m", model_code="SWEEP_MSS_FVG") + + self.assertIn("decision", payload) + self.assertIn("context", payload) + self.assertEqual(payload["decision"]["overall_stance"], "observe_only") + + def test_build_and_persist_returns_latest_run_with_messages(self) -> None: + service = self._build_service() + + payload = service.build_and_persist(instrument_id=1, timeframe="1m", model_code="SWEEP_MSS_FVG") + + self.assertEqual(payload["id"], 1) + self.assertEqual(payload["instrument_id"], 1) + self.assertIn("messages", payload) + self.assertGreaterEqual(len(payload["messages"]), 8) + self.assertEqual(payload["messages"][-1]["agent_role"], "research_manager") + + def test_resume_state_reads_latest_checkpoint(self) -> None: + service = self._build_service() + service.build_and_persist(instrument_id=1, timeframe="1m", model_code="SWEEP_MSS_FVG") + + payload = service.get_resume_state(instrument_id=1) + + self.assertFalse(payload["can_resume"]) + self.assertEqual(payload["latest_checkpoint"]["stage"], "run_completed") + + def test_service_exposes_lesson_backfill_and_compact_fetch(self) -> None: + service = self._build_service() + + backfill = service.backfill_lessons(instrument_id=1, limit=10) + lessons = service.get_compact_lessons(instrument_id=1, session_code="LONDON", limit=1) + + self.assertEqual(backfill["totals"]["created"], 1) + self.assertEqual(lessons[0]["lesson_key"], "journal:1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_review_packet_research_summary.py b/tests/unit/test_review_packet_research_summary.py new file mode 100644 index 0000000..7ba0daf --- /dev/null +++ b/tests/unit/test_review_packet_research_summary.py @@ -0,0 +1,93 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.operator.operator_workspace_service import ReviewPacketService + + +class _FakeDecisionService: + def build_preview(self, *, instrument_id: int): + return {"id": 11, "instrument_id": instrument_id, "status": "monitoring", "action_recommendation": "restricted_decision_support"} + + +class _FakeNoteService: + def get_recent_notes(self, *, instrument_id: int, limit: int = 10): + return [{"id": 1, "body": "note"}] + + +class _FakeExecutionService: + def build_execution_dashboard_items(self, *, instrument_id: int, limit: int = 10): + return [{"execution_id": 7, "execution_status": "open"}] + + +class _FakeWeeklyReviewService: + def build_summary(self, *, instrument_id: int): + return {"entry_count": 2} + + +class _FakeTrustService: + def build_summary(self, *, instrument_id: int): + return {"models": []} + + +class _FakeDecisionRepository: + def fetch_recent_decision_alerts(self, *, instrument_id: int, limit: int = 10): + return [{"id": 3, "alert_type": "candidate_blocked"}] + + +class _FakeResearchCouncilService: + def get_latest_run(self, *, instrument_id: int): + return { + "id": 77, + "status": "completed", + "payload": { + "decision": { + "research_summary": "Council wants more confirmation.", + "operator_summary": "Wait for MSS.", + "overall_stance": "observe_only", + "committee_confidence": 0.62, + "follow_up_checks": ["mss_confirmed"], + "next_confirmation_targets": ["mss_confirmed", "fvg_retrace_confirmed"], + "recommended_watch_items": ["news_window_review"], + "operator_questions": ["Has MSS confirmed?"], + } + }, + } + + +class _FakeOptimizationReviewService: + def build_review(self, *, instrument_id: int): + return { + "present": True, + "health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + } + + +class ReviewPacketResearchSummaryTests(unittest.TestCase): + def test_review_packet_surfaces_research_council_summary(self) -> None: + service = ReviewPacketService( + note_service=_FakeNoteService(), + decision_service=_FakeDecisionService(), + execution_service=_FakeExecutionService(), + trust_service=_FakeTrustService(), + weekly_review_service=_FakeWeeklyReviewService(), + decision_repository=_FakeDecisionRepository(), + research_council_service=_FakeResearchCouncilService(), + optimization_review_service=_FakeOptimizationReviewService(), + ) + + packet = service.build_packet(instrument_id=1) + + self.assertEqual(packet["research_council_summary"]["run_id"], 77) + self.assertEqual(packet["research_council_summary"]["research_summary"], "Council wants more confirmation.") + self.assertEqual(packet["research_council_summary"]["operator_summary"], "Wait for MSS.") + self.assertEqual(packet["research_council_summary"]["overall_stance"], "observe_only") + self.assertEqual(packet["research_council_summary"]["committee_confidence"], 0.62) + self.assertEqual(packet["research_council_summary"]["follow_up_checks"], ["mss_confirmed"]) + self.assertIn("fvg_retrace_confirmed", packet["research_council_summary"]["next_confirmation_targets"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_run_until_blocked.py b/tests/unit/test_run_until_blocked.py index 2388b02..c396247 100644 --- a/tests/unit/test_run_until_blocked.py +++ b/tests/unit/test_run_until_blocked.py @@ -13,6 +13,1194 @@ from scripts import run_until_blocked class RunUntilBlockedTests(unittest.TestCase): + def setUp(self) -> None: + self._status_tmp = tempfile.TemporaryDirectory() + self._original_runner_status_path = run_until_blocked.RUNNER_STATUS_PATH + self._original_monitor_history_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH + self._original_monitor_trend_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH + self._original_monitor_quality_gate_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH + self._original_monitor_maturity_rollup_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH + self._original_monitor_remote_drift_check_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH + self._original_support_quality_review_path = run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH + self._original_monitor_p7_selector_path = run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH + run_until_blocked.RUNNER_STATUS_PATH = Path(self._status_tmp.name) / "automation_status.json" + run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_history.jsonl" + run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_trend.json" + run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_quality_gate.json" + run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_maturity_rollup.json" + run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_remote_drift_check.json" + run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH = Path(self._status_tmp.name) / "analysis_decision_support_quality_review.json" + run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH = Path(self._status_tmp.name) / "analysis_decision_monitor_p7_slice_selector.json" + + def tearDown(self) -> None: + run_until_blocked.RUNNER_STATUS_PATH = self._original_runner_status_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH = self._original_monitor_history_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH = self._original_monitor_trend_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH = self._original_monitor_quality_gate_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH = self._original_monitor_maturity_rollup_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH = self._original_monitor_remote_drift_check_path + run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH = self._original_support_quality_review_path + run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH = self._original_monitor_p7_selector_path + self._status_tmp.cleanup() + + def test_selector_picks_first_unfinished_execution_layer_slice(self) -> None: + selection = run_until_blocked.select_automation_execution_slice( + anchor_state={ + "missing_required_anchors": [], + "completed_execution_slices": ["automation_execution_layer_p0_governance_gate"], + "in_progress_execution_slices": ["automation_execution_layer_p1_anchor_slice_selector"], + }, + tick_payload={ + "status": "no_op_healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + }, + ) + + self.assertEqual(selection["status"], "ready") + self.assertEqual(selection["slice"], "automation_execution_layer_p1_anchor_slice_selector") + self.assertTrue(selection["requires_code"]) + self.assertTrue(selection["already_in_progress"]) + + def test_selector_blocks_when_required_anchors_are_missing(self) -> None: + selection = run_until_blocked.select_automation_execution_slice( + anchor_state={ + "missing_required_anchors": ["docs/missing.md"], + "completed_execution_slices": [], + "in_progress_execution_slices": [], + }, + tick_payload={"status": "no_op_healthy"}, + ) + + self.assertEqual(selection["status"], "blocked") + self.assertEqual(selection["slice"], "repair_missing_automation_anchors") + self.assertEqual(selection["blocked_reason"], "missing_required_anchors:docs/missing.md") + self.assertFalse(selection["requires_code"]) + + def test_validation_plan_for_execution_layer_slice_uses_focused_checks(self) -> None: + plan = run_until_blocked.build_validation_plan( + selected_slice={ + "slice": "automation_execution_layer_p2_validation_quality_router", + "requires_code": True, + }, + changed_files=["scripts/run_until_blocked.py", "tests/unit/test_run_until_blocked.py"], + ) + + self.assertIn("D:\\ai-exchange\\.venv\\Scripts\\python.exe -m unittest tests.unit.test_run_until_blocked", plan["focused_commands"]) + self.assertIn("D:\\ai-exchange\\.venv\\Scripts\\python.exe scripts/run_script_static_checks.py", plan["quality_gates"]) + self.assertIn("npm run lint", plan["quality_gates"]) + self.assertFalse(plan["deploy_required"]) + + def test_validation_plan_requires_deploy_for_declared_runtime_api_changes(self) -> None: + plan = run_until_blocked.build_validation_plan( + selected_slice={ + "slice": "automation_execution_layer_p3_deploy_smoke_router", + "requires_code": True, + }, + changed_files=["src/api/research_council.py"], + ) + + self.assertTrue(plan["deploy_required"]) + self.assertEqual(plan["deploy_reason"], "declared_runtime_api_db_or_workbench_change") + self.assertIn("deploy_smoke_required_by_declared_runtime_api_db_or_workbench_change", plan["notes"]) + self.assertTrue(plan["deploy_smoke_plan"]["deploy_required"]) + self.assertIn("/health", plan["deploy_smoke_plan"]["smoke_targets"]) + self.assertIn("POST /trading/research-council/governance-tick", plan["deploy_smoke_plan"]["smoke_targets"]) + + def test_deploy_smoke_plan_stays_local_for_script_docs_state_changes(self) -> None: + plan = run_until_blocked.build_validation_plan( + selected_slice={ + "slice": "automation_execution_layer_p3_deploy_smoke_router", + "requires_code": True, + }, + changed_files=[ + "scripts/run_until_blocked.py", + "tests/unit/test_run_until_blocked.py", + "docs/AI_ICT_自动化流程使用教程_v1.md", + "SESSION.md", + "TASKS.md", + ], + ) + + self.assertFalse(plan["deploy_required"]) + self.assertFalse(plan["deploy_smoke_plan"]["deploy_required"]) + self.assertEqual(plan["deploy_smoke_plan"]["smoke_targets"], []) + + def test_state_writeback_plan_tracks_status_and_next_slice(self) -> None: + summary = run_until_blocked.build_automation_execution_summary( + started_ts="2026-05-11T00:00:00+00:00", + tick_payload={ + "status": "no_op_healthy", + "effective_health_state": "healthy", + "safe_to_auto_continue": True, + "recommended_actions": ["continue_periodic_optimization_cycle"], + "boundary_notes": [], + }, + anchor_state={ + "missing_required_anchors": [], + "completed_execution_slices": [ + "automation_execution_layer_p0_governance_gate", + "automation_execution_layer_p1_anchor_slice_selector", + "automation_execution_layer_p2_validation_quality_router", + "automation_execution_layer_p3_deploy_smoke_router", + ], + "in_progress_execution_slices": ["automation_execution_layer_p4_state_writeback_renderer"], + }, + changed_files=["scripts/run_until_blocked.py"], + ) + + plan = summary["state_writeback_plan"] + self.assertEqual(summary["next_slice"], "automation_execution_layer_p4_state_writeback_renderer") + self.assertEqual(plan["state"], "automation_execution_layer_p3_deploy_smoke_router_closed") + self.assertIn("SESSION.md", plan["files_to_write"]) + self.assertIn("TASKS.md", plan["files_to_write"]) + self.assertTrue(plan["no_new_phase_created"]) + self.assertIn("execution_allowed", plan["guards_unchanged"]) + + def test_automation_execution_layer_noops_on_healthy_governance_tick(self) -> None: + class _HealthyGovernanceTickService: + def run_tick(self, **kwargs): + return { + "status": "no_op_healthy", + "passed": True, + "instrument_id": kwargs["instrument_id"], + "effective_health_state": "healthy", + "safe_to_auto_continue": True, + "requires_cycle_run": False, + "requires_bounded_slice": False, + "recommended_actions": ["continue_periodic_optimization_cycle"], + "boundary_notes": [ + "governance_tick_does_not_change_execution_gate", + "governance_tick_does_not_change_live_readiness", + "governance_tick_does_not_change_trust_bucket", + ], + } + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n- state: automation_flow_p0_p6_review_closed\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n- [ ] Start automation execution-layer development\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_service = run_until_blocked.CouncilGovernanceTickService + original_rehearsal = run_until_blocked.run_trading_delivery_rehearsal + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.CouncilGovernanceTickService = _HealthyGovernanceTickService + run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: (_ for _ in ()).throw( + AssertionError("legacy stage runner should not be called") + ) + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.CouncilGovernanceTickService = original_service + run_until_blocked.run_trading_delivery_rehearsal = original_rehearsal + + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["mode"], "automation_execution_layer") + self.assertEqual(summary["tick_status"], "no_op_healthy") + self.assertEqual(summary["next_slice"], "automation_execution_layer_p1_anchor_slice_selector") + self.assertEqual(summary["selected_slice"]["reason"], "first_unfinished_execution_layer_slice") + self.assertEqual(summary["anchor_state"]["state"], "automation_flow_p0_p6_review_closed") + self.assertIn("npm run lint", summary["validation_plan"]["quality_gates"]) + self.assertTrue(summary["no_new_phase_created"]) + self.assertIn("automation_execution_layer_p0_governance_gate_closed", session_text) + self.assertIn("Automation Execution-Layer P0", tasks_text) + self.assertNotIn("### P7", session_text) + self.assertNotIn("- [ ] `P7", tasks_text) + self.assertEqual(status_payload["state"], "completed") + self.assertEqual(status_payload["mode"], "automation_execution_layer") + self.assertEqual(status_payload["tick_status"], "no_op_healthy") + + def test_analysis_decision_monitor_phase_runs_monitor_instead_of_legacy_stages(self) -> None: + monitor_payload = { + "passed": True, + "generated_ts": "2026-05-11T04:45:00+00:00", + "phase": "analysis_decision_only", + "blockers": [], + "warnings": [], + "observed": { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_stream": "market_stream_connected", + "market_freshness": "market_data_fresh", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_required": False, + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + "p6_governance_tick_status": "no_op_healthy", + }, + } + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "- state: analysis_decision_monitor_runner_deployed\n" + "- current_phase: Restricted Decision-Support Soft Rollout\n" + "- repair_next_action: run_analysis_decision_monitor_periodically_without_fill_gate\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + "- [ ] `run_analysis_decision_monitor_periodically_without_fill_gate`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_monitor = run_until_blocked.run_analysis_decision_monitor.run_monitor + original_rehearsal = run_until_blocked.run_trading_delivery_rehearsal + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.run_analysis_decision_monitor.run_monitor = lambda **kwargs: monitor_payload + run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: (_ for _ in ()).throw( + AssertionError("legacy stage runner should not be called") + ) + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.run_analysis_decision_monitor.run_monitor = original_monitor + run_until_blocked.run_trading_delivery_rehearsal = original_rehearsal + + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["mode"], "analysis_decision_monitor") + self.assertTrue(summary["monitor_passed"]) + self.assertEqual(summary["next_slice"], "continue_periodic_analysis_decision_monitoring") + self.assertIsNone(summary["blocked_reason"]) + self.assertIn("analysis_decision_monitor_runner_automation_wired", session_text) + self.assertIn("latest_analysis_decision_monitor_status: passed", session_text) + self.assertIn("latest_real_trade_sample_gate_status: deferred_analysis_decision_only", session_text) + self.assertIn("continue_periodic_analysis_decision_monitoring", tasks_text) + self.assertNotIn("wait_for_and_verify_real_fill_sample", session_text) + self.assertEqual(status_payload["state"], "completed") + self.assertEqual(status_payload["mode"], "analysis_decision_monitor") + self.assertEqual(status_payload["monitor_blockers"], []) + self.assertEqual(summary["monitor_history"]["record_count"], 1) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH.exists()) + history_record = json.loads(run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH.read_text(encoding="utf-8").strip()) + self.assertTrue(history_record["monitor_passed"]) + self.assertEqual(history_record["observed"]["broker_fills"], 0) + self.assertIn("latest_analysis_decision_monitor_history_records: 1", session_text) + self.assertIn("latest_analysis_decision_monitor_history_records: 1", tasks_text) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH.exists()) + trend_payload = json.loads(run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH.read_text(encoding="utf-8")) + self.assertEqual(trend_payload["record_count"], 1) + self.assertEqual(trend_payload["consecutive_pass_count"], 1) + self.assertFalse(trend_payload["stable_monitoring_window"]) + self.assertIn("latest_analysis_decision_monitor_consecutive_passes: 1", session_text) + self.assertIn("latest_analysis_decision_monitor_stable_window: false", tasks_text) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.exists()) + quality_gate_payload = json.loads(run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.read_text(encoding="utf-8")) + self.assertEqual(quality_gate_payload["status"], "attention_required") + self.assertIn("stable_monitoring_window_not_reached", quality_gate_payload["failure_reasons"]) + self.assertIn("latest_analysis_decision_monitor_quality_gate_status: attention_required", session_text) + + def test_analysis_decision_monitor_routes_to_p7_after_stable_quality_gate(self) -> None: + observed = { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_stream": "market_stream_connected", + "market_freshness": "market_data_fresh", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_required": False, + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + "p6_governance_tick_status": "no_op_healthy", + } + existing_records = [ + { + "recorded_ts": "2026-05-11T04:35:00+00:00", + "monitor_generated_ts": "2026-05-11T04:35:00+00:00", + "status": "completed", + "monitor_passed": True, + "blockers": [], + "warnings": [], + "observed": observed, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": ["execution_allowed"], + }, + { + "recorded_ts": "2026-05-11T04:40:00+00:00", + "monitor_generated_ts": "2026-05-11T04:40:00+00:00", + "status": "completed", + "monitor_passed": True, + "blockers": [], + "warnings": [], + "observed": observed, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": ["execution_allowed"], + }, + ] + run_until_blocked.ANALYSIS_DECISION_MONITOR_HISTORY_PATH.write_text( + "\n".join(json.dumps(record, sort_keys=True) for record in existing_records) + "\n", + encoding="utf-8", + ) + monitor_payload = { + "passed": True, + "generated_ts": "2026-05-11T04:45:00+00:00", + "phase": "analysis_decision_only", + "blockers": [], + "warnings": [], + "observed": observed, + } + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "- state: analysis_decision_monitor_runner_deployed\n" + "- current_phase: Restricted Decision-Support Soft Rollout\n" + "- repair_next_action: continue_periodic_analysis_decision_monitoring\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + "- [ ] `continue_periodic_analysis_decision_monitoring`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_monitor = run_until_blocked.run_analysis_decision_monitor.run_monitor + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.run_analysis_decision_monitor.run_monitor = lambda **kwargs: monitor_payload + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.run_analysis_decision_monitor.run_monitor = original_monitor + + self.assertEqual(summary["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE) + self.assertEqual(summary["monitor_history"]["record_count"], 3) + self.assertEqual(summary["monitor_history"]["quality_gate"]["status"], "passed") + self.assertEqual( + summary["monitor_history"]["quality_gate"]["next_slice"], + run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + ) + self.assertIn(run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, session_text) + self.assertIn("automation execution-layer P7", session_text) + self.assertIn("Stable monitor quality may enter automation execution-layer P7", tasks_text) + + def test_p7_monitor_maturity_rollup_closes_and_routes_to_remote_drift_check(self) -> None: + observed = { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + } + trend = { + "record_count": 9, + "consecutive_pass_count": 9, + "stable_sample_threshold": 3, + "stable_monitoring_window": True, + "latest_monitor_passed": True, + "latest_monitor_generated_ts": "2026-05-11T08:47:56+00:00", + "latest_blockers": [], + "latest_warnings": [], + "latest_observed": observed, + "next_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + } + quality_gate = { + "status": "passed", + "passed": True, + "failure_reasons": [], + "record_count": 9, + "consecutive_pass_count": 9, + "stable_monitoring_window": True, + "latest_monitor_generated_ts": "2026-05-11T08:47:56+00:00", + "latest_observed": observed, + "next_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "guards_unchanged": trend["guards_unchanged"], + } + run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH.write_text( + json.dumps(trend), + encoding="utf-8", + ) + run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.write_text( + json.dumps(quality_gate), + encoding="utf-8", + ) + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "### automation_execution_layer_p7_monitor_maturity_rollup\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + f"- [ ] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE}`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + + self.assertEqual(summary["mode"], "analysis_decision_monitor_maturity_rollup") + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["rollup"]["state"], "automation_execution_layer_p7_monitor_maturity_rollup_closed") + self.assertEqual(summary["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.exists()) + rollup_payload = json.loads(run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.read_text(encoding="utf-8")) + self.assertEqual(rollup_payload["maturity_status"], "mature") + self.assertEqual(rollup_payload["remote_drift_check_status"], "pending") + self.assertIn("automation_execution_layer_p7_monitor_maturity_rollup_closed", session_text) + self.assertIn(run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE, tasks_text) + self.assertEqual(status_payload["mode"], "analysis_decision_monitor_maturity_rollup") + + def test_p7_remote_drift_check_closes_and_routes_to_quality_review(self) -> None: + observed = { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + "p6_governance_tick_status": "no_op_healthy", + } + guards = [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ] + local_rollup = { + "maturity_status": "mature", + "local": { + "quality_gate_status": "passed", + "record_count": 9, + "consecutive_pass_count": 9, + "stable_monitoring_window": True, + }, + "latest_observed": observed, + "guards_unchanged": guards, + } + remote_quality = { + "status": "passed", + "passed": True, + "record_count": 12, + "consecutive_pass_count": 4, + "stable_monitoring_window": True, + "latest_observed": observed, + "guards_unchanged": guards, + } + remote_state = { + "status": "available", + "host": "192.168.3.90", + "container_quality_gate": remote_quality, + "container_trend": {"stable_sample_threshold": 3, "latest_observed": observed}, + "host_quality_gate": remote_quality, + } + run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.write_text( + json.dumps(local_rollup), + encoding="utf-8", + ) + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "### automation_execution_layer_p7_remote_drift_check\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + f"- [ ] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE}`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_fetch_remote = run_until_blocked.fetch_remote_analysis_decision_monitor_state + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.fetch_remote_analysis_decision_monitor_state = lambda *, env: remote_state + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.fetch_remote_analysis_decision_monitor_state = original_fetch_remote + + self.assertEqual(summary["mode"], "analysis_decision_monitor_remote_drift_check") + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["drift_check"]["drift_status"], "clear") + self.assertEqual(summary["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.exists()) + self.assertIn("automation_execution_layer_p7_remote_drift_check_closed", session_text) + self.assertIn(run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, tasks_text) + self.assertEqual(status_payload["mode"], "analysis_decision_monitor_remote_drift_check") + + def test_p7_remote_drift_check_blocks_when_remote_quality_not_passed(self) -> None: + local_rollup = { + "maturity_status": "mature", + "local": {"quality_gate_status": "passed"}, + "latest_observed": {"runtime_health": "ok"}, + "guards_unchanged": ["execution_allowed"], + } + remote_state = { + "status": "available", + "container_quality_gate": {"status": "attention_required", "latest_observed": {"runtime_health": "noisy"}}, + "container_trend": {"stable_sample_threshold": 3, "consecutive_pass_count": 0}, + "host_quality_gate": {"status": "attention_required"}, + } + + drift = run_until_blocked.build_analysis_decision_monitor_remote_drift_check( + local_rollup=local_rollup, + remote_state=remote_state, + ) + + self.assertEqual(drift["status"], "blocked") + self.assertEqual(drift["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE) + self.assertIn("remote_container_quality_gate_not_passed", drift["failure_reasons"]) + + def test_p7_selectors_ignore_completed_milestone_mentions(self) -> None: + session_text = ( + "# ai-exchange Session\n\n" + "- current_phase: Restricted Decision-Support Soft Rollout\n" + f"### {run_until_blocked.ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE}\n" + ) + tasks_text = ( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + f"- [ ] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE}`\n\n" + "## Major Done Milestones\n\n" + f"- [x] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE}`\n" + f"- [x] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_REMOTE_DRIFT_SLICE}`\n" + f"- [x] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE}`\n" + f"- [x] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE}`\n" + ) + + self.assertFalse( + run_until_blocked.should_use_analysis_decision_monitor_p7_selector( + session_text=session_text, + tasks_text=tasks_text, + ) + ) + self.assertFalse( + run_until_blocked.should_use_analysis_decision_monitor_maturity_rollup( + session_text=session_text, + tasks_text=tasks_text, + ) + ) + self.assertFalse( + run_until_blocked.should_use_analysis_decision_monitor_remote_drift_check( + session_text=session_text, + tasks_text=tasks_text, + ) + ) + self.assertFalse( + run_until_blocked.should_use_analysis_decision_support_quality_review( + session_text=session_text, + tasks_text=tasks_text, + ) + ) + self.assertTrue( + run_until_blocked.should_use_analysis_decision_monitor( + session_text=session_text, + tasks_text=tasks_text, + ) + ) + + def test_p7_quality_review_closes_and_routes_to_p7_selector(self) -> None: + observed = { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + } + monitor_payload = { + "passed": True, + "generated_ts": "2026-05-11T09:15:00+00:00", + "phase": "analysis_decision_only", + "blockers": [], + "warnings": [], + "observed": observed, + "checks": [ + {"code": "decision_restricted_support", "passed": True}, + {"code": "decision_observe_only", "passed": True}, + {"code": "decision_execution_not_allowed", "passed": True}, + {"code": "review_packet_present", "passed": True}, + {"code": "review_trust_score_present", "passed": True}, + {"code": "review_research_summary_present", "passed": True}, + {"code": "real_trade_sample_gate_deferred", "passed": True}, + ], + } + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "### automation_execution_layer_p7_alert_decision_support_quality_review\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + f"- [ ] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE}`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_monitor = run_until_blocked.run_analysis_decision_monitor.run_monitor + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.run_analysis_decision_monitor.run_monitor = lambda **kwargs: monitor_payload + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.run_analysis_decision_monitor.run_monitor = original_monitor + + self.assertEqual(summary["mode"], "analysis_decision_support_quality_review") + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["quality_review"]["quality_status"], "healthy") + self.assertEqual(summary["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.exists()) + review_payload = json.loads(run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.read_text(encoding="utf-8")) + self.assertEqual(review_payload["state"], "automation_execution_layer_p7_alert_decision_support_quality_review_closed") + self.assertIn("automation_execution_layer_p7_alert_decision_support_quality_review_closed", session_text) + self.assertIn(run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, tasks_text) + self.assertEqual(status_payload["mode"], "analysis_decision_support_quality_review") + + def test_p7_quality_review_blocks_when_required_research_summary_missing(self) -> None: + monitor_payload = { + "passed": True, + "blockers": [], + "warnings": [], + "observed": { + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + }, + "checks": [ + {"code": "decision_restricted_support", "passed": True}, + {"code": "decision_observe_only", "passed": True}, + {"code": "decision_execution_not_allowed", "passed": True}, + {"code": "review_packet_present", "passed": True}, + {"code": "review_trust_score_present", "passed": True}, + {"code": "real_trade_sample_gate_deferred", "passed": True}, + ], + } + + review = run_until_blocked.build_analysis_decision_support_quality_review( + monitor_payload=monitor_payload, + ) + + self.assertEqual(review["status"], "blocked") + self.assertEqual(review["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE) + self.assertIn("required_quality_checks_missing", review["failure_reasons"]) + self.assertIn("review_research_summary_present", review["missing_required_checks"]) + + def test_p7_selector_stage_closes_and_routes_to_maturity_rollup(self) -> None: + guards = ["execution_allowed"] + run_until_blocked.ANALYSIS_DECISION_MONITOR_TREND_PATH.write_text( + json.dumps( + { + "record_count": 10, + "consecutive_pass_count": 10, + "stable_sample_threshold": 3, + "stable_monitoring_window": True, + "latest_monitor_passed": True, + "latest_blockers": [], + "guards_unchanged": guards, + } + ), + encoding="utf-8", + ) + run_until_blocked.ANALYSIS_DECISION_MONITOR_QUALITY_GATE_PATH.write_text( + json.dumps( + { + "status": "passed", + "passed": True, + "stable_monitoring_window": True, + "failure_reasons": [], + "guards_unchanged": guards, + } + ), + encoding="utf-8", + ) + run_until_blocked.ANALYSIS_DECISION_MONITOR_MATURITY_ROLLUP_PATH.write_text( + json.dumps( + { + "state": "automation_execution_layer_p7_monitor_maturity_rollup_closed", + "status": "completed", + "maturity_status": "mature", + "guards_unchanged": guards, + } + ), + encoding="utf-8", + ) + run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_CHECK_PATH.write_text( + json.dumps( + { + "state": "automation_execution_layer_p7_remote_drift_check_closed", + "status": "completed", + "drift_status": "clear", + "failure_reasons": [], + "guards_unchanged": guards, + } + ), + encoding="utf-8", + ) + run_until_blocked.ANALYSIS_DECISION_SUPPORT_QUALITY_REVIEW_PATH.write_text( + json.dumps( + { + "state": "automation_execution_layer_p7_alert_decision_support_quality_review_closed", + "status": "completed", + "quality_status": "healthy", + "failure_reasons": [], + "guards_unchanged": guards, + } + ), + encoding="utf-8", + ) + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + f"### {run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE}\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "## In Progress\n\n" + f"- [ ] `{run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE}`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + + summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "0"}) + session_text = session_path.read_text(encoding="utf-8") + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + + self.assertEqual(summary["mode"], "analysis_decision_monitor_p7_selector") + self.assertEqual(summary["status"], "completed") + self.assertEqual(summary["selector"]["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE) + self.assertTrue(run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_PATH.exists()) + self.assertIn("automation_execution_layer_p7_bounded_automation_slice_selector_closed", session_text) + self.assertIn(run_until_blocked.ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE, tasks_text) + self.assertEqual(status_payload["mode"], "analysis_decision_monitor_p7_selector") + + def test_p7_selector_chooses_maturity_rollup_when_all_inputs_are_healthy(self) -> None: + observed = { + "broker_status": "broker_state_ok", + "broker_fills": 0, + "runtime_health": "ok", + "market_gap": "market_data_gap_clear", + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "decision_trust_classification": "blacklist", + "p6_governance_tick_status": "no_op_healthy", + } + trend = { + "record_count": 10, + "consecutive_pass_count": 10, + "stable_sample_threshold": 3, + "stable_monitoring_window": True, + "latest_monitor_passed": True, + "latest_monitor_generated_ts": "2026-05-11T10:13:03+00:00", + "latest_blockers": [], + "latest_warnings": [], + "latest_observed": observed, + "next_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + } + quality_gate = { + "status": "passed", + "passed": True, + "failure_reasons": [], + "record_count": 10, + "consecutive_pass_count": 10, + "stable_monitoring_window": True, + "latest_monitor_generated_ts": "2026-05-11T10:13:03+00:00", + "latest_observed": observed, + "next_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_NEXT_SLICE, + "guards_unchanged": trend["guards_unchanged"], + } + maturity_rollup = { + "state": "automation_execution_layer_p7_monitor_maturity_rollup_closed", + "status": "completed", + "maturity_status": "mature", + "local": { + "quality_gate_status": "passed", + "record_count": 10, + "consecutive_pass_count": 10, + "stable_monitoring_window": True, + "quality_gate_passed": True, + }, + "latest_observed": observed, + "remote_drift_check_status": "clear", + "guards_unchanged": trend["guards_unchanged"], + } + remote_drift_check = { + "state": "automation_execution_layer_p7_remote_drift_check_closed", + "status": "completed", + "drift_status": "clear", + "local": { + "maturity_status": "mature", + "quality_gate_status": "passed", + "record_count": 10, + "consecutive_pass_count": 10, + "stable_monitoring_window": True, + }, + "remote": { + "status": "available", + "host": "192.168.3.90", + "container_quality_gate_status": "passed", + "container_record_count": 12, + "container_consecutive_pass_count": 4, + "container_stable_monitoring_window": True, + "host_quality_gate_status": "passed", + "host_record_count": 12, + "host_consecutive_pass_count": 4, + "host_stable_monitoring_window": True, + }, + "observed_diffs": {}, + "latest_observed": {"local": observed, "remote": observed}, + "guards_unchanged": trend["guards_unchanged"], + } + quality_review = { + "state": "automation_execution_layer_p7_alert_decision_support_quality_review_closed", + "status": "completed", + "quality_status": "healthy", + "current_phase": "Restricted Decision-Support Soft Rollout", + "current_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_QUALITY_REVIEW_SLICE, + "next_slice": run_until_blocked.ANALYSIS_DECISION_MONITOR_P7_SELECTOR_SLICE, + "failure_reasons": [], + "monitor_blockers": [], + "monitor_warnings": [], + "latest_observed": observed, + "operator_mode": "observe_only", + "decision_action": "restricted_decision_support", + "trust_classification": "blacklist", + "guards_unchanged": trend["guards_unchanged"], + } + + selector = run_until_blocked.build_analysis_decision_monitor_p7_slice_selector( + trend=trend, + quality_gate=quality_gate, + maturity_rollup=maturity_rollup, + remote_drift_check=remote_drift_check, + quality_review=quality_review, + ) + + self.assertEqual(selector["status"], "completed") + self.assertEqual(selector["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_WITH_MATURITY_SLICE) + self.assertEqual(selector["selection_reason"], "all_monitoring_artifacts_healthy") + + def test_p7_selector_routes_to_remote_drift_repair_when_drift_detected(self) -> None: + trend = { + "record_count": 10, + "consecutive_pass_count": 10, + "stable_sample_threshold": 3, + "stable_monitoring_window": True, + "latest_monitor_passed": True, + "latest_blockers": [], + "guards_unchanged": ["execution_allowed"], + } + quality_gate = { + "status": "passed", + "passed": True, + "stable_monitoring_window": True, + "failure_reasons": [], + "guards_unchanged": ["execution_allowed"], + } + maturity_rollup = { + "state": "automation_execution_layer_p7_monitor_maturity_rollup_closed", + "status": "completed", + "maturity_status": "mature", + "local": {"quality_gate_status": "passed"}, + "guards_unchanged": ["execution_allowed"], + } + remote_drift_check = { + "state": "automation_execution_layer_p7_remote_drift_check_blocked", + "status": "blocked", + "drift_status": "drift_detected", + "failure_reasons": ["local_remote_observed_fields_drifted"], + "guards_unchanged": ["execution_allowed"], + } + quality_review = { + "state": "automation_execution_layer_p7_alert_decision_support_quality_review_closed", + "status": "completed", + "quality_status": "healthy", + "failure_reasons": [], + "guards_unchanged": ["execution_allowed"], + } + + selector = run_until_blocked.build_analysis_decision_monitor_p7_slice_selector( + trend=trend, + quality_gate=quality_gate, + maturity_rollup=maturity_rollup, + remote_drift_check=remote_drift_check, + quality_review=quality_review, + ) + + self.assertEqual(selector["next_slice"], run_until_blocked.ANALYSIS_DECISION_MONITOR_REMOTE_DRIFT_REPAIR_SLICE) + self.assertEqual(selector["selection_reason"], "remote_drift_detected") + + def test_analysis_decision_monitor_phase_blocks_on_monitor_blockers(self) -> None: + monitor_payload = { + "passed": False, + "generated_ts": "2026-05-11T04:46:00+00:00", + "phase": "analysis_decision_only", + "blockers": ["market_data_fresh"], + "warnings": [], + "observed": { + "real_trade_sample_gate_required": False, + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + }, + } + + with tempfile.TemporaryDirectory() as tmp: + session_path = Path(tmp) / "SESSION.md" + tasks_path = Path(tmp) / "TASKS.md" + status_path = Path(tmp) / "automation_status.json" + session_path.write_text( + "# ai-exchange Session\n\n" + "- state: analysis_decision_monitor_runner_deployed\n" + "- repair_next_action: run_analysis_decision_monitor_periodically_without_fill_gate\n", + encoding="utf-8", + ) + tasks_path.write_text( + "# ai-exchange Tasks\n\n" + "- [ ] `run_analysis_decision_monitor_periodically_without_fill_gate`\n", + encoding="utf-8", + ) + + original_session = run_until_blocked.SESSION_PATH + original_tasks = run_until_blocked.TASKS_PATH + original_status = run_until_blocked.RUNNER_STATUS_PATH + original_monitor = run_until_blocked.run_analysis_decision_monitor.run_monitor + try: + run_until_blocked.SESSION_PATH = session_path + run_until_blocked.TASKS_PATH = tasks_path + run_until_blocked.RUNNER_STATUS_PATH = status_path + run_until_blocked.run_analysis_decision_monitor.run_monitor = lambda **kwargs: monitor_payload + + summary = run_until_blocked.run_until_blocked(env={}) + tasks_text = tasks_path.read_text(encoding="utf-8") + status_payload = json.loads(status_path.read_text(encoding="utf-8")) + finally: + run_until_blocked.SESSION_PATH = original_session + run_until_blocked.TASKS_PATH = original_tasks + run_until_blocked.RUNNER_STATUS_PATH = original_status + run_until_blocked.run_analysis_decision_monitor.run_monitor = original_monitor + + self.assertEqual(summary["status"], "blocked") + self.assertEqual(summary["next_slice"], "repair_analysis_decision_monitor_blockers") + self.assertEqual(summary["blocked_reason"], "analysis_decision_monitor_blockers:market_data_fresh") + self.assertIn("analysis_decision_monitor_blockers:market_data_fresh", tasks_text) + self.assertEqual(status_payload["state"], "blocked") + self.assertEqual(status_payload["blocked_reason"], "analysis_decision_monitor_blockers:market_data_fresh") + self.assertEqual(summary["monitor_history"]["record_count"], 1) + + def test_analysis_decision_monitor_trend_marks_stable_window_after_three_passes(self) -> None: + records = [ + { + "recorded_ts": "2026-05-11T00:00:00+00:00", + "monitor_generated_ts": "2026-05-11T00:00:00+00:00", + "status": "completed", + "monitor_passed": True, + "blockers": [], + "warnings": [], + "observed": {"broker_status": "broker_state_ok"}, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": ["execution_allowed"], + }, + { + "recorded_ts": "2026-05-11T00:05:00+00:00", + "monitor_generated_ts": "2026-05-11T00:05:00+00:00", + "status": "completed", + "monitor_passed": True, + "blockers": [], + "warnings": [], + "observed": {"broker_status": "broker_state_ok"}, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": ["execution_allowed"], + }, + { + "recorded_ts": "2026-05-11T00:10:00+00:00", + "monitor_generated_ts": "2026-05-11T00:10:00+00:00", + "status": "completed", + "monitor_passed": True, + "blockers": [], + "warnings": [], + "observed": {"broker_status": "broker_state_ok"}, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": ["execution_allowed"], + }, + ] + + trend = run_until_blocked.build_analysis_decision_monitor_trend(records=records) + + self.assertEqual(trend["record_count"], 3) + self.assertEqual(trend["consecutive_pass_count"], 3) + self.assertTrue(trend["stable_monitoring_window"]) + self.assertEqual(trend["latest_observed"]["broker_status"], "broker_state_ok") + + def test_analysis_decision_monitor_quality_gate_passes_for_stable_observe_only_window(self) -> None: + trend = { + "record_count": 3, + "consecutive_pass_count": 3, + "stable_monitoring_window": True, + "latest_monitor_passed": True, + "latest_blockers": [], + "latest_warnings": [], + "latest_monitor_generated_ts": "2026-05-11T00:10:00+00:00", + "latest_observed": { + "decision_action": "restricted_decision_support", + "decision_operator_mode": "observe_only", + "real_trade_sample_gate_required": False, + "real_trade_sample_gate_status": "deferred_analysis_decision_only", + "runtime_health": "ok", + "market_gap": "market_data_gap_clear", + "p6_governance_tick_status": "no_op_healthy", + }, + "next_slice": "continue_periodic_analysis_decision_monitoring", + "guards_unchanged": [ + "execution_allowed", + "real_live_ready", + "risk_governor", + "trust_bucket", + "operator_confirmation_protocol", + ], + } + + gate = run_until_blocked.build_analysis_decision_monitor_quality_gate(trend=trend) + + self.assertTrue(gate["passed"]) + self.assertEqual(gate["status"], "passed") + self.assertEqual(gate["failure_reasons"], []) + self.assertIn("execution_gate_unchanged", gate["notes"]) + def test_advances_to_next_stage_and_updates_anchors(self) -> None: with tempfile.TemporaryDirectory() as tmp: session_path = Path(tmp) / "SESSION.md" @@ -156,23 +1344,27 @@ class RunUntilBlockedTests(unittest.TestCase): sleeper = Mock() original_run_until_blocked = run_until_blocked.run_until_blocked original_fingerprint = run_until_blocked.compute_resume_fingerprint + original_status = run_until_blocked.RUNNER_STATUS_PATH try: - run_until_blocked.run_until_blocked = lambda **kwargs: { - "status": "blocked", - "started_ts": "2026-05-07T00:00:00+00:00", - "current_stage": "P0-2", - "completed_stages": ["P0-1"], - "blocked_reason": "autonomous_mode:pipeline_failed", - "rounds": [{"round": 1}], - } - run_until_blocked.compute_resume_fingerprint = lambda: "same" - result = run_until_blocked.run_supervised_until_stopped( - env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"}, - sleeper=sleeper, - ) + with tempfile.TemporaryDirectory() as tmp: + run_until_blocked.RUNNER_STATUS_PATH = Path(tmp) / "automation_status.json" + run_until_blocked.run_until_blocked = lambda **kwargs: { + "status": "blocked", + "started_ts": "2026-05-07T00:00:00+00:00", + "current_stage": "P0-2", + "completed_stages": ["P0-1"], + "blocked_reason": "autonomous_mode:pipeline_failed", + "rounds": [{"round": 1}], + } + run_until_blocked.compute_resume_fingerprint = lambda: "same" + result = run_until_blocked.run_supervised_until_stopped( + env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"}, + sleeper=sleeper, + ) finally: run_until_blocked.run_until_blocked = original_run_until_blocked run_until_blocked.compute_resume_fingerprint = original_fingerprint + run_until_blocked.RUNNER_STATUS_PATH = original_status sleeper.assert_called_once_with(1) self.assertEqual(result["status"], "blocked") @@ -200,16 +1392,20 @@ class RunUntilBlockedTests(unittest.TestCase): sleeper = Mock() original_run_until_blocked = run_until_blocked.run_until_blocked original_fingerprint = run_until_blocked.compute_resume_fingerprint + original_status = run_until_blocked.RUNNER_STATUS_PATH try: - run_until_blocked.run_until_blocked = lambda **kwargs: summaries.pop(0) - run_until_blocked.compute_resume_fingerprint = lambda: fingerprints.pop(0) - result = run_until_blocked.run_supervised_until_stopped( - env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"}, - sleeper=sleeper, - ) + with tempfile.TemporaryDirectory() as tmp: + run_until_blocked.RUNNER_STATUS_PATH = Path(tmp) / "automation_status.json" + run_until_blocked.run_until_blocked = lambda **kwargs: summaries.pop(0) + run_until_blocked.compute_resume_fingerprint = lambda: fingerprints.pop(0) + result = run_until_blocked.run_supervised_until_stopped( + env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"}, + sleeper=sleeper, + ) finally: run_until_blocked.run_until_blocked = original_run_until_blocked run_until_blocked.compute_resume_fingerprint = original_fingerprint + run_until_blocked.RUNNER_STATUS_PATH = original_status sleeper.assert_called_once_with(1) self.assertEqual(result["status"], "completed") diff --git a/tests/unit/test_run_visual_review.py b/tests/unit/test_run_visual_review.py new file mode 100644 index 0000000..30a6e34 --- /dev/null +++ b/tests/unit/test_run_visual_review.py @@ -0,0 +1,54 @@ +import contextlib +import io +import json +import unittest +from unittest.mock import patch + +from scripts import run_visual_review + + +class RunVisualReviewScriptTests(unittest.TestCase): + def test_main_outputs_json_for_pending_reviews(self) -> None: + with patch("scripts.run_visual_review.VisualReviewService") as mock_service: + mock_service.return_value.review_pending_watch_runs.return_value = { + "status": "completed", + "attempted": 1, + "reviewed": 1, + "items": [ + { + "watch_run_id": 11, + "visual_state": "confirm", + "snapshot_id": 333, + } + ], + } + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = run_visual_review.main(env={"VISUAL_REVIEW_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["status"], "completed") + self.assertEqual(payload["reviewed"], 1) + + def test_main_reviews_explicit_watch_run(self) -> None: + with patch("scripts.run_visual_review.VisualReviewService") as mock_service: + mock_service.return_value.review_watch_run.return_value = { + "status": "reviewed", + "watch_run_id": 99, + "visual_state": "caution", + } + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = run_visual_review.main( + env={"VISUAL_REVIEW_WATCH_RUN_ID": "99", "VISUAL_REVIEW_OUTPUT_JSON": "1"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["watch_run_id"], 99) + self.assertEqual(payload["visual_state"], "caution") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_run_visual_snapshot_capture.py b/tests/unit/test_run_visual_snapshot_capture.py new file mode 100644 index 0000000..c7e3ee3 --- /dev/null +++ b/tests/unit/test_run_visual_snapshot_capture.py @@ -0,0 +1,33 @@ +import contextlib +import io +import json +import unittest +from unittest.mock import patch + +from scripts import run_visual_snapshot_capture + + +class RunVisualSnapshotCaptureScriptTests(unittest.TestCase): + def test_main_outputs_json_for_capture(self) -> None: + with patch("scripts.run_visual_snapshot_capture.capture_visual_watch_snapshot") as capture_mock: + capture_mock.return_value = { + "status": "captured", + "watch_run_id": 11, + "snapshot_id": 55, + "image_path": "captures/watch-11.html", + "artifact_path": "captures/watch-11.json", + } + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = run_visual_snapshot_capture.main( + env={"VISUAL_CAPTURE_WATCH_RUN_ID": "11", "VISUAL_CAPTURE_OUTPUT_JSON": "1"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["status"], "captured") + self.assertEqual(payload["snapshot_id"], 55) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index db30059..d65a2ac 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -24,6 +24,14 @@ from scripts import ( run_ranges, run_decision_packet, run_launch_readiness, + run_research_council_arena, + run_research_council_backfill_lessons, + run_research_council, + run_research_council_eval, + run_research_council_governance_tick, + run_research_council_optimization_loop, + run_research_council_optimization_review, + run_research_council_replay, run_review_packet, run_session_generator, run_live_readiness, @@ -140,6 +148,27 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(exit_code, 0) self.assertEqual(payload["instrument_id"], 2) + def test_run_decision_packet_json_handles_nonprimitive_values(self) -> None: + service = type( + "Service", + (), + { + "build_persist_and_alert": lambda self, instrument_id: { + "id": 1, + "status": "actionable", + "instrument_id": instrument_id, + "packet_ts": object(), + } + }, + )() + output = io.StringIO() + with patch("scripts.run_decision_packet.DecisionPacketService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_decision_packet.main(env={"DECISION_PACKET_OUTPUT_JSON": "1", "DECISION_PACKET_INSTRUMENT_ID": "2"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["instrument_id"], 2) + def test_run_broker_reconciliation_outputs_json(self) -> None: service = type("Service", (), {"reconcile_instrument": lambda self, instrument_id: {"status": "ok", "instrument_id": instrument_id, "matched_execution_ids": [1]}})() output = io.StringIO() @@ -172,6 +201,266 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(exit_code, 0) self.assertEqual(payload["instrument_id"], 2) + def test_run_research_council_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "build_and_persist": lambda self, **kwargs: {"id": 5, **kwargs}, + "get_resume_state": lambda self, instrument_id: {"instrument_id": instrument_id, "can_resume": False}, + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + "RESEARCH_COUNCIL_TIMEFRAME": "5m", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["instrument_id"], 2) + self.assertEqual(payload["timeframe"], "5m") + + def test_run_research_council_json_handles_nonprimitive_values(self) -> None: + service = type( + "Service", + (), + { + "build_and_persist": lambda self, **kwargs: {"id": 5, "generated_ts": object(), **kwargs}, + "get_resume_state": lambda self, instrument_id: {"instrument_id": instrument_id, "can_resume": False}, + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + + def test_run_research_council_outputs_checkpoint_status_json(self) -> None: + service = type( + "Service", + (), + { + "get_resume_state": lambda self, instrument_id: { + "instrument_id": instrument_id, + "can_resume": True, + "latest_checkpoint": {"stage": "run_persisted"}, + } + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_CHECKPOINT_STATUS": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertTrue(payload["can_resume"]) + self.assertEqual(payload["latest_checkpoint"]["stage"], "run_persisted") + + def test_run_research_council_backfill_lessons_outputs_json(self) -> None: + service = type("Service", (), {"backfill_lessons": lambda self, **kwargs: {"instrument_id": kwargs["instrument_id"], "totals": {"requested": 1, "created": 1, "updated": 0}}})() + output = io.StringIO() + with patch("scripts.run_research_council_backfill_lessons.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_backfill_lessons.main( + env={"RESEARCH_COUNCIL_OUTPUT_JSON": "1", "RESEARCH_COUNCIL_INSTRUMENT_ID": "2"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["totals"]["created"], 1) + + def test_run_research_council_replay_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "get_resume_state": lambda self, instrument_id: {"instrument_id": instrument_id, "can_resume": False}, + "build_preview": lambda self, **kwargs: {"decision": {"overall_stance": "observe_only"}, "context": {"compact_lessons": []}}, + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_replay.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_replay.main( + env={"RESEARCH_COUNCIL_OUTPUT_JSON": "1", "RESEARCH_COUNCIL_INSTRUMENT_ID": "2"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["preview"]["decision"]["overall_stance"], "observe_only") + + def test_run_research_council_eval_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "get_recent_runs": lambda self, **kwargs: [ + { + "payload": { + "context": { + "latest_candidate": {"session_code": "LONDON", "model_code": "SWEEP_MSS_FVG", "missing_confirmations": ["mss"]}, + "trust_policy": {"blocking_reasons": ["trust_blocked"]}, + "risk_result": {"reasons": ["risk_blocked"]}, + "compact_lessons": [{"lesson_key": "journal:1"}], + }, + "decision": { + "follow_up_checks": ["mss", "risk_blocked"], + "operator_questions": ["question"], + "disagreement_points": ["debate"], + "committee_confidence": 0.6, + }, + } + } + ] + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_eval.ResearchCouncilService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_eval.main( + env={"RESEARCH_COUNCIL_OUTPUT_JSON": "1", "RESEARCH_COUNCIL_INSTRUMENT_ID": "2"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["run_count"], 1) + + def test_run_research_council_arena_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "build_preview": lambda self, **kwargs: { + "decision": {"overall_stance": "observe_only", "committee_confidence": 0.6, "follow_up_checks": ["x"], "disagreement_points": []}, + "context": {"compact_lessons": [{"lesson_key": "journal:1"}]}, + } + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_arena.ResearchCouncilService", return_value=service), patch( + "scripts.run_research_council_arena.ModelTrustService", + return_value=type("TrustService", (), {"build_summary": lambda self, instrument_id: {"models": [{"model_code": "SWEEP_MSS_FVG"}]}})(), + ), contextlib.redirect_stdout(output): + exit_code = run_research_council_arena.main( + env={"RESEARCH_COUNCIL_OUTPUT_JSON": "1", "RESEARCH_COUNCIL_INSTRUMENT_ID": "2"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["rows"][0]["lesson_count"], 1) + + def test_run_research_council_optimization_loop_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "run_cycle": lambda self, **kwargs: { + "id": 9, + "status": "completed", + "passed": True, + "instrument_id": kwargs["instrument_id"], + "timeframe": kwargs["timeframe"], + "next_actions": ["rerun_optimization_cycle_after_next_market_sample"], + } + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_optimization_loop.CouncilOptimizationService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_optimization_loop.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + "RESEARCH_COUNCIL_TIMEFRAME": "5m", + "RESEARCH_COUNCIL_REPLAY_FILE": "tests/fixtures/offline_replay_sample.jsonl", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["instrument_id"], 2) + self.assertEqual(payload["timeframe"], "5m") + + def test_run_research_council_optimization_review_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "build_review": lambda self, **kwargs: { + "present": True, + "passed": True, + "instrument_id": kwargs["instrument_id"], + "health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + } + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_optimization_review.CouncilOptimizationReviewService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_optimization_review.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + "RESEARCH_COUNCIL_REVIEW_LIMIT": "3", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["health_state"], "healthy") + + def test_run_research_council_governance_tick_outputs_json(self) -> None: + service = type( + "Service", + (), + { + "run_tick": lambda self, **kwargs: { + "status": "no_op_healthy", + "passed": True, + "instrument_id": kwargs["instrument_id"], + "effective_health_state": "healthy", + "recommended_actions": ["continue_periodic_optimization_cycle"], + } + }, + )() + output = io.StringIO() + with patch("scripts.run_research_council_governance_tick.CouncilGovernanceTickService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_research_council_governance_tick.main( + env={ + "RESEARCH_COUNCIL_OUTPUT_JSON": "1", + "RESEARCH_COUNCIL_INSTRUMENT_ID": "2", + "RESEARCH_COUNCIL_GOVERNANCE_FORCE_CYCLE": "0", + } + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["status"], "no_op_healthy") + self.assertEqual(payload["instrument_id"], 2) + def test_run_live_readiness_outputs_json(self) -> None: service = type("Service", (), {"build_live_readiness": lambda self, instrument_id, timeframe: {"instrument_id": instrument_id, "timeframe": timeframe, "ready_for_real_live": False, "blocking_reasons": ["adapter_real_live_not_ready"]}})() output = io.StringIO() diff --git a/tests/unit/test_settings_local_env.py b/tests/unit/test_settings_local_env.py new file mode 100644 index 0000000..4194578 --- /dev/null +++ b/tests/unit/test_settings_local_env.py @@ -0,0 +1,44 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from pydantic_settings import SettingsConfigDict + + +class SettingsLocalEnvTests(unittest.TestCase): + def test_settings_composes_database_url_from_components(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".env.local").write_text( + "DATABASE_HOST=192.168.3.90\n" + "DATABASE_PORT=5432\n" + "DATABASE_NAME=ai_ict\n" + "DATABASE_USER=yuqei\n" + "DATABASE_PASSWORD=abc123\n", + encoding="utf-8", + ) + (root / ".env").write_text("", encoding="utf-8") + + import src.config.settings as settings_module + + with patch.dict(os.environ, {}, clear=True), patch.object(settings_module, "ROOT", root), patch.object( + settings_module.Settings, + "model_config", + SettingsConfigDict( + env_file=(str(root / ".env.local"), str(root / ".env")), + env_file_encoding="utf-8", + extra="ignore", + ), + ): + settings = settings_module.Settings() + + self.assertEqual( + settings.database_url, + "postgresql+psycopg://yuqei:abc123@192.168.3.90:5432/ai_ict", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_trading_runtime_service.py b/tests/unit/test_trading_runtime_service.py index 18d6fbe..6ba3c80 100644 --- a/tests/unit/test_trading_runtime_service.py +++ b/tests/unit/test_trading_runtime_service.py @@ -91,7 +91,15 @@ class FakeDecisionPacketService: "id": 501, "status": "actionable", "action_recommendation": "confirm_ticket", - "alerts": [{"alert_type": "candidate_actionable"}], + "alerts": [ + { + "alert_type": "candidate_actionable", + "payload": { + "visual_trigger_type": "candidate_actionable_watch", + "visual_watch_reservation": {"status": "reserved", "watch_run_id": 801}, + }, + } + ], } @@ -105,6 +113,44 @@ class FakeReconciliationService: } +class FakeAlertDispatcherService: + def dispatch_pending(self, **kwargs): + return {"attempted": 0, "items": []} + + +class FakeVisualReviewService: + def __init__(self, payload=None): + self.payload = payload or {"status": "idle", "attempted": 0, "reviewed": 0, "items": [], "pending_watch_run_ids": []} + self.calls = [] + + def review_pending_watch_runs(self, **kwargs): + self.calls.append(kwargs) + return self.payload + + +class FakeResearchCouncilService: + def __init__(self, *, should_fail=False): + self.should_fail = should_fail + self.calls = [] + + def build_and_persist(self, **kwargs): + self.calls.append(kwargs) + if self.should_fail: + raise RuntimeError("council unavailable") + return { + "id": 601, + "instrument_id": kwargs["instrument_id"], + "status": "completed", + "payload": { + "decision": { + "overall_stance": "decision_support_only", + "committee_confidence": 0.64, + "recommended_watch_items": ["wait_for_candidate_confirmation"], + } + }, + } + + class TradingRuntimeSchedulerTests(unittest.TestCase): def test_run_cycle_records_successful_task_state(self) -> None: scheduler = TradingRuntimeScheduler( @@ -114,6 +160,23 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): broker_read_model_service=FakeBrokerReadModelService(), reconciliation_service=FakeReconciliationService(), decision_packet_service=FakeDecisionPacketService(), + alert_dispatcher_service=FakeAlertDispatcherService(), + visual_review_service=FakeVisualReviewService( + { + "status": "completed", + "attempted": 1, + "reviewed": 1, + "items": [ + { + "watch_run_id": 801, + "visual_state": "confirm", + "snapshot_id": 333, + } + ], + "pending_watch_run_ids": [801], + } + ), + research_council_service=FakeResearchCouncilService(), ) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): payload = scheduler.run_cycle() @@ -124,6 +187,12 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): self.assertEqual(payload["tasks"]["broker_sync"]["details"]["status"], "broker_state_ok") self.assertEqual(payload["tasks"]["reconciliation"]["details"]["matched_execution_ids"], [91]) self.assertEqual(payload["tasks"]["decision_packet"]["details"]["packet_id"], 501) + self.assertEqual(payload["tasks"]["research_council"]["details"]["run_id"], 601) + self.assertEqual(payload["tasks"]["research_council"]["last_status"], "ok") + self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_trigger_types"], ["candidate_actionable_watch"]) + self.assertTrue(payload["tasks"]["decision_packet"]["details"]["visual_watch_reserved"]) + self.assertEqual(payload["tasks"]["visual_watch"]["details"]["visual_reviews_created"], 1) + self.assertEqual(payload["tasks"]["visual_watch"]["details"]["last_visual_state"], "confirm") self.assertEqual(payload["latest_broker_state_by_symbol"]["BTC-USDT"]["account"]["equity"], 1000.0) self.assertTrue(payload["tasks"]["watch"]["next_run_ts"]) @@ -135,14 +204,19 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): broker_read_model_service=FakeBrokerReadModelService(), reconciliation_service=FakeReconciliationService(), decision_packet_service=FakeDecisionPacketService(), + visual_review_service=FakeVisualReviewService(), + research_council_service=FakeResearchCouncilService(), ) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): payload = scheduler.run_cycle() self.assertTrue(payload["degraded_mode_state"]["is_degraded"]) self.assertIn("market_stream_degraded:BTC-USDT:market_stream_disconnected", payload["current_blockers"]) - self.assertIn("watch_skipped_market_degraded:BTC-USDT", payload["current_blockers"]) + self.assertIn("watch_skipped_market_degraded:BTC-USDT", payload["current_noisy_incidents"]) self.assertGreaterEqual(len(payload["incidents"]), 1) + severities = {item["severity"] for item in payload["incidents"]} + self.assertIn("blocker", severities) + self.assertIn("noisy_incident", severities) def test_supervisor_run_once_exposes_supervisor_state(self) -> None: scheduler = TradingRuntimeScheduler( @@ -152,6 +226,9 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): broker_read_model_service=FakeBrokerReadModelService(), reconciliation_service=FakeReconciliationService(), decision_packet_service=FakeDecisionPacketService(), + alert_dispatcher_service=FakeAlertDispatcherService(), + visual_review_service=FakeVisualReviewService(), + research_council_service=FakeResearchCouncilService(), ) supervisor = TradingRuntimeSupervisor(scheduler=scheduler) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): diff --git a/tests/unit/test_visual_review_service.py b/tests/unit/test_visual_review_service.py new file mode 100644 index 0000000..0a19aff --- /dev/null +++ b/tests/unit/test_visual_review_service.py @@ -0,0 +1,180 @@ +import unittest + +from src.services.visual_watch import ( + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, +) +from src.services.visual_watch.visual_review_service import VisualReviewService + + +class StubVisualSnapshotRepository: + def __init__(self) -> None: + self.watch_runs = {} + self.snapshots = {} + self.latest_snapshots_by_candidate = {} + self.snapshot_calls = [] + self.review_calls = [] + self.completed_calls = [] + self.pending_watch_runs = [] + + def fetch_watch_run_by_id(self, watch_run_id: int): + return self.watch_runs.get(watch_run_id) + + def fetch_snapshot_by_id(self, snapshot_id: int): + return self.snapshots.get(snapshot_id) + + def fetch_latest_snapshot_for_candidate(self, candidate_id: int): + return self.latest_snapshots_by_candidate.get(candidate_id) + + def insert_snapshot(self, **kwargs): + self.snapshot_calls.append(kwargs) + snapshot_id = len(self.snapshot_calls) + 332 + self.snapshots[snapshot_id] = { + "id": snapshot_id, + "watch_run_id": kwargs["watch_run_id"], + "source_name": kwargs["source_name"], + "image_path": kwargs["image_path"], + "captured_ts": kwargs["captured_ts"], + "status": kwargs["status"], + "payload": kwargs["payload"], + } + return snapshot_id + + def insert_visual_review(self, **kwargs): + self.review_calls.append(kwargs) + return len(self.review_calls) + 443 + + def mark_visual_watch_run_completed(self, **kwargs): + self.completed_calls.append(kwargs) + + def fetch_pending_watch_runs(self, **kwargs): + return list(self.pending_watch_runs) + + +class StubVisualSnapshotCaptureService: + def __init__(self) -> None: + self.calls = [] + + def capture_watch_run_snapshot(self, *, watch_run: dict, source_name: str | None = None, output_dir=None): + self.calls.append({"watch_run": watch_run, "source_name": source_name, "output_dir": output_dir}) + return { + "status": "captured", + "watch_run_id": watch_run["id"], + "instrument_id": watch_run["instrument_id"], + "source_name": source_name or "auto_visual_watch_capture", + "image_path": f"captures/watch-{watch_run['id']}.html", + "artifact_path": f"captures/watch-{watch_run['id']}.json", + "captured_ts": "2026-05-11T02:00:00+00:00", + "capture_mode": "review_packet_html", + "payload": { + "capture_mode": "review_packet_html", + "captured_ts": "2026-05-11T02:00:00+00:00", + "source_name": source_name or "auto_visual_watch_capture", + "image_path": f"captures/watch-{watch_run['id']}.html", + "artifact_path": f"captures/watch-{watch_run['id']}.json", + "watch_run": {"id": watch_run["id"]}, + "review_packet_status": "captured", + "review_packet_summary": {"present": True}, + "review_packet": {"decision_packet": {"id": watch_run["decision_packet_id"]}}, + }, + } + + +class VisualReviewServiceTests(unittest.TestCase): + def test_review_watch_run_with_image_path_creates_snapshot_and_review(self) -> None: + repository = StubVisualSnapshotRepository() + repository.watch_runs[11] = { + "id": 11, + "trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + "source_name": "reserved_for_visual_watch", + "instrument_id": 1, + "candidate_id": 22, + "signal_id": 33, + "decision_packet_id": 44, + "symbol": "BTC-USDT", + "timeframe": "1m", + "status": "reserved", + "payload": {"trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH}, + } + service = VisualReviewService(repository=repository) + + result = service.review_watch_run( + watch_run_id=11, + image_path="captures/btc.png", + source_name="capture_bot", + ) + + self.assertEqual(result["status"], "reviewed") + self.assertEqual(result["watch_run_id"], 11) + self.assertEqual(result["visual_state"], "confirm") + self.assertEqual(result["snapshot_id"], 333) + self.assertEqual(repository.snapshot_calls[0]["image_path"], "captures/btc.png") + self.assertEqual(repository.review_calls[0]["visual_state"], "confirm") + self.assertEqual(repository.completed_calls[0]["status"], "reviewed") + + def test_review_pending_watch_runs_uses_existing_snapshot(self) -> None: + repository = StubVisualSnapshotRepository() + repository.watch_runs[21] = { + "id": 21, + "trigger_type": VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, + "source_name": "reserved_for_visual_watch", + "instrument_id": 1, + "candidate_id": 55, + "signal_id": 66, + "decision_packet_id": 77, + "symbol": "ETH-USDT", + "timeframe": "5m", + "status": "reserved", + "payload": {"trigger_type": VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW}, + } + repository.latest_snapshots_by_candidate[55] = { + "id": 444, + "watch_run_id": 99, + "source_name": "manual_capture", + "image_path": "captures/eth.png", + "captured_ts": "2026-05-11T01:00:00+00:00", + "status": "captured", + "payload": {}, + } + repository.pending_watch_runs = [repository.watch_runs[21]] + service = VisualReviewService(repository=repository) + + result = service.review_pending_watch_runs(limit=1, instrument_id=1) + + self.assertEqual(result["status"], "completed") + self.assertEqual(result["attempted"], 1) + self.assertEqual(result["reviewed"], 1) + self.assertEqual(result["items"][0]["visual_state"], "mismatch") + self.assertEqual(result["items"][0]["snapshot_id"], 444) + self.assertEqual(repository.completed_calls[0]["payload"]["review_status"], "reviewed") + + def test_review_watch_run_auto_captures_when_no_snapshot_exists(self) -> None: + repository = StubVisualSnapshotRepository() + repository.watch_runs[31] = { + "id": 31, + "trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + "source_name": "reserved_for_visual_watch", + "instrument_id": 1, + "candidate_id": None, + "signal_id": 88, + "decision_packet_id": 99, + "symbol": "SOL-USDT", + "timeframe": "1m", + "status": "reserved", + "payload": {"trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH}, + } + capture_service = StubVisualSnapshotCaptureService() + service = VisualReviewService(repository=repository, snapshot_capture_service=capture_service) + + result = service.review_watch_run(watch_run_id=31) + + self.assertEqual(result["status"], "reviewed") + self.assertEqual(result["snapshot_image_path"], "captures/watch-31.html") + self.assertEqual(result["snapshot_artifact_path"], "captures/watch-31.json") + self.assertEqual(capture_service.calls[0]["source_name"], "auto_visual_watch_capture") + self.assertEqual(repository.snapshot_calls[0]["source_name"], "auto_visual_watch_capture") + self.assertEqual(repository.snapshot_calls[0]["payload"]["payload"]["capture_mode"], "review_packet_html") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_visual_snapshot_capture_service.py b/tests/unit/test_visual_snapshot_capture_service.py new file mode 100644 index 0000000..7a5c8e5 --- /dev/null +++ b/tests/unit/test_visual_snapshot_capture_service.py @@ -0,0 +1,68 @@ +import tempfile +import unittest +from pathlib import Path + +from src.services.visual_watch.snapshot_capture_service import VisualSnapshotCaptureService + + +class VisualSnapshotCaptureServiceTests(unittest.TestCase): + def test_capture_watch_run_snapshot_writes_html_and_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + service = VisualSnapshotCaptureService( + review_packet_service=_ReviewPacketService(), + output_root=Path(tmp), + ) + + result = service.capture_watch_run_snapshot( + watch_run={ + "id": 11, + "trigger_type": "candidate_actionable_watch", + "instrument_id": 1, + "candidate_id": 22, + "signal_id": 33, + "decision_packet_id": 44, + "symbol": "BTC-USDT", + "timeframe": "1m", + "status": "reserved", + }, + source_name="unit_capture", + ) + + self.assertEqual(result["status"], "captured") + self.assertEqual(result["capture_mode"], "review_packet_html") + self.assertEqual(result["source_name"], "unit_capture") + self.assertTrue(Path(result["image_path"]).is_file()) + self.assertTrue(Path(result["artifact_path"]).is_file()) + html = Path(result["image_path"]).read_text(encoding="utf-8") + self.assertIn("Visual Watch Snapshot", html) + self.assertIn("BTC-USDT", html) + self.assertIn("confirm_ticket", html) + self.assertEqual(result["payload"]["review_packet_summary"]["recent_alert_count"], 1) + + +class _ReviewPacketService: + def build_packet(self, *, instrument_id: int) -> dict: + return { + "instrument_id": instrument_id, + "generated_ts": "2026-05-11T01:00:00+00:00", + "decision_packet": { + "id": 44, + "status": "actionable", + "action_recommendation": "confirm_ticket", + }, + "recent_alerts": [{"id": 1}], + "recent_notes": [{"id": 2}], + "recent_executions": [{"id": 3}], + "weekly_review": {"entry_count": 4}, + "trust_score": {"models": [{"model_code": "research_council"}]}, + "research_council": {"id": 31}, + "research_council_summary": { + "present": True, + "overall_stance": "observe_only", + }, + "research_council_optimization_review": {"health_state": "healthy"}, + } + + +if __name__ == "__main__": + unittest.main()