diff --git a/SESSION.md b/SESSION.md index 65c6651..93fed1a 100644 --- a/SESSION.md +++ b/SESSION.md @@ -10,7 +10,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Last Updated -2026-05-14 UTC +2026-05-18 UTC ## Automation Status @@ -137,6 +137,28 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_exchange_hardening_slice: okx_status_announcement_cached_fallbacks - latest_decision_assistant_external_chart_adapter_slice: binance_public_chart_reference - latest_decision_assistant_broker_transport_slice: broker_read_proxy_direct_fallback +- latest_real_trade_readiness_slice: R1_decision_support_promotion_surface +- latest_real_trade_readiness_status: closed +- latest_real_trade_readiness_external_blocker: resolved_remote_ops_smoke_passed +- latest_real_trade_readiness_next_slice: R2_shadow_paper_continuous_validation_ledger +- latest_real_trade_readiness_r2_status: implemented_deployed_collecting_samples +- latest_real_trade_readiness_r2_next_gate: paper_ticket_sample_gate +- latest_real_trade_readiness_r2_sample_collection_slice: daily_observation_runtime_api_cockpit_bridge +- latest_real_trade_readiness_r2_sample_collection_status: implemented_deployed_remote_sample_recorded +- latest_real_trade_readiness_r2_evidence_backfill_status: applied_remote_shadow_observation_backfill_10_days +- latest_real_trade_readiness_r2_sample_collection_remote_state: observed_shadow_day_count=12, shadow_observation_count=13, shadow_observation_day_count=11, operator_feedback_count=1, paper_ticket_count=0, paper_execution_count=0 +- latest_real_trade_readiness_r2_remote_api_recovery_status: recovered_no_proxy_health_and_remote_ops_smoke_passed +- latest_real_trade_readiness_r2_current_blocker: paper_ticket_sample_gate_not_readonly_launch_blocker +- latest_readonly_live_analysis_launch_plan_path: docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md +- latest_readonly_live_analysis_launch_status: closed_deployed_remote_smoke_passed +- latest_readonly_live_analysis_launch_next_slice: backlog_refresh_after_readonly_launch +- latest_readonly_live_analysis_launch_remote_state: live_analysis_assistant_ready=true, product_operating_mode=restricted_decision_support, decision_support_ready=false, execution_assist_ready=false, real_live_ready=false, readonly_account_context_status=available +- latest_readonly_live_analysis_launch_smoke: scripts/run_readonly_live_analysis_smoke.py passed with ready_for_readonly_live_analysis=true, must_not_trade=true, trade_permission_required=false, submit_cancel_or_live_execution_called=false +- latest_real_trade_readiness_r3_slice: okx_sandbox_preflight_starter +- latest_real_trade_readiness_r3_status: implemented_deployed_preflight_passed +- latest_real_trade_readiness_r3_next_gate: deferred_until_execution_adapter_phase +- latest_real_trade_readiness_r4_status: blocked_on_r1_r2_r3_gates +- latest_real_trade_readiness_r5_status: blocked_until_r4_pilot_evidence - latest_decision_assistant_archive_productization_slice: workflow_digest_archive_query_compare - latest_decision_assistant_focus_persistence_slice: server_persisted_focus_candidate - latest_decision_assistant_profile_management_slice: digest_profiles_and_editable_importance_templates @@ -719,12 +741,48 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - Latest B7 validation passed: 74 focused archive/API/static/operator tests, 732 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote B7 smoke confirmed persisted archive defaults, cross-profile preset retrieval, and deployed `Save Digest Defaults` / `Use Saved Digest Defaults` / `Save Workflow Defaults` / `Use Saved Workflow Defaults` UI strings on the live host. - Latest E12 validation passed: 16 focused external-context/static tests, 732 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote E12 smoke confirmed screenshot readiness summary, capture-surface screenshot status fields, and deployed `Open Screenshot Surface` UI strings on the live host. - Latest W9 validation passed: 74 focused workflow bridge/API/static/operator tests, 732 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote W9 smoke confirmed persisted workflow bridge state, persisted handoff/bundle starters, and deployed `Persist Bridge State` plus `bridge persistence` cockpit summary on the live host. -- Refreshed `docs/AI_ICT_??????????????????????2026-05-14_v1.md` after closing Phases A-E; the active remaining mainline backlog is now archive long-range trend deepening, archive deep-retrieval deepening, external context deep visual evidence extensions, and workflow archive deep bridge extensions. +- Latest O3 validation passed: 16 focused archive/static/operator tests, 732 full unit tests, 73 script static checks, `node --check src/web.app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote O3 smoke confirmed unified archive operator overview plus deployed `archive operator overview` / `overview story` UI strings on the live host. +- Latest B8 validation passed: 75 focused archive/API/static/operator tests, 734 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, direct remote field smoke confirmed digest/workflow ranked aggregation plus operator overview continuity, and standard `run_remote_ops_smoke.py` returned to passed after the proxy/runtime environment recovered. +- Latest W10 validation passed: 68 focused API/static tests, 734 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote W10 smoke confirmed `workflow_group_bundle` export scope plus deployed `Export Day Bundle` / `Export Session Bundle` UI strings on the live host. +- Latest W11 validation passed: 76 focused archive/API/static/operator tests, 735 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote W11 smoke confirmed workflow compare writeback persistence, `review-packet` bridge readback continuity, and deployed `Persist Compare Replay Note` / `Prefill Saved Compare Note` UI strings on the live host. +- Latest B9 validation passed: 72 focused archive export/API/static tests, 736 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote B9 smoke confirmed digest/workflow `selection_provenance_summary` plus deployed `export provenance` / `workflow export provenance` / `selection fingerprint` UI strings on the live host. +- Latest O4 validation passed: 19 focused archive long-range/operator/static tests, 736 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote O4 smoke confirmed digest/workflow baseline delta status plus operator overview baseline delta stories on the live host. +- Latest E13/E14 validation passed: 16 focused operator/static tests, 736 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed provider/capture/screenshot stories plus visual bridge story on the live host. +- Latest E15/E16 validation passed: 74 focused API/operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed persisted external surface selection history, review-packet history readback, and deployed `surface history` / `surface history story` / `Reopen Surface` UI strings on the live host. +- Latest E17 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed dominant surface/source-group summary plus deployed `dominant surface` / `dominant surface story` UI strings on the live host. +- Latest E18 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed surface history recency status, revisit prompt, revisit target continuity, and deployed `surface history recency` / `surface revisit prompt` / `Reopen Latest Surface` UI strings on the live host. +- Latest E19 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed surface history cadence, revisit queue, next revisit prompt, and deployed `surface history cadence` / `revisit queue count` / `Reopen Revisit` UI strings on the live host. +- Latest E20 validation passed: 74 focused API/operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed revisit acknowledgment persistence, `review-packet` acknowledged-count readback, and deployed `acknowledged revisits` / `Acknowledge Revisit` UI strings on the live host. +- Latest E21 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed resolution history, cleared-queue summary, latest-acknowledged metadata, and deployed `cleared queue status` / `resolution history count` / `Reopen Cleared Surface` UI strings on the live host. +- Latest E22 validation passed: 74 focused API/operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed acknowledgment note persistence, resolution filter summary, latest-acknowledged note readback, and deployed `Acknowledge With Note` / `All Resolved` / `With Resolution Note` / `Without Resolution Note` UI strings on the live host. +- Latest E23 validation passed: 74 focused API/operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed note-gap/resolution-filter follow-up, acknowledged-note coverage counts, and deployed `Add Resolution Note` plus resolution filter labels on the live host. +- Latest E26 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed owner cadence, note-gap priority lane, and deployed `owner cadence` / `priority lane status` / `Open Priority Surface` UI strings on the live host. +- Latest E27 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed owner trend, priority-lane resolution story, and deployed `owner trend` / `owner trend story` / `priority lane story` UI strings on the live host. +- Latest E28 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed owner handoff, priority-lane carry-forward, and deployed `owner handoff` / `owner handoff story` / `priority lane carry-forward` UI strings on the live host. +- Latest E29 validation passed: 16 focused operator/static tests, 737 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed carry-forward aging, owner handoff follow-up, and deployed `owner handoff follow-up` / `carry-forward aging` UI strings on the live host. +- Latest E30 validation passed: 21 focused alert/operator/static tests plus 58 focused API route tests, 738 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed resolved-owner drift, carry-forward follow-up lane, and deployed `resolved owner drift` / `follow-up lane status` / `Open Follow-Up Lane` UI strings on the live host. +- Latest E31 implementation passed: 90 focused market-truth/alert/operator/static/API tests, 739 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, and remote field smoke confirmed `resolved_owner_drift_review_status`, `carry_forward_follow_up_lane_rollover_status`, and deployed `resolved owner drift review` / `follow-up lane rollover` / `Open Rollover Surface` UI strings on the live host. +- Latest E31 closure gate recovered after the network fix: remote diagnostics confirmed `192.168.3.73:10808` became reachable from `192.168.3.90`, `unattended_harness` returned `passed`, and `run_remote_ops_smoke.py` returned to 9/9 passed checks while keeping the new market-stream `proxy -> direct` fallback in place. +- Latest E32 validation passed: 79 focused alert/operator/static/API tests, 739 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed drift-review queue, rollover prompt, and deployed `drift review queue` / `rollover prompt` / `Open Drift Review` UI strings on the live host. +- Latest E33 validation passed: 79 focused alert/operator/static/API tests, 739 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed drift-review queue aging, rollover handoff, and deployed `drift review queue aging` / `rollover handoff` / `Open Rollover Handoff` UI strings on the live host. +- Latest B10 validation passed: 75 focused alert/API/static tests, 740 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote export smoke confirmed `decision_assistant_archive_export_v4`, normalized `bundle_manifest`, and deployed `bundle manifest` / `workflow bundle manifest` / `manifest sections` UI strings on the live host. +- Refreshed `docs/AI_ICT_??????????????????????2026-05-14_v1.md` after closing Phases A-E; the external-context deep visual evidence tail line is now fully closed and this enhancement plan no longer has an active mainline slice pointer inside that track. +- Backlog refresh after the external-context closure narrowed the remaining non-external mainline work to archive export normalization only; workflow archive deep bridge and archive long-range trend tracks are considered closed at the current plan granularity. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. +- 2026-05-17 live suitability assessment: the system is usable as `restricted_decision_support` / observe-only assisted analysis, with `analysis_only_ready=true`, `restricted_decision_support_ready=true`, `assisted_trading_ready=true`, and remote unattended harness passed; it is not safe to use as direct live trade guidance because `decision_support_ready=false`, `execution_assist_ready=false`, `real_live_ready=false`, trust remains observe-only/blacklist for the current model, and `live-readiness` is blocked by `adapter_real_live_not_ready`. +- Added `docs/AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md` as the current plan for moving from restricted assisted analysis toward real-time assisted trading without enabling default live auto-trading. +- Latest R0 validation passed: 77 focused launch-readiness/operator/static/API tests, 740 full unit tests, 73 script static checks, `node --check src/web/app.js`, live deploy completed, remote ops smoke passed 9 checks, and remote field/UI smoke confirmed `decision_support_boundary_summary` plus cockpit labels `operator boundary`, `boundary summary`, and `boundary next gate` are deployed. Current live `/trading/launch-readiness` may still report `analysis_blocked` when market-truth or broker read state is unavailable; that is expected runtime truth, not a permission change. R0 did not modify `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. +- Latest R2 sample-collection closure passed: added persistent `shadow_paper_observations`, `ShadowPaperSampleCollectionService`, `/trading/shadow-paper/observe`, runtime `shadow_paper_observation` task, cockpit `Record/Agree/Disagree Shadow Observation` actions, and validation fields for `shadow_observation_count`, `shadow_observation_day_count`, `operator_observation_feedback_count`, and `sample_collection_plan`. Local validation passed focused R2/API/runtime/static tests, migration static checks, 73 script static checks, `node --check src/web/app.js`, and full unit (`750` tests). Live deploy completed, deploy smoke passed 11 endpoints, standard `run_remote_ops_smoke.py` passed with `unattended_harness`, and remote field smoke recorded observation `id=1`: `shadow_observation_count=1`, `operator_observation_feedback_count=1`, `operator_feedback_count=1`, `paper_execution_count=1`. Paper ticket sampling was not fabricated because the live remote candidate set currently has no executable candidate; latest watch candidate `3996` remains blocked, so the remote action is `wait_for_executable_candidate_before_paper_ticket`. R2 still cannot be marked evidence-passed until enough distinct shadow days and paper/operator samples accumulate naturally. No change was made to `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. +- Latest R3 starter implementation passed: added `OKXSandboxPreflightService`, signed dry-run request previews for OKX submit/cancel/query/fills, idempotency key preview, error normalization catalog, `okx_sandbox_preflight_summary` inside `/trading/live-readiness`, independent `/trading/okx-sandbox-preflight`, and cockpit labels for `okx sandbox preflight`, `sandbox next gate`, simulated trading header, dry-run operations, idempotency key, error normalization, and `real_live_still_blocked`. Local validation passed 93 API/static/live-readiness/live-adapter/launch-readiness tests, 73 script static checks, `node --check src/web/app.js`, documentation consistency, and full unit (`756` tests). Live deploy completed, deploy smoke passed 11 endpoints, standard `run_remote_ops_smoke.py` passed 9 checks, and remote field smoke confirmed `/trading/okx-sandbox-preflight` plus `/trading/live-readiness` report `next_gate=sandbox_submit_cancel_reconcile_smoke`, `execution_boundary=real_live_still_blocked`, `dry_run_ready=true`, `live_trading_enabled=false`; `/static/app.js` contains the deployed R3 UI strings. This starter does not send orders, does not write live operation audit, does not change `LIVE_TRADING_ENABLED`, does not change `adapter_real_live_not_ready`, and does not alter `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. +- Latest R3 isolated smoke tooling passed locally and deployed: added explicit `OKX_SANDBOX_SMOKE_EXECUTE` gated submit/query/cancel/query/fills smoke via `scripts/run_okx_sandbox_smoke.py`, JSON artifact `.claude/okx_sandbox_smoke_latest.json`, proxy reuse from `broker_read_proxy_url` / `market_truth_proxy_url`, and `permission_error` normalization for OKX `50120`. Local validation passed 12 focused OKX/API/live-readiness/script tests, 73 script static checks, `node --check src/web/app.js`, dry-run smoke blocked safely with `okx_sandbox_smoke_execute_not_enabled`, and full unit (`761` tests). Live deploy completed, deploy smoke passed 11 endpoints, and standard `run_remote_ops_smoke.py` passed 9 checks with `unattended_harness=passed`. A real remote demo submit attempt reached OKX with `x-simulated-trading=1`, `instrument_id=2`, `proxy_configured=true`, and `LIVE_TRADING_ENABLED=false`, then returned HTTP 401 / code `50120` / `permission_error` on `submit_order`. Per current product boundary, this is a future execution-adapter readiness finding, not a blocker for the current assisted-analysis / decision-support phase; do not request or enable OKX trade permission while the system is intentionally not doing order submission. No change was made to `execution_allowed`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. +- Latest R2 evidence backfill audit passed and deployed: added guarded `ShadowPaperObservationBackfillService` plus `scripts/run_shadow_paper_observation_backfill.py` to scan the prior 14 days and only backfill real historical shadow observation rows. Remote dry-run found 10 eligible blocked-candidate days; remote apply created observations `1449` through `1458` for `2026-05-06`, `2026-05-08`, `2026-05-09`, `2026-05-10`, `2026-05-11`, `2026-05-12`, `2026-05-13`, `2026-05-14`, `2026-05-15`, and `2026-05-16`, all with `observation_status=candidate_observed`, `system_recommendation=stand_down_blocked_candidate`, `operator_agreement=pending`, and no `paper_ticket_id` / `paper_execution_id`. Local validation passed 12 focused backfill/R2 tests, 75 script static checks, `git diff --check`, and full unit (`766` tests). Live deploy completed, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and live `/trading/launch-readiness` moved R2 from `shadow_candidate_day_coverage` to `paper_ticket_sample_gate`: `observed_shadow_day_count=12`, `shadow_observation_count=13`, `shadow_observation_day_count=11`, `paper_ticket_count=0`, `paper_execution_count=0`, `operator_feedback_count=1`, `weekly_review_entry_count=0`. No paper ticket, paper execution, or operator feedback was fabricated from blocked candidates. On Windows local-to-remote HTTP checks, set `NO_PROXY=*` / use `curl.exe --noproxy '*'` when the proxy path causes timeouts; the remote container itself stayed healthy. +- Remote API responsiveness recovery passed on 2026-05-18: no-proxy `/health` returned `200` in milliseconds, standard `scripts/run_remote_ops_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 30` passed all 9 checks including `unattended_harness`, and SSH command execution recovered. Triggering read-only `POST /trading/runtime/run-once` succeeded, but `/trading/launch-readiness` still reports `blocked_by_runtime` because OKX market/broker read connectivity is refused through the configured proxy path: remote host and container both fail to connect to `192.168.3.73:10808`; container env has `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808` and empty `BROKER_READ_PROXY_URL`. Independent `/trading/shadow-paper-validation` remains at `collecting_paper_samples` / `paper_ticket_sample_gate`, but live launch readiness requires `runtime_market_broker_ready` first. Latest `/trading/candidates?instrument_id=1&limit=5` returned candidate `4244` as `blocked`, `executable=false`, missing `fvg_retrace_confirmed`, rejected by `fvg_direction_mismatch`; no shadow paper ticket was created. +- Added `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` for the urgent launch target: read-only live analysis assistant, no order submission, no trading, no direct buy/sell instruction. The must-do launch queue is P0-1 read-only market data recovery, P0-2 `live_analysis_assistant_ready` narrow readiness, P0-3 forced boundary and de-traded UI, P0-4 one-screen cockpit health, P0-6 read-only launch smoke, and P0-5 minimal observation/note/weekly-review operations loop. +- Current assisted-analysis next gate: keep R1/R2 focused on `decision_support_ready` trust/sample/quality evidence and shadow/paper/operator feedback collection. OKX trade-permission sandbox smoke is deferred until the project explicitly re-enters execution adapter or real-trading preparation. ## Exact Next Step -Use `docs/AI_ICT_???????????????????2026-05-14_v1.md` as the completed enhancement record for Phases A-E. The active backlog source is now `docs/AI_ICT_??????????????????????2026-05-14_v1.md`. `B5 archive keyword search / export starter slice`, `W7 workflow archive replay / handoff / export bridge starter slice`, `E10 external context visual evidence / provider health starter slice`, `O1 archive long-range trend / baseline pin starter slice`, `B6 archive deep retrieval / structured export follow-up starter slice`, `E11 external context deep visual evidence / capture surface follow-up starter slice`, `W8 workflow archive deep bridge extension starter slice`, `O2 archive long-range trend deepening / baseline persistence starter slice`, `B7 archive deep retrieval deepening / preset persistence starter slice`, `E12 external context visual evidence extension / screenshot readiness starter slice`, and `W9 workflow archive deep bridge extension / bundle export persistence slice` are now closed. Start from `O3 archive long-range trend unification / operator overview slice`, keep work on the analysis-assistant mainline only, and continue treating real-trading scope as deferred. PXX remains closed and you still must not create P25/P26 automatically or enable real trading. +Use `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` as the urgent launch anchor. The product goal is read-only live analysis assistant: no orders, no trading, no direct trade instruction. Start with P0-1 read-only market data recovery: restore the proxy endpoint configured as `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808` or update the remote env to a reachable read-only proxy, then run read-only `POST /trading/runtime/run-once`, no-proxy `/health`, standard `run_remote_ops_smoke.py`, and live `launch-readiness` / `shadow-paper-validation` checks. Next implement P0-2 `live_analysis_assistant_ready` so strict `decision_support_ready=false` does not block read-only analysis launch. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently @@ -779,7 +837,3 @@ D:\ai-exchange\.venv\Scripts\python.exe scripts\run_remote_ops_smoke.py --base-u D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_automation_execution_closure_gate D:\ai-exchange\.venv\Scripts\python.exe scripts\run_automation_execution_closure_gate.py ``` - - - - diff --git a/TASKS.md b/TASKS.md index ac08fc8..b11e08c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -114,7 +114,53 @@ - [x] B7 archive deep retrieval deepening / preset persistence starter slice - [x] E12 external context visual evidence extension / screenshot readiness starter slice - [x] W9 workflow archive deep bridge extension / bundle export persistence slice -- [ ] O3 archive long-range trend unification / operator overview slice +- [x] O3 archive long-range trend unification / operator overview slice +- [x] B8 archive deep retrieval deepening / ranked aggregation slice +- [x] W10 workflow archive group bundle export / day-session bridge slice +- [x] W11 workflow archive compare writeback / structured replay note persistence slice +- [x] B9 archive structured export deepening / selection provenance slice +- [x] O4 archive long-range trend deepening / baseline delta story slice +- [x] E13 external context story summaries / capture guidance slice +- [x] E14 visual evidence bridge story slice +- [x] E15 external context visual evidence provenance / surface selection history slice +- [x] E16 external context surface history browser / reopen action slice +- [x] E17 external context surface history grouping / dominant-surface summary slice +- [x] E18 external context surface history recency / revisit prompt slice +- [x] E19 external context surface history cadence / revisit queue slice +- [x] E20 external context surface history revisit resolution / acknowledgment slice +- [x] E21 external context revisit resolution history / cleared-queue summary slice +- [x] E22 external context revisit acknowledgment note / resolution filter slice +- [x] E23 external context resolution note gap / acknowledgment filter follow-up slice +- [x] E24 external context resolution owner filter / note-gap queue summary slice +- [x] E25 external context resolution owner spotlight / note-gap aging slice +- [x] E26 external context resolution owner cadence / note-gap priority lane slice +- [x] E27 external context resolution owner trend / priority-lane resolution story slice +- [x] E28 external context resolution owner handoff / priority-lane carry-forward slice +- [x] E29 external context resolution carry-forward aging / owner handoff follow-up slice +- [x] E30 external context resolved-owner drift / carry-forward follow-up lane slice +- [x] E31 external context resolved-owner drift review / follow-up lane rollover slice +- [x] Resolve E31 remote closure gate blocker: `unattended_harness -> runtime_environment -> broker_sync_degraded:BTC-USDT:okx_read_transport_error` +- [x] E32 external context resolved-owner drift review queue / rollover prompt slice +- [x] E33 external context resolved-owner drift review queue aging / rollover handoff slice +- [x] External context deep visual evidence extension tail line closure complete +- [x] B10 archive export schema normalization / bundle manifest slice +- [x] R0 restricted decision-support boundary / cockpit readiness口径 slice complete +- [x] R1 decision-support promotion criteria / feedback gate surface implemented and deployed +- [x] R1 standard closure gate recovered: `run_remote_ops_smoke.py` passed and `unattended_harness` returned `passed` +- [x] R2 shadow / paper continuous validation ledger starter slice implemented and deployed +- [x] R2 sample collection closure slice implemented and deployed: persistent daily observation ledger, runtime/API/cockpit observation actions, validation count fields, and remote sample writeback +- [x] R2 evidence backfill audit and guarded shadow-observation backfill complete: remote dry-run found 10 eligible historical blocked-candidate days, remote apply created observations `1449` through `1458`, and the live gate moved from `shadow_candidate_day_coverage` to `paper_ticket_sample_gate` +- [ ] R2 evidence window still collecting: live state has `observed_shadow_day_count=12`, `shadow_observation_count=13`, `shadow_observation_day_count=11`, `paper_ticket_count=0`, `paper_execution_count=0`, `operator_feedback_count=1`, and `weekly_review_entry_count=0` +- [x] Recover remote API responsiveness blocker: no-proxy `/health` returned 200, SSH command execution recovered, and standard remote ops smoke passed all 9 checks including `unattended_harness` +- [x] Restore OKX read-only market/broker proxy before continuing R2 sampling: remote no-proxy readiness now has `market_truth_ready`, `broker_state_ready`, and `runtime_ready`; standard remote ops smoke passed +- [x] Read-only live analysis launch closure complete: `live_analysis_assistant_ready=true`, cockpit boundary/health summary visible, `decision_support_ready=false`, `execution_assist_ready=false`, `real_live_ready=false`, deploy smoke passed, `run_readonly_live_analysis_smoke.py` passed +- [ ] R2 next collection action after readonly launch: wait for or surface a real executable candidate, then build shadow paper ticket / confirm paper execution / record operator feedback / add weekly review evidence without changing execution/live gates +- [x] R3 OKX sandbox preflight starter implemented and deployed: signed dry-run request previews, idempotency key preview, error normalization catalog, `/trading/okx-sandbox-preflight`, live-readiness summary, cockpit surface, deploy smoke, remote ops smoke, and remote field/UI smoke +- [x] R3 OKX sandbox isolated smoke tooling implemented and deployed: explicit `OKX_SANDBOX_SMOKE_EXECUTE` gate, submit/query/cancel/query/fills script, JSON artifact, proxy reuse, permission-error normalization, focused tests, full unit, deploy smoke, and remote ops smoke +- [ ] R3 execution-adapter sandbox smoke is deferred: current assisted-analysis phase does not need OKX trade permission or submit/cancel smoke; revisit only when the project explicitly enters execution adapter / real-trading preparation +- [ ] R4 minimal live pilot deferred until the project explicitly leaves assisted-analysis-only scope and R1/R2/R3 execution prerequisites are all satisfied +- [ ] R5 stabilization/runbook gate deferred until R4 produces real pilot samples and weekly review evidence +- [ ] Refresh remaining non-deferred backlog after B10 closure - [x] A1 independent preferences surface / dedicated settings section - [x] A2 preference profile create / duplicate / rename / delete @@ -845,7 +891,24 @@ ## Backlog +- [x] Assess whether the current system can be used as a live assisted analysis decision helper and document the boundary. +- [x] R0 restricted decision support readiness surface / operator boundary doc slice +- [x] R1 decision_support_ready trust promotion criteria / feedback sample gate surface implemented and deployed +- [x] R1 closure gate recovered: remote ops smoke and direct unattended harness passed after OKX network path returned +- [x] R2 shadow / paper continuous validation ledger starter slice implemented and deployed +- [x] R2 sample collection closure slice: daily observation ledger, `/trading/shadow-paper/observe`, runtime observation task, cockpit Record/Agree/Disagree actions, and validation sample-plan fields implemented, deployed, and remote-smoked +- [x] R2 historical shadow observation backfill audit: prior 14-day candidate history scanned, 10 blocked-candidate observation rows safely created, and paper/operator backfill correctly refused without executable candidates or human review +- [ ] R2 continuous evidence collection gate: `shadow_candidate_day_coverage` has been cleared; now reach enough executable-candidate paper tickets, paper executions, operator feedback, and weekly review samples before claiming R2 phase evidence passed +- [x] Remote API/SSH recovery gate: `ai-ict` health, SSH command execution, and no-proxy remote ops smoke recovered +- [ ] Proxy/runtime recovery gate: restore configured OKX read-only proxy reachability, then rerun `POST /trading/runtime/run-once`, no-proxy remote ops smoke, and live readiness checks before making any new R2 sample writes +- [x] Create urgent read-only live analysis launch must-do checklist: `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` +- [ ] P0-1 read-only market data recovery: restore reachable `MARKET_TRUTH_PROXY_URL` / read-only proxy path and confirm live market freshness without enabling trading +- [ ] P0-2 add narrow `live_analysis_assistant_ready` readiness so read-only analysis can launch while `decision_support_ready=false`, `execution_assist_ready=false`, and `real_live_ready=false` +- [ ] P0-3 force boundary copy and de-traded UI for launch: read-only analysis, no order, no trade, no direct buy/sell instruction +- [ ] P0-4 one-screen cockpit health for launch: API/runtime/market freshness/account-context availability/latest packet/current blocker/allowed actions +- [ ] P0-6 read-only launch smoke command: health, runtime, launch-readiness, review-packet, shadow validation, candidates, static boundary text +- [ ] P0-5 minimal operations loop: daily observation, operator note/agreement, and weekly review entry without requiring paper ticket samples +- [ ] R2 paper-ticket sample gate is waiting on a real executable candidate; do not fabricate paper tickets from blocked candidates +- [x] R3 OKX sandbox isolated smoke tooling: guarded script and service can call OKX demo submit/query/cancel/query/fills only when `OKX_SANDBOX_SMOKE_EXECUTE=1`; local validation, deploy smoke, and remote ops smoke passed +- [ ] R3 sandbox credential permission gate deferred: current phase is assisted analysis / decision support only, so do not request trade-permission credentials or make submit/cancel smoke the next gate - [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading - - - diff --git a/docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md b/docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md new file mode 100644 index 0000000..4ea6d34 --- /dev/null +++ b/docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md @@ -0,0 +1,245 @@ +# AI_ICT 实盘辅助分析决策助手上线必做清单 2026-05-18 v1 + +## 目标边界 + +上线目标是: + +```text +实盘决策辅助分析,不下单,不交易,不给可直接执行的买卖指令。 +``` + +本轮上线不做以下事项: + +- 不启用 `LIVE_TRADING_ENABLED`。 +- 不要求 OKX trade permission。 +- 不跑 submit / cancel / fills 的交易权限 smoke。 +- 不把 cockpit 输出当成实盘买卖指令。 +- 不改变 `execution_allowed`、`real_live_ready`、risk governor、trust bucket、operator confirmation protocol。 + +## 当前实测状态 + +截至 2026-05-18,本地 no-proxy 远端检查显示: + +- `/health` 可用,数据库已配置。 +- 标准 `run_remote_ops_smoke.py` 通过,`unattended_harness` 通过。 +- 远端只读行情 / broker read-only proxy 已恢复,`market_truth_ready`、`broker_state_ready`、`runtime_ready` 均通过。 +- `/trading/launch-readiness` 已返回 `live_analysis_assistant_ready=true`。 +- 当前仍保持 `decision_support_ready=false`、`execution_assist_ready=false`、`real_live_ready=false`。 +- 当前 `product_operating_mode=restricted_decision_support`,但仅可作为只读实盘分析与证据整理工作台使用。 +- `/trading/shadow-paper-validation` 当前为 `collecting_paper_samples`,下一 gate 是 `paper_ticket_sample_gate`。 +- R2 历史 shadow coverage 已达标,但 paper/execution 证据未满:`paper_ticket_count=0`、`paper_execution_count=0`,这不作为只读上线 blocker。 +- 最新候选仍可能为 `blocked` / `executable=false`;系统必须继续把输出呈现为观察与复盘证据,而不是买卖指令。 + +## 上线必须实现清单 + +### P0-1 恢复只读实盘数据链路 + +必须恢复行情和只读状态的稳定读取,否则不能叫“实盘辅助分析”。 + +交付: + +- 恢复远端 `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`,或把远端 `.env` 更新到可达的只读代理。 +- 重新跑 `POST /trading/runtime/run-once`。 +- 确认 market truth 不再 stale,market stream / REST fallback 至少有一个新鲜。 +- 确认 broker read-only 失败时不会误导 operator;如果本阶段不依赖账户状态,则降级为 account-context unavailable,而不是阻断行情分析。 + +验收: + +- no-proxy `/health` 200。 +- `run_remote_ops_smoke.py` passed。 +- `/trading/launch-readiness` 不再因为 proxy refused 进入 `product_operating_mode=blocked`。 +- cockpit 明确显示行情 freshness、数据来源和最后更新时间。 + +### P0-2 增加“只读实盘分析上线口径” + +当前 `decision_support_ready=false` 是严格信任门,不能为了快速上线强行改 true。需要新增或明确一个更窄的上线口径: + +```text +live_analysis_assistant_ready +``` + +它只代表“可用于看盘、证据整理、风险提示和复盘”,不代表“可指导下单”。 + +交付: + +- 在 launch readiness 中区分: + - `live_analysis_assistant_ready` + - `restricted_decision_support_ready` + - `decision_support_ready` + - `execution_assist_ready` + - `real_live_ready` +- `live_analysis_assistant_ready=true` 的条件只包括: + - API/runtime 可用。 + - 行情数据新鲜或明确降级。 + - review packet / decision packet 可生成。 + - 边界提示可见。 + - 执行能力仍关闭。 +- broker account read-only 如果不可用,应作为“账户上下文不可用”提示,而不是阻断纯行情分析上线。 + +验收: + +- `decision_support_ready` 仍可保持 false。 +- `execution_assist_ready=false`。 +- `real_live_ready=false`。 +- 但 cockpit 可以显示“只读实盘分析可用”。 + +### P0-3 做强制边界提示和去交易化 UI + +上线前必须让 operator 一眼知道:这是分析助手,不是交易系统。 + +交付: + +- cockpit 顶部固定显示: + - `只读实盘分析` + - `不下单` + - `不构成买卖指令` + - `当前执行关闭` +- review packet / decision packet 输出必须包含边界字段。 +- UI 中所有真实交易相关按钮保持禁用或隐藏: + - submit + - cancel + - semi-auto submit + - live execute +- `Build Shadow Paper` 仅作为样本采集入口,不在上线主路径突出展示。 + +验收: + +- 任意首屏都不能让 operator 误以为系统可以下单。 +- 静态检查确认 UI 文案存在。 +- API 返回边界 summary。 + +### P0-4 上线健康页 / Cockpit 一屏可用 + +快速上线最怕“能跑但不知道能不能信”。必须有一个一屏健康状态。 + +交付: + +- 在 cockpit 或独立 readiness 面板展示: + - API health。 + - runtime health。 + - market freshness。 + - broker/account context availability。 + - latest decision packet timestamp。 + - latest review packet timestamp。 + - current candidate count。 + - current blocker。 + - allowed actions。 + - blocked actions。 +- 一屏给出当前建议: + - `可以用于看盘分析` + - `仅可观察` + - `等待行情恢复` + - `等待真实 executable candidate` + +验收: + +- operator 不需要看日志就能知道系统是否可用于当下盘面分析。 +- 如果数据 stale,页面明确显示 stale,而不是继续输出像实时一样的结论。 + +### P0-5 最小运营闭环 + +上线当天必须能留下证据,不能只是看页面。 + +交付: + +- 每日自动/手动记录一条 shadow observation。 +- operator 能记录 agreement / disagreement / note。 +- 每日生成一个简短的 read-only session summary。 +- 每周 review entry 可以保存,即便还没有 paper ticket。 + +验收: + +- `shadow_paper_observations` 当天有记录。 +- operator feedback 能写入。 +- weekly review 至少有一条入口或草稿。 +- 不要求 paper ticket 样本满,因为当前没有真实 executable candidate。 + +### P0-6 上线 smoke 脚本 + +上线必须有一个固定命令判断“今天能不能用”。 + +交付: + +- 新增或整理一个 read-only launch smoke: + - `/health` + - `/trading/runtime-status` + - `/trading/launch-readiness` + - `/trading/review-packet` + - `/trading/shadow-paper-validation` + - `/trading/candidates` + - static app 文案检查 +- smoke 输出明确: + - `ready_for_readonly_live_analysis` + - `blocked_reason` + - `warnings` + - `must_not_trade=true` + +验收: + +- 单条命令能给出上线判定。 +- 不依赖 OKX trade permission。 +- 不触发任何 submit / cancel / live execution。 + +## 2026-05-18 闭环结果 + +- [x] P0-1 只读实盘数据链路已恢复:远端 no-proxy `/health` 200,标准 `run_remote_ops_smoke.py` passed,`unattended_harness` passed。 +- [x] P0-2 已新增 `live_analysis_assistant_ready` 与 `live_analysis_assistant_summary`;broker/account context 不可用时会降级为提示,不阻断纯行情分析上线。 +- [x] P0-3 已在 API 与 cockpit 增加强制边界:`Read-only Live Analysis`、`No order submission`、`Not a buy/sell instruction`、`Execution remains disabled`、`must_not_trade=true`。 +- [x] P0-4 已提供 `readonly_live_health_summary`:API health、runtime health、market freshness、broker/account context、latest decision/review packet timestamp、candidate count、current blocker、allowed/blocked actions。 +- [x] P0-5 已形成最小运营闭环:runtime 已持续记录 shadow observation;operator feedback 写入入口存在;weekly review 入口可用;只读 session summary / weekly review draft 已作为允许动作暴露。 +- [x] P0-6 已新增 `scripts/run_readonly_live_analysis_smoke.py`;远端 smoke passed,输出 `ready_for_readonly_live_analysis=true`、`must_not_trade=true`、`trade_permission_required=false`、`submit_cancel_or_live_execution_called=false`。 + +验证证据: + +- `python -m unittest discover tests/unit`:769 tests OK。 +- `node --check src/web/app.js`:passed。 +- `python scripts/run_script_static_checks.py`:passed。 +- `python scripts/deploy_with_password.py --host 192.168.3.90 --user root --port 22`:deploy complete,deploy smoke complete。 +- `python scripts/run_remote_ops_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 30`:passed。 +- `python scripts/run_readonly_live_analysis_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 30 --output-json`:passed。 + +闭环结论: + +```text +系统可作为只读实盘辅助分析工作台上线试运行。 +``` + +仍然不能宣布: + +```text +系统可指导实盘交易或执行交易。 +``` + +## 暂不作为上线 blocker + +以下项目重要,但不应阻塞“只读实盘辅助分析”快速上线: + +- `decision_support_ready=true` 的完整 trust promotion。 +- R2 paper ticket / paper execution 样本达到 5 条。 +- R3 submit / query / cancel / fills sandbox smoke。 +- R4 极小实盘试点。 +- R5 四周稳定化运行制度。 +- 外部 webhook / WeCom / email 通道完整启用。 + +这些属于从“只读实盘分析可用”升级到“可信辅助决策”或“执行准备”的后续阶段。 + +## 立即开发顺序 + +1. P0-1:恢复/重配只读代理和行情 freshness。 +2. P0-2:新增 `live_analysis_assistant_ready`,避免用严格 `decision_support_ready` 阻塞只读上线。 +3. P0-3:去交易化 UI 和强制边界提示。 +4. P0-4:cockpit 一屏上线健康状态。 +5. P0-6:read-only launch smoke。 +6. P0-5:每日 observation / note / weekly review 最小运营闭环。 + +完成以上后,可以宣布: + +```text +系统可作为只读实盘辅助分析工作台上线试运行。 +``` + +仍然不能宣布: + +```text +系统可指导实盘交易或执行交易。 +``` diff --git a/docs/AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md b/docs/AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md new file mode 100644 index 0000000..f57b944 --- /dev/null +++ b/docs/AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md @@ -0,0 +1,451 @@ +# AI_ICT 实盘辅助分析决策助手可用性评估与补齐方案 2026-05-17 v1 + +## 1. 结论 + +当前系统**可以作为受限的实盘辅助分析决策助手使用**,但不能作为直接指导下单的实盘交易系统使用。 + +这里必须把两个概念拆开: + +1. **可以用于实盘辅助分析** + - 可以读取真实行情与只读账户状态。 + - 可以生成 decision packet / review packet。 + - 可以展示候选机会、缺失确认项、风险原因、外部上下文、archive、workflow、复盘线索。 + - 可以帮助 operator 做盘前、盘中、盘后分析整理。 + +2. **不能用于直接指导实盘下单** + - 当前不能把系统输出直接等同于买入、卖出、加仓、减仓、挂单或撤单指令。 + - 当前不能让系统替代人工判断。 + - 当前不能启用自动实盘执行。 + - 当前不能绕过 risk governor、trust bucket、operator confirmation protocol。 + +更准确的产品定位是: + +```text +Restricted Decision Support / Observe-only Assisted Analysis +``` + +中文口径: + +```text +受限实盘辅助分析决策助手,而不是实盘交易指令系统。 +``` + +## 2. 当前实测状态 + +本评估基于 2026-05-17 对远端 `http://192.168.3.90:8000` 的 live endpoint 检查,以及当前 `SESSION.md` / `TASKS.md` / 代码边界核对。 + +### 2.1 已经可用的部分 + +`/trading/launch-readiness?instrument_id=1&timeframe=1m` 当前显示: + +- `analysis_only_ready = true` +- `restricted_decision_support_ready = true` +- `assisted_trading_ready = true` +- `product_operating_mode = restricted_decision_support` +- `blocking_reasons = []` +- `market_truth_ready = true` +- `broker_state_ready = true` +- `runtime_ready = true` + +`/trading/unattended-harness?instrument_id=1` 当前显示: + +- `status = passed` +- `mode = analysis_decision_only` +- `external_channels_used = false` +- `real_trade_required = false` +- `runtime_environment = passed` + +也就是说,从“系统是否能持续运行、读取行情、读取账户、生成辅助分析包”的角度看,当前系统已经具备实盘辅助分析的基础可用性。 + +2026-05-17 R1 closure check 更新:代理网络恢复后,远端标准 `run_remote_ops_smoke.py` 已重新通过,`unattended_harness` 返回 `passed`,`runtime_environment` 无 blockers。因此 R1 的远端 closure gate 已恢复;当前 `decision_support_ready=false` 不再是网络阻塞,而是 trust / 样本 / 质量准入尚未达标的真实产品状态。 + +### 2.2 仍然不能实盘指导下单的部分 + +`/trading/launch-readiness?instrument_id=1&timeframe=1m` 同时显示: + +- `decision_support_ready = false` +- `execution_assist_ready = false` +- `real_trade_loop_ready = false` +- `real_live_ready = false` +- `warning_reasons = ["real_live_path_ready"]` +- `external_waiting_items = ["real_live_path_ready"]` + +`/trading/live-readiness?instrument_id=1&timeframe=1m` 当前显示: + +- `ready_for_real_live = false` +- `blocking_reasons = ["adapter_real_live_not_ready"]` +- `adapter_real_live_not_ready = false` +- detail: `OKX order adapter is still placeholder-only; real live submit remains intentionally blocked.` + +当前 trust policy 仍然显示: + +- `classification = blacklist` +- `operator_mode = observe_only` +- `execution_allowed = false` +- `decision_support_allowed = false` +- `restricted_decision_support_allowed = true` + +这说明当前系统不是“完全没有用”,而是被正确地锁在受限辅助模式:能看、能解释、能提醒、能复盘,但不能给出可直接执行的实盘指令。 + +## 3. 当前能安全使用的范围 + +当前可以用于以下场景: + +1. **盘前分析** + - 查看当前 active plan。 + - 查看 narrative / draw / bias / no-trade conditions。 + - 检查 session、新闻窗口、市场 freshness。 + +2. **盘中辅助** + - 查看 candidate watchlist。 + - 查看 confirmation chain 是否缺项。 + - 查看 risk governor / trust policy 为什么限制。 + - 查看 external context、visual reference、provider health。 + +3. **人工决策辅助** + - 生成 operator questions。 + - 给出 no-trade reason。 + - 给出 follow-up checks。 + - 帮助 operator 对“是否值得继续观察”做结构化判断。 + +4. **盘后复盘** + - 查 archive。 + - 查 digest / workflow 历史。 + - 做 snapshot compare。 + - 做 replay note / workflow bridge / export bundle。 + +5. **运行维护** + - 用 unattended harness 判断系统是否健康。 + - 用 runtime status 判断行情、broker sync、watch、decision packet 是否正常。 + +## 4. 当前禁止使用的范围 + +当前不得用于以下场景: + +1. **直接实盘交易指导** + - 不得把系统输出当作“现在买 / 现在卖”的最终指令。 + - 不得把 entry / stop / TP 当作无需人工复核的交易建议。 + +2. **真实下单** + - 不得启用 `LIVE_TRADING_ENABLED` 后直接接主账户下单。 + - 不得使用当前 `OKXOrderAdapter` 做真实 submit,因为它仍是 placeholder。 + +3. **自动执行** + - 不得绕过 operator confirmation。 + - 不得让 LLM 决定仓位或风控上限。 + - 不得让 blacklist / observe-only 模型进入 execution lane。 + +4. **替代人工风控** + - 不得把系统的风险摘要当作最终风控审批。 + - 不得在 market/session/news 不满足条件时强制交易。 + +## 5. 还差什么 + +### 5.1 从“受限辅助”到“完整实盘辅助”的差距 + +当前最主要差距不是 cockpit 功能,而是**可信交易决策链路尚未被实盘样本证明**。 + +缺口包括: + +1. **模型信任层仍未放行** + - 当前主要模型仍是 `blacklist / observe_only`。 + - `decision_support_ready = false`。 + - `execution_allowed = false`。 + - 需要真实或高质量 replay/paper 样本证明模型在特定 session / setup / symbol 下值得进入更高信任层。 + +2. **实盘下单 adapter 仍是 placeholder** + - `OKXOrderAdapter.submit_bracket_order()` 当前返回 `placeholder_not_implemented`。 + - `live-readiness` 明确阻塞在 `adapter_real_live_not_ready`。 + - 没有真实 OKX 下单、撤单、OCO、错误处理、幂等、回执解析的生产级实现。 + +3. **真实成交闭环不足** + - 只读 broker state 可以读取账户、仓位、订单、fills。 + - 但真实交易样本仍未成为完整的 journal / reconciliation / weekly review 可信闭环。 + - `real_trade_sample_verified` 当前是 deferred,不是已验证。 + +4. **operator 反馈样本不足** + - 当前 alert/visual feedback 仍有 `feedback_sample_missing`。 + - 系统需要更多 operator 对“有用 / 误导 / 可执行”的反馈,才能证明辅助质量。 + +5. **视觉确认仍未变成硬准入** + - 当前 visual review 是 advisory。 + - Research council 仍会提示 `visual_review_missing`。 + - 如果要提高实盘使用信心,需要把关键 setup 的视觉确认变成 soft gate 或 hard gate。 + +6. **交易时段和 session 准入仍需生产校准** + - 当前 live snapshot 处于 `OFF_HOURS / session missing / allows_new_positions=false`。 + - 真正实盘辅助前,需要明确 BTC/ETH 的交易时段、禁入窗口、高波动窗口和运营口径。 + +7. **外部通知通道没有完整启用** + - 当前 workbench 可用。 + - webhook / WeCom / email 等外部通道仍可能因 credential missing 而 disabled。 + - 如果要盘中真正辅助,关键状态变化需要主动通知,而不是只靠人工刷新。 + +## 6. 补齐方案 + +### Phase R0: 明确产品边界与上线口径 + +目标:把当前系统正式定级为 `Restricted Decision Support`,避免 operator 误以为它可以指导下单。 + +交付: + +- 在 cockpit / readiness panel 显示清晰状态: + - `受限辅助分析可用` + - `实盘下单不可用` + - `当前模型 observe-only` +- 在 review packet 中明确: + - 可辅助内容 + - 不可执行内容 + - 当前阻塞项 +- 新增一份 operator 使用说明: + - 可以如何用 + - 不能如何用 + - 每次下单前必须人工复核什么 + +验收: + +- `/trading/launch-readiness` 和 cockpit 状态口径一致。 +- operator 看一眼就知道当前不是 live trading mode。 +- 不改变 `execution_allowed` / `real_live_ready`。 + +当前状态(2026-05-17): + +- 已完成。`/trading/launch-readiness` 和 `review-packet` 都暴露 `decision_support_boundary_summary`。 +- cockpit 已显示 `operator boundary`、`boundary summary`、`current model boundary`、`live execution boundary`、`allowed operator actions`、`blocked operator actions` 和 `boundary next gate`。 +- 远端验证通过:focused launch-readiness/operator/static/API tests、全量 unit、脚本静态检查、`node --check src/web/app.js`、deploy smoke、`remote_ops_smoke`。 +- 运行时事实说明:当远端市场流或 broker read 不可用时,`decision_support_boundary_summary.status` 会如实显示 `analysis_blocked`;这不是 R0 失败,而是当前 live readiness 的真实状态。 +- R0 未改变 `execution_allowed`、`real_live_ready`、risk governor、trust bucket 或 operator confirmation protocol。 + +### Phase R1: 把 `decision_support_ready` 从 false 推到 true + +目标:让系统从“受限辅助”进入“可信辅助决策”,但仍不允许执行。 + +交付: + +- 定义 `decision_support_ready` 的严格准入: + - 模型不再是 blacklist。 + - 至少一个 setup/session/symbol 组合有足够样本。 + - replay / paper / operator feedback 达到阈值。 + - risk governor 解释稳定。 +- 增加 trust promotion review: + - 从 `observe_only` 到 `restricted_decision_support` + - 从 `restricted_decision_support` 到 `decision_support` +- 补齐 operator feedback 样本采集: + - 有用 + - 误导 + - 可执行 + - 漏报 + - 误报 + +验收: + +- `decision_support_ready = true` +- `execution_assist_ready = false` +- `real_live_ready = false` +- 系统仍不允许下单。 + +当前状态(2026-05-17): + +- 已实现并部署 R1 的非执行 promotion readiness surface。`/trading/launch-readiness` 现在暴露 `decision_support_promotion_summary`,cockpit / launch readiness panel 也显示 `decision support promotion`、严格准入状态、promotion criteria、failed criteria 和 next promotion gate。 +- 已把该 surface 接入 operator workspace:decision assistant summary 返回 `decision_support_promotion_summary`,`evidence_summary` 暴露 `promotion_status` 和 `next_promotion_gate`。 +- 本地验证通过:focused launch-readiness/operator/static/API tests、全量 unit、脚本静态检查、`node --check src/web/app.js`。 +- 远端部署已完成,deploy smoke 通过;远端字段检查确认 R1 字段和 UI 文案已经上线。 +- 标准 closure gate 已恢复:代理网络恢复后,`run_remote_ops_smoke.py` 通过,`unattended_harness` 返回 `passed`,`runtime_environment` 无 blockers。 +- 当前 `decision_support_promotion_summary.status = needs_trust_promotion`,`next_promotion_gate = decision_support_trust_candidate`;这说明 R1 surface 正在如实呈现 trust / 样本 / 质量准入缺口,而不是运行环境失败。 +- R1 未改变 `execution_allowed`、`real_live_ready`、risk governor、trust bucket 或 operator confirmation protocol。 + +### Phase R2: 影子盘 / paper 盘连续验证 + +目标:证明系统在真实行情节奏里能稳定辅助,而不是只在回放或静态快照里好看。 + +交付: + +- 开启连续 shadow mode: + - 每天记录 candidate。 + - 每天记录系统建议继续观察 / 放弃 / 等确认的原因。 + - 每天记录 operator 是否同意。 +- paper execution / simulated ticket: + - 只记录,不下真实单。 + - 记录入场、止损、TP、失效、结果。 +- 生成日度/周度 shadow report: + - 命中率 + - 错过机会 + - 误报 + - no-trade 是否正确 + - operator override 原因 + +验收: + +- 至少 2 周连续样本。 +- 无 runtime blocker。 +- 关键 setup 有可解释统计。 +- operator feedback 不再是 missing。 + +当前状态(2026-05-17): + +- 已实现并部署 R2 starter slice:新增 `shadow_paper_validation_summary`,并在 `/trading/launch-readiness`、`/trading/review-packet` 的 decision assistant summary、独立 `/trading/shadow-paper-validation` 查询接口和 cockpit 中展示。 +- ledger 聚合真实 `trade_plan_candidates`、`execution_tickets`、`trade_executions`、`trading_journal_entries`、`alert_feedback`、weekly review 与 runtime blockers,输出 `daily_coverage`、样本计数、criteria、failed criteria、`next_validation_gate` 与 operator summary。 +- 已新增显式 `allow_shadow_paper` / `Build Shadow Paper` 入口,只允许 paper 样本收集,不放开 semi-auto 或 live submit;`execution_boundary` 仍为 `real_live_still_blocked`。 +- 本地验证通过:R2 focused tests、launch-readiness/operator/static/API tests、全量 unit、脚本静态检查、`node --check src/web/app.js`。 +- 远端验证通过:deploy smoke、标准 `run_remote_ops_smoke.py`、独立 `/trading/shadow-paper-validation` 字段检查、`/trading/launch-readiness` 字段检查、`/trading/review-packet` decision assistant 字段检查、`/static/app.js` UI 文案检查。 +- R2 starter 远端初始状态是 `collecting_shadow_candidates`,`next_validation_gate = shadow_candidate_day_coverage`;当时实测样本为 `candidate_count=500`、`observed_shadow_day_count=2`、`paper_ticket_count=0`、`paper_execution_count=1`、`operator_feedback_count=0`。因此 R2 的产品能力已经上线,但连续样本证据仍在采集中,不能宣称 R2 phase evidence 已 passed。 +- 2026-05-17 样本采集闭环更新:已新增持久化 `shadow_paper_observations` daily observation ledger、`/trading/shadow-paper/observe`、runtime `shadow_paper_observation` task、cockpit `Record/Agree/Disagree Shadow Observation` 操作,以及 `shadow_observation_count`、`shadow_observation_day_count`、`operator_observation_feedback_count` 和 `sample_collection_plan` 字段。远端已真实写入 observation `id=1`,当前为 `shadow_observation_count=1`、`shadow_observation_day_count=1`、`operator_observation_feedback_count=1`、`operator_feedback_count=1`、`paper_execution_count=1`。 +- paper ticket 样本没有被人为伪造:远端最新 500 条 candidate 当前均为 `blocked`,最新 watch candidate 仍因 `fvg_direction_mismatch` / `fvg_retrace_confirmed` 未满足而不可执行。因此下一步应继续每日 observation,并等待真实 executable candidate 后再 build shadow paper ticket / confirm paper execution。 +- 2026-05-18 R2 evidence backfill audit 更新:已新增 guarded `ShadowPaperObservationBackfillService` 和 `scripts/run_shadow_paper_observation_backfill.py`。该审计扫描过去 14 天真实 candidate 历史,只允许回填 shadow observation;不允许在没有 executable candidate 的情况下回填 paper ticket,不允许在没有 paper ticket 的情况下回填 paper execution,也不允许自动回填 operator feedback。远端 dry-run 找到 10 个可回填的 blocked-candidate 日期,远端 apply 创建 observation `1449` 至 `1458`,覆盖 `2026-05-06`、`2026-05-08`、`2026-05-09`、`2026-05-10`、`2026-05-11`、`2026-05-12`、`2026-05-13`、`2026-05-14`、`2026-05-15`、`2026-05-16`。所有回填记录均为 `candidate_observed` / `stand_down_blocked_candidate` / `operator_agreement=pending`,且 `paper_ticket_id`、`paper_execution_id` 均为空。 +- 2026-05-18 远端最新状态:`observed_shadow_day_count=12`、`shadow_observation_count=13`、`shadow_observation_day_count=11`,R2 已从 `shadow_candidate_day_coverage` 前移到 `paper_ticket_sample_gate`;仍缺 `paper_ticket_count=0/5`、`paper_execution_count=0/5`、`operator_feedback_count=1/5` 和 weekly review evidence。因此 R2 仍不能标记 completed,下一步必须等待或呈现真实 executable candidate,然后走 shadow paper ticket / paper execution / operator feedback / weekly review 的非执行样本闭环。 +- 2026-05-18 next-gate probe blocker:尝试继续读取 `/trading/candidates`、`/trading/launch-readiness`、`/trading/shadow-paper-validation` 时,远端 API 出现 60 秒超时;随后 `/health` 也从 8-20 秒慢响应退化为超时,SSH 密码认证可连接但 command channel 超时,受保护的 `docker restart ai-ict` 尝试未在本地超时前返回。该状态下不能继续写入 paper ticket / execution / operator feedback 样本;下一步必须先恢复远端 API/SSH 命令响应,再重新跑 no-proxy remote ops smoke 和 live readiness checks。 +- 2026-05-18 远端恢复复测:远端 API/SSH 已恢复,no-proxy `/health` 毫秒级返回,标准 `run_remote_ops_smoke.py` 9 项通过并包含 `unattended_harness`。但 live `/trading/launch-readiness` 仍是 `blocked_by_runtime`,`next_validation_gate=runtime_clear_for_shadow`,原因是 OKX market/broker read-only 代理不可达:远端主机和容器都对 `192.168.3.73:10808` 返回 connection refused,容器当前 `MARKET_TRUTH_PROXY_URL=http://192.168.3.73:10808`,`BROKER_READ_PROXY_URL` 为空。独立 `/trading/shadow-paper-validation` 仍保持 `collecting_paper_samples` / `paper_ticket_sample_gate`,但正式 launch readiness 必须先恢复 `runtime_market_broker_ready`。最新 candidate `4244` 为 `blocked`,`executable=false`,缺 `fvg_retrace_confirmed` 且 rejected by `fvg_direction_mismatch`,因此本轮没有创建 shadow paper ticket。 +- R2 未改变 `execution_allowed`、`real_live_ready`、risk governor、trust bucket 或 operator confirmation protocol。 + +### Phase R3: OKX live adapter 预备实现,但只接测试/沙盒 + +目标:把 adapter 从 placeholder 升级成真实实现,但先不接主账户实盘。 + +交付: + +- 实现 OKX signed order client: + - submit order + - cancel order + - cancel all + - query order + - query fills +- 实现 bracket / OCO 语义映射。 +- 实现幂等键与重复提交保护。 +- 实现错误码归一化: + - 网络错误 + - 余额不足 + - 参数错误 + - 交易所拒绝 + - 部分成交 +- 所有 live operation 写入 audit。 +- 默认仍保持 `LIVE_TRADING_ENABLED=0`。 + +验收: + +- sandbox/testnet 或极小风险隔离环境通过 submit/cancel/reconcile。 +- `adapter_real_live_not_ready` 只在明确通过后才允许调整。 +- 主账户真实下单仍不默认开启。 + +当前状态(2026-05-17 R3 isolated smoke attempt): + +- 已实现 OKX sandbox / demo trading preflight starter:新增 `OKXSandboxPreflightService`,可生成 submit order、cancel order、query order、query fills 的签名 dry-run request preview。 +- 已接入 `/trading/live-readiness` 的 `okx_sandbox_preflight_summary`,并新增独立 `/trading/okx-sandbox-preflight` 查询接口。 +- cockpit live readiness 面板已展示 `okx sandbox preflight`、`sandbox next gate`、`simulated trading header`、submit/cancel/query/fills dry-run 状态、idempotency key、error normalization 与 `real_live_still_blocked` 边界。 +- 已具备幂等键 preview、bracket / OCO 语义引用、reconciliation semantics 引用,以及网络错误、权限错误、余额不足、参数错误、交易所拒绝、部分成交的错误归一化目录。 +- 该 starter 只做本地 request preview 与 readiness surface,不发送订单、不写 live operation audit、不改变 `LIVE_TRADING_ENABLED`、不解除 `adapter_real_live_not_ready`。 +- 本地验证通过:93 个 focused API/static/live-readiness/live-adapter/launch-readiness tests、73 个脚本静态检查、`node --check src/web/app.js`、756 个全量 unit tests。 +- 远端部署通过:deploy smoke 11 endpoints、标准 `run_remote_ops_smoke.py` 9 checks、远端字段 smoke 确认 `/trading/okx-sandbox-preflight` 与 `/trading/live-readiness` 都返回 `next_gate=sandbox_submit_cancel_reconcile_smoke`、`execution_boundary=real_live_still_blocked`、`live_trading_enabled=false`,并确认 `/static/app.js` 已包含 `okx sandbox preflight` / `sandbox next gate` / `simulated trading header` UI 文案。 +- 本轮已新增 R3 isolated smoke 工具:`OKXSandboxPreflightService.run_isolated_smoke()` 与 `scripts/run_okx_sandbox_smoke.py` 可在显式 `OKX_SANDBOX_SMOKE_EXECUTE=1` 时执行 submit -> query -> cancel -> query -> fills,并写出 `.claude/okx_sandbox_smoke_latest.json`;默认 dry-run 会以 `okx_sandbox_smoke_execute_not_enabled` 安全阻塞。 +- isolated smoke 会复用 `broker_read_proxy_url` / `market_truth_proxy_url`,只输出 `proxy_configured=true/false`,不暴露代理地址或 OKX secret;实际请求保持 `x-simulated-trading=1`,且在 `LIVE_TRADING_ENABLED=true` 时拒绝运行。 +- 本轮本地验证通过:12 个 focused OKX/API/live-readiness/script tests、73 个脚本静态检查、`node --check src/web/app.js`、本地 dry-run 安全阻塞、761 个全量 unit tests。 +- 本轮远端验证通过:live deploy smoke 11 endpoints、标准 `run_remote_ops_smoke.py` 9 checks、`unattended_harness=passed`。 +- 本轮真实 OKX demo submit 已尝试:`OKX_SANDBOX_SMOKE_EXECUTE=1`、`instrument_id=2`、`x-simulated-trading=1`、`proxy_configured=true`、`live_trading_enabled=false`;OKX 在 submit 阶段返回 HTTP 401 / code `50120` / `permission_error`(`This API key doesn't have permission to use this function`)。 +- R3 尚未最终验收:代码链路已经能到达 OKX demo trading endpoint,但当前远端 API key 缺少交易函数权限。 +- 当前产品边界修正:本阶段只做交易辅助分析决策,不做真实或模拟下单执行,因此 OKX trade permission / submit-cancel sandbox smoke 不应作为当前 assisted-analysis 闭环 blocker。该项降级为未来 execution adapter / real-trading preparation 阶段再处理。 +- 当前阶段不能为了通过 R3 smoke 而启用 trade-permission credentials;只有项目明确进入执行适配或真实交易准备时,才重新要求隔离 sandbox/demo trade-permission key 并跑 submit / query / cancel / query / fills / reconcile smoke。 + +### Phase R4: 人工确认的极小实盘试点 + +目标:在所有前置条件满足后,用极小仓位验证真实闭环。 + +前置条件: + +- `decision_support_ready = true` +- `execution_assist_ready = true` +- OKX adapter 通过测试环境验证。 +- risk governor 通过。 +- operator confirmation protocol 完整。 +- kill switch 可用。 +- `LIVE_TRADING_ENABLED` 由人工显式打开。 + +交付: + +- 只允许单 symbol。 +- 只允许最小风险额度。 +- 只允许人工确认。 +- 每笔真实交易必须: + - 写入 audit。 + - 写入 execution。 + - 匹配 broker order/fill。 + - 自动生成 journal。 + - 纳入 weekly review。 + +验收: + +- `real_trade_sample_verified = true` +- reconciliation 可匹配真实 order/fill。 +- 失败时能安全停止。 +- 周报能区分模型问题、执行问题和 operator override。 + +当前状态(2026-05-17): + +- 未开始。R4 仍被 R1 `decision_support_ready=false`、R2 连续 shadow/paper 证据未满、R3 sandbox adapter 未完成真实隔离环境 submit/cancel/reconcile 验收所阻塞。 +- 不能提前打开 `LIVE_TRADING_ENABLED`,也不能把 cockpit 输出作为真实下单指令。 + +### Phase R5: 实盘辅助稳定化 + +目标:形成可长期运行的半自动实盘辅助系统,但仍不做默认自动交易。 + +交付: + +- 运行手册。 +- 事故处理手册。 +- 每周复盘节奏。 +- trust bucket 晋级/降级制度。 +- 风控参数变更审批记录。 +- 外部通知通道启用和静默窗口策略。 + +验收: + +- 连续 4 周稳定运行。 +- runtime smoke 长期通过。 +- 每周 review 有明确 action。 +- 所有执行相关动作可审计。 + +当前状态(2026-05-17): + +- 未开始。R5 需要 R4 极小实盘试点完成后才有真实运行数据、事故样本、周复盘节奏和 trust bucket 晋级/降级证据。 +- 当前可以先保留运行手册/周复盘制度草案,但不能宣称稳定化运行闭环完成。 + +## 7. 推荐下一步 + +下一步仍不要直接做主账户 live submit,也不要直接打开 `LIVE_TRADING_ENABLED`。 + +当前未彻底闭环的阶段: + +1. R1:promotion/readiness surface 已上线并通过远端 closure gate,但 `decision_support_ready=true` 的 trust / 样本 / 质量验收未通过。 +2. R2:shadow/paper 采集闭环已上线并写入真实 observation;2026-05-18 已完成 14 天 backfill audit 并把 shadow day coverage 推过门槛,独立 R2 ledger 当前卡在 `paper_ticket_sample_gate`,paper ticket / paper execution / operator feedback / weekly review 证据未满;远端 API/SSH 已恢复,但正式 launch readiness 继续采样前还需要先恢复 OKX read-only proxy / runtime market-broker gate。 +3. R3:sandbox preflight 与 isolated smoke 工具已完成;trade-permission submit/cancel smoke 属于未来执行适配阶段,本阶段不再作为 assisted-analysis 的当前 blocker。 +4. R4:未开始,且已明确不属于当前 assisted-analysis-only 阶段。 +5. R5:未开始,必须等 R4 有真实试点样本后才能进入稳定化运行制度闭环。 + +下一轮建议最小闭环切片: + +```text +R1/R2 decision-support evidence growth / shadow-paper sample gate slice +``` + +建议范围: + +- 继续采集真实 shadow observation、paper ticket、paper execution 和 operator feedback 样本。 +- 不从 blocked candidate 人工伪造 paper ticket;当前 `shadow_candidate_day_coverage` 已通过,但下一步先恢复 OKX read-only proxy / runtime market-broker gate,再等待或呈现真实 executable candidate,创建 shadow paper ticket、确认 paper execution、记录 operator feedback,并补 weekly review evidence。 +- 以 trust / 样本 / 质量门推动 `decision_support_ready`,但继续保持 `execution_assist_ready=false`、`real_live_ready=false`、`LIVE_TRADING_ENABLED=false`。 +- 不要求 OKX trade permission,不运行 submit/cancel smoke,不替换 placeholder adapter 为可执行 adapter。 + +## 8. 最终判断 + +当前系统已经接近一个可用的“实盘辅助分析工作台”,但还不是可以指导真实下单的“实盘交易决策系统”。 + +可以使用的口径: + +```text +用它辅助看盘、整理证据、提醒缺失确认项、解释风险和复盘。 +``` + +不可以使用的口径: + +```text +让它告诉你现在该不该下真实单,并按它的输出直接交易。 +``` + +只有当 `decision_support_ready`、`execution_assist_ready`、真实 adapter、真实成交 reconciliation、operator confirmation 和 real-trade sample 都完成闭环后,系统才可以进入“人工确认的极小实盘试点”。 diff --git a/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md b/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md index 8937360..d17c5f2 100644 --- a/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md +++ b/docs/AI_ICT_需求文档剩余功能与延期项清单_2026-05-14_v1.md @@ -145,6 +145,320 @@ - 下一刀最小闭环切片改为: - `W9 workflow archive deep bridge extension / bundle export persistence slice` +## 11. 2026-05-15 W9/O3 Addendum + +- `W9 workflow archive deep bridge extension / bundle export persistence slice` 已完成。 +- `O3 archive long-range trend unification / operator overview slice` 已完成。 +- 当前已具备: + - persisted workflow bridge state + - unified archive operator overview + - cockpit `Persist Bridge State` +- 下一刀最小闭环切片改为: + - `B8 archive deep retrieval deepening / ranked aggregation slice` + +## 12. 2026-05-16 B8 Addendum + +- `B8 archive deep retrieval deepening / ranked aggregation slice` 已完成。 +- 当前已具备: + - digest / workflow archive `ranked_aggregation_summary` + - review-packet `decision_assistant_summary` 内的 archive ranked aggregation continuity + - cockpit `archive top profile / archive top kind / workflow top profile / workflow top session / workflow top phase` + - unified `archive operator overview` continuity with ranked archive retrieval +- 关闭说明: + - 本地 focused/full validation 已通过 + - 远端字段 smoke 已确认 ranked aggregation 与 operator overview 在线 + - 标准 `run_remote_ops_smoke.py` 曾被 `unattended_harness -> maintenance_summary/runtime_environment` 环境 blocker 卡住 + - 2026-05-16 在代理恢复后,标准 closure gate 已恢复 `passed` +- 下一步: + - 重新刷新本清单中的剩余 backlog,再选择新的最小闭环切片 + +## 13. 2026-05-16 W10 Addendum + +- `W10 workflow archive group bundle export / day-session bridge slice` 已完成。 +- 当前已具备: + - workflow archive day-group bundle export + - workflow archive session-group bundle export + - `workflow_group_bundle` export scope + - cockpit `Export Day Bundle` / `Export Session Bundle` +- 仍未完成的 workflow 深桥接缺口收敛为: + - compare 结果回写为结构化 replay note / persisted writeback +- 下一刀最小闭环切片改为: + - `W11 workflow archive compare writeback / structured replay note persistence slice` + +## 14. 2026-05-16 W11 Addendum + +- `W11 workflow archive compare writeback / structured replay note persistence slice` 已完成。 +- 当前已具备: + - workflow compare replay note writeback + - compare replay note persisted bridge summary + - cockpit `Persist Compare Replay Note` / `Prefill Saved Compare Note` + - `review-packet` 内的 compare writeback persistence readback +- 说明: + - workflow archive 深桥接链路中,“compare 结果回写为结构化 replay note / persisted writeback”这一缺口已收口 +- 下一刀最小闭环切片改为: + - `B9 archive structured export deepening / selection provenance slice` + +## 15. 2026-05-16 B9 Addendum + +- `B9 archive structured export deepening / selection provenance slice` 已完成。 +- 当前已具备: + - digest/workflow archive export `selection_provenance_summary` + - export scope / filter / snapshot selection provenance fingerprint + - cockpit `export provenance` / `workflow export provenance` / `selection fingerprint` +- 说明: + - archive structured export 现在不再只是“导出结果本身”,也会说明结果是按什么选择链路导出的 +- 下一刀最小闭环切片改为: + - `O4 archive long-range trend deepening / baseline delta story slice` + +## 16. 2026-05-16 O4 Addendum + +- `O4 archive long-range trend deepening / baseline delta story slice` 已完成。 +- 当前已具备: + - digest/workflow baseline delta status + - digest/workflow baseline delta story + - operator overview baseline delta story readback +- 下一刀最小闭环切片改为: + - `E13 external context story summaries / capture guidance slice` + +## 17. 2026-05-16 E13/E14 Addendum + +- `E13 external context story summaries / capture guidance slice` 已完成。 +- `E14 visual evidence bridge story slice` 已完成。 +- 当前已具备: + - provider health story + - capture surface story + - screenshot readiness story + - visual evidence bridge story +- 下一刀最小闭环切片改为: + - `E15 external context visual evidence provenance / surface selection history slice` + +## 18. 2026-05-16 E15/E16 Addendum + +- `E15 external context visual evidence provenance / surface selection history slice` 已完成。 +- `E16 external context surface history browser / reopen action slice` 已完成。 +- 当前已具备: + - persisted external context surface selection history + - review-packet external surface history readback + - cockpit `surface history` / `surface history story` + - `Reopen Surface` action +- 下一刀最小闭环切片改为: + - `E17 external context surface history grouping / dominant-surface summary slice` + +## 19. 2026-05-16 E17 Addendum + +- `E17 external context surface history grouping / dominant-surface summary slice` 已完成。 +- 当前已具备: + - surface history grouping + - dominant surface summary + - dominant source-group summary + - cockpit `dominant surface` / `dominant surface story` +- 下一刀最小闭环切片改为: + - `E18 external context surface history recency / revisit prompt slice` + +## 20. 2026-05-16 E18 Addendum + +- `E18 external context surface history recency / revisit prompt slice` 已完成。 +- 当前已具备: + - surface history recency status + - revisit-needed flag + - revisit prompt / revisit target + - cockpit `surface history recency` / `surface revisit prompt` / `Reopen Latest Surface` +- 下一刀最小闭环切片改为: + - `E19 external context surface history cadence / revisit queue slice` + +## 21. 2026-05-16 E19 Addendum + +- `E19 external context surface history cadence / revisit queue slice` 已完成。 +- 当前已具备: + - surface history cadence status / cadence story + - revisit queue / revisit queue count + - next revisit prompt + - cockpit `surface history cadence` / `revisit queue count` / `Reopen Revisit` +- 下一刀最小闭环切片改为: + - `E20 external context surface history revisit resolution / acknowledgment slice` + +## 22. 2026-05-16 E20 Addendum + +- `E20 external context surface history revisit resolution / acknowledgment slice` 已完成。 +- 当前已具备: + - revisit acknowledgment persistence + - acknowledged revisit count / latest acknowledged surface readback + - revisit queue acknowledged removal + - cockpit `acknowledged revisits` / `Acknowledge Revisit` +- 下一刀最小闭环切片改为: + - `E21 external context revisit resolution history / cleared-queue summary slice` + +## 23. 2026-05-16 E21 Addendum + +- `E21 external context revisit resolution history / cleared-queue summary slice` 已完成。 +- 当前已具备: + - resolution history / resolution history count + - cleared-queue status / cleared-queue story + - latest acknowledged metadata readback + - cockpit `cleared queue status` / `resolution history count` / `Reopen Cleared Surface` +- 下一刀最小闭环切片改为: + - `E22 external context revisit acknowledgment note / resolution filter slice` + +## 24. 2026-05-16 E22 Addendum + +- `E22 external context revisit acknowledgment note / resolution filter slice` 已完成。 +- 当前已具备: + - acknowledgment note persistence + - resolution filter summary + - latest acknowledged note readback + - cockpit `Acknowledge With Note` / `All Resolved` / `With Resolution Note` / `Without Resolution Note` +- 下一刀最小闭环切片改为: + - `E23 external context resolution note gap / acknowledgment filter follow-up slice` + +## 25. 2026-05-16 E23 Addendum + +- `E23 external context resolution note gap / acknowledgment filter follow-up slice` 已完成。 +- 当前已具备: + - note-gap queue summary + - acknowledgment-by filter option + - acknowledgment note follow-up action + - cockpit `Add Resolution Note` / `All Resolved` / `With Resolution Note` / `Without Resolution Note` +- 下一刀最小闭环切片改为: + - `E24 external context resolution owner filter / note-gap queue summary slice` + +## 26. 2026-05-16 E24 Addendum + +- `E24 external context resolution owner filter / note-gap queue summary slice` 已完成。 +- 当前已具备: + - resolution owner counts in summary + - active resolution filter visibility + - note-gap queue summary visibility + - cockpit `resolution owners` / `active resolution filter` / `Reopen Note Gap` +- 下一刀最小闭环切片改为: + - `E25 external context resolution owner spotlight / note-gap aging slice` + +## 27. 2026-05-16 E25 Addendum + +- `E25 external context resolution owner spotlight / note-gap aging slice` 已完成。 +- 当前已具备: + - owner spotlight / owner spotlight prompt + - note-gap oldest item / average aging + - cockpit `owner spotlight` / `owner spotlight count` / `note gap avg age` +- 下一刀最小闭环切片改为: + - `E26 external context resolution owner cadence / note-gap priority lane slice` + +## 28. 2026-05-16 E26 Addendum + +- `E26 external context resolution owner cadence / note-gap priority lane slice` 已完成。 +- 当前已具备: + - owner cadence status / average gap / cadence story + - note-gap priority lane status / count / items + - cockpit `owner cadence` / `priority lane status` / `Open Priority Surface` +- 下一刀最小闭环切片改为: + - `E27 external context resolution owner trend / priority-lane resolution story slice` + +## 29. 2026-05-16 E27 Addendum + +- `E27 external context resolution owner trend / priority-lane resolution story slice` 已完成。 +- 当前已具备: + - owner trend status / owner trend story + - priority-lane resolution story + - cockpit `owner trend` / `owner trend story` / `priority lane story` +- 下一刀最小闭环切片改为: + - `E28 external context resolution owner handoff / priority-lane carry-forward slice` + +## 30. 2026-05-17 E28/E29 Addendum + +- `E28 external context resolution owner handoff / priority-lane carry-forward slice` 已完成。 +- `E29 external context resolution carry-forward aging / owner handoff follow-up slice` 已完成。 +- 当前已具备: + - owner handoff status / story + - owner handoff follow-up status / story + - carry-forward aging status / story + - cockpit `owner handoff` / `owner handoff follow-up` / `carry-forward aging` +- 下一刀最小闭环切片改为: + - `E30 external context resolved-owner drift / carry-forward follow-up lane slice` + +## 31. 2026-05-17 E30 Addendum + +- `E30 external context resolved-owner drift / carry-forward follow-up lane slice` 已完成。 +- 当前已具备: + - resolved-owner drift status / story + - carry-forward follow-up lane status / count / story + - cockpit `resolved owner drift` / `follow-up lane status` / `Open Follow-Up Lane` +- 说明: + - 本轮顺手修正了锚点重复问题,移除了会把下一刀误导回 `E28/E29` 的重复记录。 +- 下一刀最小闭环切片改为: + - `E31 external context resolved-owner drift review / follow-up lane rollover slice` + +## 32. 2026-05-17 E31 Addendum + +- `E31 external context resolved-owner drift review / follow-up lane rollover slice` 已完成。 +- 当前已具备: + - resolved-owner drift review status / story + - carry-forward follow-up lane rollover status / story + - cockpit `resolved owner drift review` / `follow-up lane rollover` / `Open Rollover Surface` + - market stream `proxy -> direct` websocket fallback hardening +- 关闭说明: + - 网络恢复后,远端 `192.168.3.73:10808` 对 `192.168.3.90` 已恢复可达 + - `unattended_harness` 已恢复 `passed` + - 标准 `run_remote_ops_smoke.py` 已恢复 9/9 passed +- 下一刀最小闭环切片改为: + - `E32 external context resolved-owner drift review queue / rollover prompt slice` + +## 33. 2026-05-17 E32 Addendum + +- `E32 external context resolved-owner drift review queue / rollover prompt slice` 已完成。 +- 当前已具备: + - resolved-owner drift review queue status / count / story + - carry-forward lane rollover prompt + - cockpit `drift review queue` / `rollover prompt` / `Open Drift Review` +- 下一刀最小闭环切片改为: + - `E33 external context resolved-owner drift review queue aging / rollover handoff slice` + +## 34. 2026-05-17 E33 Addendum + +- `E33 external context resolved-owner drift review queue aging / rollover handoff slice` 已完成。 +- 当前已具备: + - drift review queue aging status / story + - rollover handoff status / story / prompt + - cockpit `drift review queue aging` / `rollover handoff` / `Open Rollover Handoff` +- 结论: + - `external context` 这条 deep visual evidence / surface-history / resolution 长尾线现已完整闭环。 + - 当前这份增强计划里,不再保留 external context 的活动切片指针。 +- 下一步: + - 继续开发前,先刷新本清单其余部分的真实状态,再重新开新的非 external-context backlog。 + +## 35. 2026-05-17 Backlog Refresh Addendum + +- 本轮刷新后,`external context` 长尾线已完整闭环,不再保留活动切片指针。 +- 同时按当前代码与锚点对齐,以下两条也按当前计划粒度视为已闭环: + - `workflow archive` deep bridge / export bridge line + - `archive long-range trend / operator overview` line +- 因此,当前仍保留为活动 backlog 的非 `external context` 方向只剩: + - `archive` deep retrieval / structured export normalization +- 新的下一刀最小闭环切片改为: + - `B10 archive export schema normalization / bundle manifest slice` + +## 36. 2026-05-17 B10 Addendum + +- `B10 archive export schema normalization / bundle manifest slice` 已完成。 +- 当前已具备: + - digest / workflow export `decision_assistant_archive_export_v4` + - normalized `bundle_manifest` + - normalized `structured_export_summary` + - cockpit `bundle manifest` / `workflow bundle manifest` / `manifest sections` +- 结论: + - 按当前这份增强计划的粒度,活动 backlog 已不再剩下明确的非延期主线切片。 +- 下一步: + - 继续开发前,先重新整理并刷新新的 backlog 文档,再决定新的产品方向。 + +## 21. 2026-05-16 E19 Addendum + +- `E19 external context surface history cadence / revisit queue slice` 已完成。 +- 当前已具备: + - surface history cadence status / cadence story + - revisit queue / revisit queue count + - next revisit prompt + - cockpit `surface history cadence` / `revisit queue count` / `Reopen Revisit` +- 下一刀最小闭环切片改为: + - `E20 external context surface history revisit resolution / acknowledgment slice` + ## 11. 2026-05-15 W9 Addendum - `W9 workflow archive deep bridge extension / bundle export persistence slice` 已完成。 diff --git a/migrations/046_create_shadow_paper_observations.sql b/migrations/046_create_shadow_paper_observations.sql new file mode 100644 index 0000000..70c4a62 --- /dev/null +++ b/migrations/046_create_shadow_paper_observations.sql @@ -0,0 +1,28 @@ +create table if not exists shadow_paper_observations ( + id bigserial primary key, + instrument_id bigint not null references instruments(id) on delete cascade, + observation_date date not null, + timeframe varchar(16) not null default '1m', + session_code varchar(32) not null default 'ALL', + candidate_id bigint references trade_plan_candidates(id) on delete set null, + decision_packet_id bigint references decision_packets(id) on delete set null, + paper_ticket_id bigint references execution_tickets(id) on delete set null, + paper_execution_id bigint references trade_executions(id) on delete set null, + observation_status varchar(64) not null default 'observed', + system_recommendation varchar(64) not null default 'monitor', + operator_agreement varchar(32) not null default 'pending', + operator_note text not null default '', + created_by text not null default 'runtime_shadow_collector', + reason_codes jsonb not null default '[]'::jsonb, + payload jsonb not null default '{}'::jsonb, + observed_ts timestamptz not null default now(), + created_ts timestamptz not null default now(), + updated_ts timestamptz not null default now(), + unique (instrument_id, observation_date, timeframe, session_code) +); + +create index if not exists idx_shadow_paper_observations_instrument_date_desc +on shadow_paper_observations (instrument_id, observation_date desc, id desc); + +create index if not exists idx_shadow_paper_observations_status_date_desc +on shadow_paper_observations (instrument_id, observation_status, observation_date desc); diff --git a/scripts/run_okx_sandbox_smoke.py b/scripts/run_okx_sandbox_smoke.py new file mode 100644 index 0000000..839be7e --- /dev/null +++ b/scripts/run_okx_sandbox_smoke.py @@ -0,0 +1,85 @@ +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.broker import OKXSandboxPreflightService + +DEFAULT_ARTIFACT_PATH = ROOT / ".claude" / "okx_sandbox_smoke_latest.json" + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = _get_int(env, "OKX_SANDBOX_SMOKE_INSTRUMENT_ID", 1) + timeframe = str(env.get("OKX_SANDBOX_SMOKE_TIMEFRAME") or "1m") + execute = _get_bool(env, "OKX_SANDBOX_SMOKE_EXECUTE", False) + output_json = _get_bool(env, "OKX_SANDBOX_SMOKE_OUTPUT_JSON", False) + artifact_path = _artifact_path(env) + payload = OKXSandboxPreflightService().run_isolated_smoke( + instrument_id=instrument_id, + timeframe=timeframe, + execute=execute, + order_price=_get_optional(env, "OKX_SANDBOX_SMOKE_PRICE"), + order_size=_get_optional(env, "OKX_SANDBOX_SMOKE_SIZE"), + order_side=str(env.get("OKX_SANDBOX_SMOKE_SIDE") or "buy"), + order_type=str(env.get("OKX_SANDBOX_SMOKE_ORDER_TYPE") or "post_only"), + td_mode=str(env.get("OKX_SANDBOX_SMOKE_TD_MODE") or "isolated"), + timeout_seconds=_get_float(env, "OKX_SANDBOX_SMOKE_TIMEOUT_SECONDS", 20.0), + ) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"OKX sandbox smoke status: {payload.get('status')}") + print(f"Execute requested: {payload.get('execute_requested')}") + print(f"Blockers: {payload.get('blockers') or []}") + print(f"Artifact: {artifact_path}") + return 0 if payload.get("status") == "passed" else 2 + + +def _artifact_path(env) -> Path: + value = str(env.get("OKX_SANDBOX_SMOKE_ARTIFACT_PATH") or "").strip() + if value: + path = Path(value) + return path if path.is_absolute() else ROOT / path + return DEFAULT_ARTIFACT_PATH + + +def _get_optional(env, key: str): + value = env.get(key) + if value is None: + return None + value = str(value).strip() + return value or None + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +def _get_float(env, key: str, default: float) -> float: + value = env.get(key) + if value is None or value == "": + return default + return float(value) + + +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_readonly_live_analysis_smoke.py b/scripts/run_readonly_live_analysis_smoke.py new file mode 100644 index 0000000..f634843 --- /dev/null +++ b/scripts/run_readonly_live_analysis_smoke.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Optional +import argparse +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request + + +DEFAULT_BASE_URL = "http://192.168.3.90:8000" +DEFAULT_TIMEOUT_SECONDS = 20.0 +DEFAULT_ATTEMPTS = 2 +DEFAULT_SLEEP_SECONDS = 2.0 + + +ENDPOINTS = ( + { + "name": "health", + "method": "GET", + "path": "/health", + "expected_statuses": (200,), + }, + { + "name": "runtime_status", + "method": "GET", + "path": "/trading/runtime-status", + "expected_statuses": (200,), + }, + { + "name": "launch_readiness", + "method": "GET", + "path": "/trading/launch-readiness?instrument_id={instrument_id}&timeframe={timeframe}", + "expected_statuses": (200,), + "payload_check": "readonly_launch_ready", + }, + { + "name": "review_packet", + "method": "GET", + "path": "/trading/review-packet?instrument_id={instrument_id}", + "expected_statuses": (200,), + "payload_check": "review_packet_available", + }, + { + "name": "shadow_paper_validation", + "method": "GET", + "path": "/trading/shadow-paper-validation?instrument_id={instrument_id}&timeframe={timeframe}", + "expected_statuses": (200,), + "payload_check": "shadow_paper_surface_available", + }, + { + "name": "weekly_review", + "method": "GET", + "path": "/trading/research/weekly-review?instrument_id={instrument_id}", + "expected_statuses": (200,), + "payload_check": "weekly_review_entrypoint_available", + }, + { + "name": "candidates", + "method": "GET", + "path": "/trading/candidates?instrument_id={instrument_id}&limit=5", + "expected_statuses": (200,), + "payload_check": "candidates_surface_available", + }, + { + "name": "static_app", + "method": "GET", + "path": "/static/app.js", + "expected_statuses": (200,), + "payload_check": "readonly_boundary_copy_present", + "decode_text": True, + }, +) + + +def run_readonly_live_analysis_smoke( + *, + base_url: str = DEFAULT_BASE_URL, + instrument_id: int = 1, + timeframe: str = "1m", + attempts: int = DEFAULT_ATTEMPTS, + sleep_seconds: float = DEFAULT_SLEEP_SECONDS, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable[[urllib.request.Request, float], object]] = None, + sleeper: Optional[Callable[[float], None]] = None, +) -> dict: + opener = opener or _urlopen_no_proxy + sleeper = sleeper or time.sleep + endpoint_results = [] + blockers = [] + warnings = [] + passed_checks = [] + launch_payload = {} + + for endpoint in ENDPOINTS: + result = _call_endpoint( + endpoint=endpoint, + base_url=base_url, + instrument_id=instrument_id, + timeframe=timeframe, + attempts=max(attempts, 1), + sleep_seconds=max(sleep_seconds, 0.0), + timeout_seconds=max(timeout_seconds, 0.1), + opener=opener, + sleeper=sleeper, + ) + endpoint_results.append(result) + if result["name"] == "launch_readiness": + launch_payload = dict(result.get("payload_excerpt") or {}) + if result["status"] == "passed": + passed_checks.append(result["name"]) + elif result["status"] == "warning": + warnings.append(result["name"]) + else: + blockers.append(result["name"]) + + launch_summary = dict(launch_payload.get("live_analysis_assistant_summary") or {}) + ready = bool(launch_payload.get("live_analysis_assistant_ready") or launch_summary.get("ready_for_readonly_live_analysis")) + must_not_trade = bool(launch_summary.get("must_not_trade", True)) + blocked_reason = blockers[0] if blockers else "" + return { + "status": "blocked" if blockers else "passed", + "mode": "readonly_live_analysis_smoke", + "generated_ts": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "instrument_id": instrument_id, + "timeframe": timeframe, + "ready_for_readonly_live_analysis": ready, + "must_not_trade": must_not_trade, + "real_trade_required": False, + "trade_permission_required": False, + "submit_cancel_or_live_execution_called": False, + "blocked_reason": blocked_reason, + "warnings": warnings, + "blockers": blockers, + "passed_checks": passed_checks, + "endpoints": endpoint_results, + } + + +def _call_endpoint( + *, + endpoint: dict, + base_url: str, + instrument_id: int, + timeframe: str, + attempts: int, + sleep_seconds: float, + timeout_seconds: float, + opener, + sleeper, +) -> dict: + name = endpoint["name"] + method = endpoint["method"] + path = endpoint["path"].format( + instrument_id=urllib.parse.quote(str(instrument_id)), + timeframe=urllib.parse.quote(str(timeframe or "1m")), + ) + url = base_url.rstrip("/") + path + request = urllib.request.Request(url, headers={"Accept": "application/json"}, method=method) + expected_statuses = tuple(endpoint.get("expected_statuses") or (200,)) + last_error = None + + for attempt in range(1, attempts + 1): + try: + with opener(request, timeout_seconds) as response: + raw_body = response.read() + payload = _decode_payload(raw_body, decode_text=bool(endpoint.get("decode_text"))) + status_code = int(getattr(response, "status", 0) or 0) + return _classify_response( + endpoint=endpoint, + path=path, + status_code=status_code, + expected_statuses=expected_statuses, + body_size=len(raw_body), + payload=payload, + attempt=attempt, + ) + except urllib.error.HTTPError as exc: + raw_body = exc.read() + payload = _decode_payload(raw_body, decode_text=bool(endpoint.get("decode_text"))) + return _classify_response( + endpoint=endpoint, + path=path, + status_code=int(exc.code), + expected_statuses=expected_statuses, + body_size=len(raw_body), + payload=payload, + attempt=attempt, + ) + except Exception as exc: + last_error = exc + if attempt < attempts: + sleeper(sleep_seconds) + + return { + "name": name, + "method": method, + "path": path, + "status": "blocked", + "reason": "endpoint_unreachable", + "attempts": attempts, + "error": str(last_error), + } + + +def _classify_response( + *, + endpoint: dict, + path: str, + status_code: int, + expected_statuses: tuple, + body_size: int, + payload, + attempt: int, +) -> dict: + result = { + "name": endpoint["name"], + "method": endpoint["method"], + "path": path, + "status_code": status_code, + "body_size": body_size, + "attempt": attempt, + } + if status_code not in expected_statuses: + result.update( + { + "status": "blocked", + "reason": "unexpected_http_status", + "expected_statuses": list(expected_statuses), + "payload_excerpt": _payload_excerpt(payload), + } + ) + return result + payload_check = str(endpoint.get("payload_check") or "") + if payload_check: + check_result = _run_payload_check(payload_check=payload_check, payload=payload) + result.update(check_result) + result["payload_excerpt"] = _payload_excerpt(payload) + return result + result["status"] = "passed" + result["payload_excerpt"] = _payload_excerpt(payload) + return result + + +def _run_payload_check(*, payload_check: str, payload) -> dict: + if payload_check == "readonly_launch_ready": + return _check_readonly_launch_ready(dict(payload or {})) + if payload_check == "review_packet_available": + payload_dict = dict(payload or {}) + decision_packet = dict(payload_dict.get("decision_packet") or {}) + if ( + payload_dict.get("status") + or payload_dict.get("action_recommendation") + or decision_packet.get("status") + or decision_packet.get("action_recommendation") + ): + return {"status": "passed"} + return {"status": "blocked", "reason": "review_packet_missing_status"} + if payload_check == "shadow_paper_surface_available": + payload_dict = dict(payload or {}) + if payload_dict.get("status") and payload_dict.get("execution_boundary") == "real_live_still_blocked": + return {"status": "passed"} + return {"status": "blocked", "reason": "shadow_paper_surface_unavailable"} + if payload_check == "candidates_surface_available": + if isinstance(payload, dict) and isinstance(payload.get("items"), list): + return {"status": "passed"} + return {"status": "blocked", "reason": "candidate_surface_unavailable"} + if payload_check == "weekly_review_entrypoint_available": + payload_dict = dict(payload or {}) + if "entry_count" in payload_dict and isinstance(payload_dict.get("next_week_actions"), list): + return {"status": "passed"} + return {"status": "blocked", "reason": "weekly_review_entrypoint_unavailable"} + if payload_check == "readonly_boundary_copy_present": + text = str(payload or "") + missing = [ + item + for item in [ + "Read-only Live Analysis", + "No order submission", + "Not a buy/sell instruction", + "Execution remains disabled", + "ready_for_readonly_live_analysis", + "must_not_trade", + ] + if item not in text + ] + if not missing: + return {"status": "passed"} + return {"status": "blocked", "reason": "readonly_boundary_copy_missing", "missing_copy": missing} + return {"status": "passed"} + + +def _check_readonly_launch_ready(payload: dict) -> dict: + summary = dict(payload.get("live_analysis_assistant_summary") or {}) + ready = bool(payload.get("live_analysis_assistant_ready") or summary.get("ready_for_readonly_live_analysis")) + must_not_trade = bool(summary.get("must_not_trade", True)) + execution_assist_ready = bool(payload.get("execution_assist_ready")) + real_live_ready = bool(payload.get("real_live_ready")) + if not ready: + return { + "status": "blocked", + "reason": "readonly_live_analysis_not_ready", + "blocking_reasons": list(payload.get("blocking_reasons") or []), + } + if not must_not_trade: + return {"status": "blocked", "reason": "must_not_trade_boundary_missing"} + if execution_assist_ready or real_live_ready: + return { + "status": "blocked", + "reason": "execution_boundary_not_readonly", + "execution_assist_ready": execution_assist_ready, + "real_live_ready": real_live_ready, + } + return {"status": "passed"} + + +def _decode_payload(raw_body: bytes, *, decode_text: bool): + if not raw_body: + return "" if decode_text else None + if decode_text: + return raw_body.decode("utf-8", errors="replace") + try: + return json.loads(raw_body.decode("utf-8")) + except Exception: + return raw_body[:512].decode("utf-8", errors="replace") + + +def _payload_excerpt(payload): + if isinstance(payload, dict): + keys = [ + "status", + "action_recommendation", + "scheduler_state", + "current_blockers", + "live_analysis_assistant_ready", + "execution_assist_ready", + "real_live_ready", + "product_operating_mode", + "readonly_account_context_status", + "blocking_reasons", + "warning_reasons", + "live_analysis_assistant_summary", + "readonly_live_health_summary", + "entry_count", + "next_week_actions", + ] + excerpt = {key: payload.get(key) for key in keys if key in payload} + if "decision_packet" in payload and isinstance(payload.get("decision_packet"), dict): + packet = dict(payload.get("decision_packet") or {}) + excerpt["decision_packet"] = { + "status": packet.get("status"), + "action_recommendation": packet.get("action_recommendation"), + "candidate_id": packet.get("candidate_id"), + } + if "items" in payload and isinstance(payload.get("items"), list): + excerpt["items_count"] = len(payload.get("items") or []) + return excerpt or {key: payload.get(key) for key in sorted(payload.keys())[:8]} + if isinstance(payload, str): + return payload[:512] + return payload + + +def _urlopen_no_proxy(request: urllib.request.Request, timeout_seconds: float): + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + return opener.open(request, timeout=timeout_seconds) + + +def _get_bool(env, key: str, default: bool) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _parse_args(argv: Optional[list[str]], env) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run read-only live analysis launch smoke against ai-exchange API.") + parser.add_argument("--base-url", default=env.get("READONLY_LIVE_ANALYSIS_SMOKE_BASE_URL", DEFAULT_BASE_URL)) + parser.add_argument("--instrument-id", type=int, default=int(env.get("READONLY_LIVE_ANALYSIS_SMOKE_INSTRUMENT_ID", "1"))) + parser.add_argument("--timeframe", default=env.get("READONLY_LIVE_ANALYSIS_SMOKE_TIMEFRAME", "1m")) + parser.add_argument("--attempts", type=int, default=int(env.get("READONLY_LIVE_ANALYSIS_SMOKE_ATTEMPTS", str(DEFAULT_ATTEMPTS)))) + parser.add_argument("--sleep-seconds", type=float, default=float(env.get("READONLY_LIVE_ANALYSIS_SMOKE_SLEEP_SECONDS", str(DEFAULT_SLEEP_SECONDS)))) + parser.add_argument("--timeout-seconds", type=float, default=float(env.get("READONLY_LIVE_ANALYSIS_SMOKE_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT_SECONDS)))) + parser.add_argument("--output-json", action="store_true", default=_get_bool(env, "READONLY_LIVE_ANALYSIS_SMOKE_OUTPUT_JSON", False)) + parser.add_argument("--artifact-path", default=env.get("READONLY_LIVE_ANALYSIS_SMOKE_ARTIFACT_PATH", "")) + return parser.parse_args(argv) + + +def main(argv: Optional[list[str]] = None, env=None) -> int: + env = os.environ if env is None else env + args = _parse_args(argv, env) + payload = run_readonly_live_analysis_smoke( + base_url=args.base_url, + instrument_id=args.instrument_id, + timeframe=args.timeframe, + attempts=args.attempts, + sleep_seconds=args.sleep_seconds, + timeout_seconds=args.timeout_seconds, + ) + if args.artifact_path: + Path(args.artifact_path).write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + if args.output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"Read-only live analysis smoke status: {payload['status']}") + print(f"Base URL: {payload['base_url']}") + print(f"ready_for_readonly_live_analysis: {payload['ready_for_readonly_live_analysis']}") + print(f"must_not_trade: {payload['must_not_trade']}") + print(f"Blocked reason: {payload['blocked_reason'] or 'none'}") + print(f"Warnings: {', '.join(payload['warnings']) or 'none'}") + print(f"Passed checks: {', '.join(payload['passed_checks']) or 'none'}") + return 0 if payload["status"] == "passed" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_shadow_paper_observation_backfill.py b/scripts/run_shadow_paper_observation_backfill.py new file mode 100644 index 0000000..1bc2eb8 --- /dev/null +++ b/scripts/run_shadow_paper_observation_backfill.py @@ -0,0 +1,56 @@ +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.readiness import ShadowPaperObservationBackfillService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = _get_int(env, "SHADOW_PAPER_BACKFILL_INSTRUMENT_ID", 1) + timeframe = str(env.get("SHADOW_PAPER_BACKFILL_TIMEFRAME") or "1m") + window_days = _get_int(env, "SHADOW_PAPER_BACKFILL_WINDOW_DAYS", 14) + apply = _get_bool(env, "SHADOW_PAPER_BACKFILL_APPLY", False) + output_json = _get_bool(env, "SHADOW_PAPER_BACKFILL_OUTPUT_JSON", False) + created_by = str(env.get("SHADOW_PAPER_BACKFILL_CREATED_BY") or "shadow_paper_backfill_audit") + payload = ShadowPaperObservationBackfillService().build_backfill_audit( + instrument_id=instrument_id, + timeframe=timeframe, + window_days=window_days, + apply=apply, + created_by=created_by, + ) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) + else: + print(f"Shadow paper observation backfill: {payload.get('status')}") + print(f"Eligible dates: {', '.join(payload.get('eligible_backfill_dates') or []) or 'none'}") + print(f"Created observations: {payload.get('created_observation_count')}") + print(f"Projected shadow days: {payload.get('projected_observed_shadow_day_count')}") + print(f"Blocked backfill items: {', '.join(payload.get('blocked_backfill_items') or []) or 'none'}") + return 0 + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +def _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/src/api/__init__.py b/src/api/__init__.py index d05e954..efa5b3a 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -30,16 +30,19 @@ from src.api.trading_runtime import ( get_trading_market_state, get_trading_runtime_incidents, get_trading_live_readiness, + get_trading_okx_sandbox_preflight, get_trading_launch_readiness, get_trading_operator_notes, get_trading_plan_by_id, get_trading_review_packet, + get_trading_shadow_paper_validation, get_trading_runtime_status, get_trading_runtime_watchdog, get_trading_session_view, get_trading_ticket_by_id, get_trading_trust_score, get_weekly_review, + record_trading_shadow_paper_observation, rerun_ticket_risk_check, run_candidate_confirm_check, create_trading_operator_note, @@ -101,6 +104,7 @@ __all__ = [ "get_latest_trading_plan", "get_trading_market_state", "get_trading_live_readiness", + "get_trading_okx_sandbox_preflight", "get_trading_launch_readiness", "get_trading_plan_by_id", "get_trading_runtime_status", @@ -121,6 +125,8 @@ __all__ = [ "get_trading_session_view", "get_trading_operator_notes", "get_trading_review_packet", + "get_trading_shadow_paper_validation", + "record_trading_shadow_paper_observation", "run_trading_watch", "get_trading_candidate_by_id", "run_candidate_confirm_check", diff --git a/src/api/alerts.py b/src/api/alerts.py index 5a1a691..24be33a 100644 --- a/src/api/alerts.py +++ b/src/api/alerts.py @@ -6,6 +6,7 @@ import re from src.repositories.alert_delivery_repository import AlertDeliveryRepository from src.repositories.decision_assistant_repository import DecisionAssistantRepository from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository +from src.api.trading_runtime import create_trading_operator_note from src.services.alerts import ( AlertDispatcherService, AlertQualitySummaryService, @@ -18,6 +19,10 @@ from src.services.operator.archive_long_range_projection import ( build_workflow_archive_baseline_summary, build_workflow_archive_long_range_summary, ) +from src.services.operator.archive_retrieval_projection import ( + build_digest_archive_ranked_aggregation, + build_workflow_archive_ranked_aggregation, +) from src.services.operator.workflow_archive_projection import build_workflow_archive_operations_view @@ -409,6 +414,123 @@ def save_operator_archive_baselines( } +def get_operator_external_context_surface_history( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, +) -> dict: + preference = get_operator_digest_preferences(instrument_id=instrument_id, profile_key=profile_key) + payload = _normalize_preference_payload(preference.get("payload")) + return _build_external_context_surface_history_summary( + instrument_id=instrument_id, + profile_key=profile_key, + history=list(payload.get("external_context_surface_history") or []), + ) + + +def save_operator_external_context_surface_history( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + surface_id: str, + title: str | None = None, + source_group: str | None = None, + selection_kind: str = "target_url", + target_url: str | None = None, + prompt: str | None = None, +) -> dict: + repository = DecisionAssistantRepository() + existing = repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key) + effective = dict(existing or _default_preference_payload(instrument_id=instrument_id, profile_key=profile_key)) + payload = _normalize_preference_payload(effective.get("payload")) + history = [dict(item) for item in list(payload.get("external_context_surface_history") or []) if item] + selected_ts = datetime.now(timezone.utc).isoformat() + entry = { + "surface_id": str(surface_id or "external_surface"), + "title": str(title or surface_id or "external_surface"), + "source_group": str(source_group or "external_context"), + "selection_kind": str(selection_kind or "target_url"), + "target_url": str(target_url or ""), + "prompt": str(prompt or ""), + "selected_ts": selected_ts, + } + history.insert(0, entry) + payload["external_context_surface_history"] = history[:20] + saved = repository.upsert_preference( + instrument_id=instrument_id, + profile_key=profile_key, + digest_mode=effective.get("digest_mode") or "all_reminders", + preferred_history_kind=effective.get("preferred_history_kind"), + importance_threshold=int(effective.get("importance_threshold") or 70), + payload=payload, + ) + summary = _build_external_context_surface_history_summary( + instrument_id=instrument_id, + profile_key=profile_key, + history=list(payload.get("external_context_surface_history") or []), + ) + summary["status"] = "saved" + summary["updated_ts"] = saved.get("updated_ts") + summary["latest_selection"] = entry + return summary + + +def acknowledge_operator_external_context_surface_history( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + surface_id: str, + selected_ts: str | None = None, + acknowledged_by: str = "local_operator", + note: str | None = None, +) -> dict: + repository = DecisionAssistantRepository() + existing = repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key) + effective = dict(existing or _default_preference_payload(instrument_id=instrument_id, profile_key=profile_key)) + payload = _normalize_preference_payload(effective.get("payload")) + history = [dict(item) for item in list(payload.get("external_context_surface_history") or []) if item] + acknowledged_ts = datetime.now(timezone.utc).isoformat() + updated = None + for item in history: + if str(item.get("surface_id") or "") != str(surface_id or ""): + continue + if selected_ts and str(item.get("selected_ts") or "") != str(selected_ts): + continue + item["revisit_acknowledged"] = True + item["revisit_acknowledged_ts"] = acknowledged_ts + item["revisit_acknowledged_by"] = str(acknowledged_by or "local_operator") + item["revisit_ack_note"] = str(note or "") + updated = dict(item) + break + if updated is None: + return { + "status": "ignored", + "reason": "surface_history_item_missing", + "instrument_id": instrument_id, + "profile_key": profile_key, + "surface_id": surface_id, + "selected_ts": selected_ts, + } + payload["external_context_surface_history"] = history[:20] + saved = repository.upsert_preference( + instrument_id=instrument_id, + profile_key=profile_key, + digest_mode=effective.get("digest_mode") or "all_reminders", + preferred_history_kind=effective.get("preferred_history_kind"), + importance_threshold=int(effective.get("importance_threshold") or 70), + payload=payload, + ) + summary = _build_external_context_surface_history_summary( + instrument_id=instrument_id, + profile_key=profile_key, + history=list(payload.get("external_context_surface_history") or []), + ) + summary["status"] = "saved" + summary["updated_ts"] = saved.get("updated_ts") + summary["latest_acknowledgment"] = updated + return summary + + def get_operator_digest_history_archive( instrument_id: int, *, @@ -453,6 +575,7 @@ def get_operator_digest_history_archive( items, persisted_snapshot_id=baseline_state.get("digest_snapshot_id"), ) + ranked_aggregation = build_digest_archive_ranked_aggregation(items) profile_scope_summary = _build_archive_profile_scope_summary( items=items, profile_scope=normalized_scope, @@ -468,6 +591,7 @@ def get_operator_digest_history_archive( "available_query_presets": _digest_archive_available_query_presets(), "available_profile_scopes": ["selected_profile", "all_profiles"], "profile_scope_summary": profile_scope_summary, + "ranked_aggregation_summary": ranked_aggregation, "items": items, "item_count": len(items), "offset": offset, @@ -575,25 +699,45 @@ def export_operator_digest_history_archive( query_text=query_text, ) timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + export_scope = "selected_compare" if compare else "query_result_window" + generated_ts = datetime.now(timezone.utc).isoformat() + selection_provenance_summary = _build_archive_selection_provenance_summary( + archive=archive, + compare=compare, + export_scope=export_scope, + selection_kind="digest_archive", + ) + structured_export_summary = _build_digest_archive_structured_export_summary(archive, compare) return { "status": archive.get("status") or "idle", "export_kind": "digest_archive_bundle", - "generated_ts": datetime.now(timezone.utc).isoformat(), + "generated_ts": generated_ts, "export_name": f"digest_archive_{profile_key}_{timestamp}.json", - "export_schema_version": "decision_assistant_archive_export_v3", - "export_scope": "selected_compare" if compare else "query_result_window", + "export_schema_version": "decision_assistant_archive_export_v4", + "export_scope": export_scope, "instrument_id": instrument_id, "profile_key": profile_key, "profile_scope": archive.get("profile_scope"), "query_preset": archive.get("query_preset"), "profile_scope_summary": dict(archive.get("profile_scope_summary") or {}), + "ranked_aggregation_summary": dict(archive.get("ranked_aggregation_summary") or {}), + "selection_provenance_summary": selection_provenance_summary, "filters": dict(archive.get("filters") or {}), "item_count": archive.get("item_count") or 0, "items": list(archive.get("items") or []), "long_range_trend_summary": dict(archive.get("long_range_trend_summary") or {}), "baseline_pin_summary": dict(archive.get("baseline_pin_summary") or {}), "archive_baseline_state": dict(archive.get("archive_baseline_state") or {}), - "structured_export_summary": _build_digest_archive_structured_export_summary(archive, compare), + "structured_export_summary": structured_export_summary, + "bundle_manifest": _build_archive_bundle_manifest( + archive=archive, + compare=compare, + generated_ts=generated_ts, + export_kind="digest_archive_bundle", + export_scope=export_scope, + selection_provenance_summary=selection_provenance_summary, + structured_export_summary=structured_export_summary, + ), "compare": compare, } @@ -646,6 +790,7 @@ def get_operator_workflow_archive( items, persisted_snapshot_id=baseline_state.get("workflow_snapshot_id"), ) + ranked_aggregation = build_workflow_archive_ranked_aggregation(items) profile_scope_summary = _build_archive_profile_scope_summary( items=items, profile_scope=normalized_scope, @@ -661,6 +806,7 @@ def get_operator_workflow_archive( "available_query_presets": _workflow_archive_available_query_presets(), "available_profile_scopes": ["selected_profile", "all_profiles"], "profile_scope_summary": profile_scope_summary, + "ranked_aggregation_summary": ranked_aggregation, "items": items, "item_count": len(items), "offset": offset, @@ -744,6 +890,8 @@ def export_operator_workflow_archive( created_before=None, left_id: int | None = None, right_id: int | None = None, + group_date: str | None = None, + session_group: str | None = None, ) -> dict: archive = get_operator_workflow_archive( instrument_id=instrument_id, @@ -773,18 +921,43 @@ def export_operator_workflow_archive( query_text=query_text, ) timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + group_export_summary = _build_workflow_archive_group_export_summary( + archive, + group_date=group_date, + session_group=session_group, + ) + export_scope = "selected_compare" if compare else ("workflow_group_bundle" if group_export_summary.get("status") == "active" else "query_result_window") + generated_ts = datetime.now(timezone.utc).isoformat() + selection_provenance_summary = _build_archive_selection_provenance_summary( + archive=archive, + compare=compare, + export_scope=export_scope, + selection_kind="workflow_archive", + group_date=group_date, + session_group=session_group, + ) + handoff_bundle_summary = _build_workflow_archive_handoff_bundle_summary(archive, compare) + handoff_packet_starter = _build_workflow_archive_handoff_packet_starter(archive, compare) + replay_note_bridge_summary = _build_workflow_archive_replay_note_bridge_summary(archive, compare) + artifact_bundle_summary = _build_workflow_archive_artifact_bundle_summary(archive) + structured_export_summary = _build_workflow_archive_structured_export_summary(archive, compare) return { "status": archive.get("status") or "idle", "export_kind": "workflow_archive_bundle", - "generated_ts": datetime.now(timezone.utc).isoformat(), + "generated_ts": generated_ts, "export_name": f"workflow_archive_{profile_key}_{timestamp}.json", - "export_schema_version": "decision_assistant_archive_export_v3", - "export_scope": "selected_compare" if compare else "query_result_window", + "export_schema_version": "decision_assistant_archive_export_v4", + "export_scope": export_scope, "instrument_id": instrument_id, "profile_key": profile_key, "profile_scope": archive.get("profile_scope"), "query_preset": archive.get("query_preset"), + "group_date": group_date, + "session_group": session_group, "profile_scope_summary": dict(archive.get("profile_scope_summary") or {}), + "ranked_aggregation_summary": dict(archive.get("ranked_aggregation_summary") or {}), + "group_export_summary": group_export_summary, + "selection_provenance_summary": selection_provenance_summary, "filters": dict(archive.get("filters") or {}), "item_count": archive.get("item_count") or 0, "items": list(archive.get("items") or []), @@ -792,15 +965,118 @@ def export_operator_workflow_archive( "baseline_pin_summary": dict(archive.get("baseline_pin_summary") or {}), "archive_baseline_state": dict(archive.get("archive_baseline_state") or {}), "bridge_summary": dict(archive.get("bridge_summary") or {}), - "handoff_bundle_summary": _build_workflow_archive_handoff_bundle_summary(archive, compare), - "handoff_packet_starter": _build_workflow_archive_handoff_packet_starter(archive, compare), - "replay_note_bridge_summary": _build_workflow_archive_replay_note_bridge_summary(archive, compare), - "artifact_bundle_summary": _build_workflow_archive_artifact_bundle_summary(archive), - "structured_export_summary": _build_workflow_archive_structured_export_summary(archive, compare), + "handoff_bundle_summary": handoff_bundle_summary, + "handoff_packet_starter": handoff_packet_starter, + "replay_note_bridge_summary": replay_note_bridge_summary, + "artifact_bundle_summary": artifact_bundle_summary, + "structured_export_summary": structured_export_summary, + "bundle_manifest": _build_archive_bundle_manifest( + archive=archive, + compare=compare, + generated_ts=generated_ts, + export_kind="workflow_archive_bundle", + export_scope=export_scope, + selection_provenance_summary=selection_provenance_summary, + structured_export_summary=structured_export_summary, + group_export_summary=group_export_summary, + handoff_bundle_summary=handoff_bundle_summary, + artifact_bundle_summary=artifact_bundle_summary, + ), "compare": compare, } +def save_operator_workflow_archive_compare_writeback( + instrument_id: int, + *, + profile_key: str = DecisionAssistantRepository.DEFAULT_PROFILE_KEY, + profile_scope: str = "selected_profile", + query_preset: str | None = None, + left_id: int | None = None, + right_id: int | None = None, + current_session: str | None = None, + next_phase: str | None = None, + query_text: str | None = None, + author: str = "local_operator", + note_scope: str = "workflow_compare", +) -> dict: + compare = compare_operator_workflow_archive( + instrument_id=instrument_id, + profile_key=profile_key, + profile_scope=profile_scope, + query_preset=query_preset, + left_id=left_id, + right_id=right_id, + current_session=current_session, + next_phase=next_phase, + query_text=query_text, + ) + if compare.get("status") != "active": + return { + "status": "ignored", + "reason": "compare_unavailable", + "instrument_id": instrument_id, + "profile_key": profile_key, + "compare": compare, + } + replay_note_bridge_summary = _build_workflow_archive_replay_note_bridge_summary({}, compare) + tags = _build_workflow_archive_compare_writeback_tags(compare, profile_key=profile_key) + note = create_trading_operator_note( + instrument_id=instrument_id, + note_scope=note_scope, + author=author, + body=replay_note_bridge_summary.get("note_body") or "", + session_code=(dict(compare.get("left") or {}).get("current_session") or current_session), + tags=tags, + ) + repository = DecisionAssistantRepository() + existing = repository.fetch_preference(instrument_id=instrument_id, profile_key=profile_key) + effective = dict(existing or _default_preference_payload(instrument_id=instrument_id, profile_key=profile_key)) + payload = _normalize_preference_payload(effective.get("payload")) + workflow_bridge_state = dict(payload.get("workflow_bridge_state") or {}) + query_context = { + "profile_key": profile_key, + "profile_scope": _normalize_archive_profile_scope(profile_scope), + "query_preset": query_preset or "", + "current_session": current_session or "", + "next_phase": next_phase or "", + "query_text": query_text or "", + "left_id": left_id, + "right_id": right_id, + } + compare_writeback_summary = _build_workflow_archive_compare_writeback_summary( + note=note, + replay_note_bridge_summary=replay_note_bridge_summary, + compare=compare, + query_context=query_context, + ) + workflow_bridge_state["replay_note_bridge_summary"] = replay_note_bridge_summary + workflow_bridge_state["compare_writeback_summary"] = compare_writeback_summary + workflow_bridge_state["query_context"] = query_context + workflow_bridge_state["updated_ts"] = datetime.now(timezone.utc).isoformat() + payload["workflow_bridge_state"] = workflow_bridge_state + saved = repository.upsert_preference( + instrument_id=instrument_id, + profile_key=profile_key, + digest_mode=effective.get("digest_mode") or "all_reminders", + preferred_history_kind=effective.get("preferred_history_kind"), + importance_threshold=int(effective.get("importance_threshold") or 70), + payload=payload, + ) + compare_writeback_summary["updated_ts"] = saved.get("updated_ts") + workflow_bridge_state["updated_ts"] = saved.get("updated_ts") + return { + "status": "saved", + "instrument_id": instrument_id, + "profile_key": profile_key, + "compare": compare, + "note": note, + "replay_note_bridge_summary": replay_note_bridge_summary, + "compare_writeback_summary": compare_writeback_summary, + "updated_ts": saved.get("updated_ts"), + } + + def get_alert_silent_window_status() -> dict: return AlertSilentWindowService().build_status() @@ -1368,6 +1644,45 @@ def _build_workflow_archive_replay_note_bridge_summary(archive: dict, compare: d } +def _build_workflow_archive_compare_writeback_tags(compare: dict, *, profile_key: str) -> list[str]: + left = dict(compare.get("left") or {}) + tags = [ + "workflow-compare", + f"profile:{profile_key}", + f"compare-left:{left.get('snapshot_id') or 'n/a'}", + f"compare-right:{dict(compare.get('right') or {}).get('snapshot_id') or 'n/a'}", + ] + if left.get("current_session"): + tags.append(f"session:{left.get('current_session')}") + if left.get("next_phase"): + tags.append(f"phase:{left.get('next_phase')}") + return [tag for tag in tags if tag] + + +def _build_workflow_archive_compare_writeback_summary( + *, + note: dict, + replay_note_bridge_summary: dict, + compare: dict, + query_context: dict, +) -> dict: + return { + "status": "persisted", + "note_id": note.get("id"), + "note_scope": note.get("note_scope"), + "note_title": replay_note_bridge_summary.get("note_title"), + "note_body": replay_note_bridge_summary.get("note_body"), + "author": note.get("author"), + "created_ts": note.get("created_ts"), + "compare_left_snapshot_id": replay_note_bridge_summary.get("compare_left_snapshot_id"), + "compare_right_snapshot_id": replay_note_bridge_summary.get("compare_right_snapshot_id"), + "changed_phase_count": replay_note_bridge_summary.get("changed_phase_count") or 0, + "summary": compare.get("summary"), + "query_context": dict(query_context or {}), + "tags": list(note.get("tags") or []), + } + + def _build_workflow_archive_artifact_bundle_summary(archive: dict) -> dict: daily_groups = list(archive.get("daily_groups") or []) session_groups = list(archive.get("session_groups") or []) @@ -1392,6 +1707,984 @@ def _build_workflow_archive_artifact_bundle_summary(archive: dict) -> dict: } +def _build_workflow_archive_group_export_summary( + archive: dict, + *, + group_date: str | None = None, + session_group: str | None = None, +) -> dict: + daily_groups = list(archive.get("daily_groups") or []) + session_groups = list(archive.get("session_groups") or []) + selected_day = next((dict(item) for item in daily_groups if str(item.get("group_date") or "") == str(group_date or "")), {}) + selected_session = next((dict(item) for item in session_groups if str(item.get("session_code") or "") == str(session_group or "")), {}) + if not selected_day and not selected_session: + return { + "status": "idle", + "group_date": group_date, + "session_group": session_group, + "selected_group_count": 0, + "summary": None, + "starter_prompt": None, + } + parts = [] + if selected_day: + parts.append(f"day {selected_day.get('group_date') or 'n/a'} ({selected_day.get('snapshot_count') or 0} snapshots)") + if selected_session: + parts.append(f"session {selected_session.get('session_code') or 'n/a'} ({selected_session.get('snapshot_count') or 0} snapshots)") + return { + "status": "active", + "group_date": group_date, + "session_group": session_group, + "selected_group_count": int(bool(selected_day)) + int(bool(selected_session)), + "selected_day_group": selected_day, + "selected_session_group": selected_session, + "summary": ", ".join(parts), + "starter_prompt": f"Export workflow archive group bundle for {', '.join(parts)}." if parts else None, + } + + +def _build_archive_selection_provenance_summary( + *, + archive: dict, + compare: dict | None, + export_scope: str, + selection_kind: str, + group_date: str | None = None, + session_group: str | None = None, +) -> dict: + filters = dict(archive.get("filters") or {}) + active_filters = {key: value for key, value in filters.items() if value not in (None, "", [], {})} + compare = dict(compare or {}) + compare_ids = [ + value + for value in [ + dict(compare.get("left") or {}).get("snapshot_id"), + dict(compare.get("right") or {}).get("snapshot_id"), + ] + if value is not None + ] + baseline_state = dict(archive.get("archive_baseline_state") or {}) + baseline_snapshot_id = baseline_state.get("digest_snapshot_id") + if baseline_snapshot_id is None: + baseline_snapshot_id = baseline_state.get("workflow_snapshot_id") + selection_parts = [selection_kind, export_scope] + if compare_ids: + selection_parts.append(f"compare:{'/'.join(str(item) for item in compare_ids)}") + if group_date: + selection_parts.append(f"day:{group_date}") + if session_group: + selection_parts.append(f"session:{session_group}") + fingerprint = "|".join( + [ + str(selection_kind), + str(export_scope), + str(archive.get("profile_scope") or ""), + str(archive.get("query_preset") or ""), + ",".join(f"{key}={active_filters[key]}" for key in sorted(active_filters)), + ",".join(str(item) for item in compare_ids), + str(group_date or ""), + str(session_group or ""), + str(baseline_snapshot_id or ""), + ] + ) + return { + "status": archive.get("status") or "idle", + "selection_kind": selection_kind, + "export_scope": export_scope, + "profile_scope": archive.get("profile_scope"), + "query_preset": archive.get("query_preset"), + "active_filters": active_filters, + "active_filter_count": len(active_filters), + "selected_snapshot_ids": compare_ids, + "baseline_snapshot_id": baseline_snapshot_id, + "group_date": group_date, + "session_group": session_group, + "item_count": archive.get("item_count") or 0, + "selection_label": " / ".join(selection_parts), + "selection_fingerprint": fingerprint, + } + + +def _build_archive_bundle_manifest( + *, + archive: dict, + compare: dict | None, + generated_ts: str, + export_kind: str, + export_scope: str, + selection_provenance_summary: dict, + structured_export_summary: dict, + group_export_summary: dict | None = None, + handoff_bundle_summary: dict | None = None, + artifact_bundle_summary: dict | None = None, +) -> dict: + included_sections = [ + "items", + "filters", + "selection_provenance_summary", + "structured_export_summary", + "long_range_trend_summary", + "baseline_pin_summary", + ] + compare_status = dict(compare or {}).get("status") or "idle" + if dict(compare or {}).get("status") == "active": + included_sections.append("compare") + if dict(group_export_summary or {}).get("status") == "active": + included_sections.append("group_export_summary") + if dict(handoff_bundle_summary or {}).get("status") == "active": + included_sections.append("handoff_bundle_summary") + if dict(artifact_bundle_summary or {}).get("status") == "active": + included_sections.append("artifact_bundle_summary") + effective_status = archive.get("status") or "idle" + if effective_status != "active" and (compare_status == "active" or (archive.get("item_count") or 0) > 0 or len(included_sections) > 0): + effective_status = "active" + return { + "status": effective_status, + "manifest_version": "decision_assistant_archive_bundle_manifest_v1", + "normalization_status": "normalized", + "export_kind": export_kind, + "export_scope": export_scope, + "export_schema_version": "decision_assistant_archive_export_v4", + "generated_ts": generated_ts, + "profile_scope": archive.get("profile_scope"), + "query_preset": archive.get("query_preset"), + "item_count": archive.get("item_count") or 0, + "active_filter_count": selection_provenance_summary.get("active_filter_count") or 0, + "selected_snapshot_ids": list(selection_provenance_summary.get("selected_snapshot_ids") or []), + "baseline_snapshot_id": selection_provenance_summary.get("baseline_snapshot_id"), + "included_sections": included_sections, + "included_section_count": len(included_sections), + "manifest_label": f"{export_kind}:{export_scope}", + "manifest_story": ( + f"Normalized bundle manifest for {export_kind} includes " + f"{len(included_sections)} section(s) and {archive.get('item_count') or 0} item(s)." + ), + } + + +def _build_external_context_surface_history_summary(*, instrument_id: int, profile_key: str, history: list[dict]) -> dict: + entries = [dict(item) for item in list(history or []) if item][:10] + if not entries: + return { + "status": "idle", + "instrument_id": instrument_id, + "profile_key": profile_key, + "entry_count": 0, + "latest_surface_id": None, + "latest_selection_kind": None, + "latest_target_url": None, + "source_group_counts": {}, + "selection_kind_counts": {}, + "surface_counts": {}, + "source_group_rankings": [], + "surface_rankings": [], + "dominant_surface_id": None, + "dominant_surface_title": None, + "dominant_surface_count": 0, + "dominant_source_group": None, + "latest_age_seconds": None, + "recency_status": "idle", + "revisit_needed": False, + "cadence_status": "idle", + "cadence_story": None, + "average_gap_seconds": None, + "revisit_queue_count": 0, + "revisit_queue": [], + "acknowledged_count": 0, + "latest_acknowledged_surface_id": None, + "latest_acknowledged_title": None, + "latest_acknowledged_ts": None, + "latest_acknowledged_by": None, + "resolution_history_count": 0, + "resolution_history": [], + "cleared_queue_status": "idle", + "cleared_queue_story": None, + "next_resolution_review_prompt": None, + "latest_acknowledged_note": None, + "resolution_filter_summary": { + "status": "idle", + "note_count": 0, + "note_gap_count": 0, + "group_counts": {}, + "acknowledged_by_counts": {}, + "filter_options": [], + "next_filter_prompt": None, + }, + "next_revisit_surface_id": None, + "next_revisit_surface_title": None, + "next_revisit_target_url": None, + "revisit_surface_id": None, + "revisit_surface_title": None, + "revisit_target_url": None, + "revisit_prompt": None, + "provenance_story": None, + "dominant_surface_story": None, + "items": [], + } + source_group_counts: dict[str, int] = {} + selection_kind_counts: dict[str, int] = {} + surface_counts: dict[str, int] = {} + for item in entries: + source_group = str(item.get("source_group") or "external_context") + selection_kind = str(item.get("selection_kind") or "target_url") + surface_key = str(item.get("title") or item.get("surface_id") or "external_surface") + source_group_counts[source_group] = int(source_group_counts.get(source_group) or 0) + 1 + selection_kind_counts[selection_kind] = int(selection_kind_counts.get(selection_kind) or 0) + 1 + surface_counts[surface_key] = int(surface_counts.get(surface_key) or 0) + 1 + enriched_entries = [_decorate_surface_history_item(item) for item in entries] + latest = dict(enriched_entries[0] or {}) + surface_rankings = _rank_surface_history_counts(surface_counts) + source_group_rankings = _rank_surface_history_counts(source_group_counts) + dominant_surface = dict(surface_rankings[0] or {}) if surface_rankings else {} + dominant_source_group = dict(source_group_rankings[0] or {}) if source_group_rankings else {} + latest_age_seconds = _surface_history_age_seconds(latest.get("selected_ts")) + recency_status, revisit_needed = _surface_history_recency_status(latest_age_seconds) + average_gap_seconds = _surface_history_average_gap_seconds(enriched_entries) + cadence_status = _surface_history_cadence_status(average_gap_seconds=average_gap_seconds, entry_count=len(enriched_entries)) + revisit_queue = [dict(item) for item in enriched_entries if item.get("revisit_needed")] + next_revisit = dict(revisit_queue[0] or {}) if revisit_queue else {} + acknowledged = [dict(item) for item in enriched_entries if item.get("revisit_acknowledged")] + latest_acknowledged = dict(sorted( + acknowledged, + key=lambda item: str(item.get("revisit_acknowledged_ts") or ""), + reverse=True, + )[0] or {}) if acknowledged else {} + resolution_filter_summary = _surface_history_resolution_filter_summary(acknowledged) + return { + "status": "active", + "instrument_id": instrument_id, + "profile_key": profile_key, + "entry_count": len(entries), + "latest_surface_id": latest.get("surface_id"), + "latest_title": latest.get("title"), + "latest_selection_kind": latest.get("selection_kind"), + "latest_target_url": latest.get("target_url"), + "latest_selected_ts": latest.get("selected_ts"), + "source_group_counts": source_group_counts, + "selection_kind_counts": selection_kind_counts, + "surface_counts": surface_counts, + "source_group_rankings": source_group_rankings, + "surface_rankings": surface_rankings, + "dominant_surface_id": dominant_surface.get("value"), + "dominant_surface_title": dominant_surface.get("value"), + "dominant_surface_count": dominant_surface.get("count") or 0, + "dominant_source_group": dominant_source_group.get("value"), + "latest_age_seconds": latest_age_seconds, + "recency_status": recency_status, + "revisit_needed": revisit_needed, + "cadence_status": cadence_status, + "cadence_story": ( + f"Surface history cadence is {cadence_status}; average selection gap={_format_duration_seconds(average_gap_seconds)}." + if average_gap_seconds is not None + else "Surface history cadence needs at least two selections." + ), + "average_gap_seconds": average_gap_seconds, + "revisit_queue_count": len(revisit_queue), + "revisit_queue": revisit_queue[:5], + "acknowledged_count": len(acknowledged), + "latest_acknowledged_surface_id": latest_acknowledged.get("surface_id"), + "latest_acknowledged_title": latest_acknowledged.get("title"), + "latest_acknowledged_ts": latest_acknowledged.get("revisit_acknowledged_ts"), + "latest_acknowledged_by": latest_acknowledged.get("revisit_acknowledged_by"), + "latest_acknowledged_note": latest_acknowledged.get("revisit_ack_note"), + "resolution_history_count": len(acknowledged), + "resolution_history": acknowledged[:5], + "cleared_queue_status": "active" if acknowledged else "idle", + "cleared_queue_story": ( + f"Cleared revisit queue entries={len(acknowledged)}; latest={latest_acknowledged.get('title') or latest_acknowledged.get('surface_id') or 'n/a'}." + if acknowledged + else "No revisit queue entries have been acknowledged yet." + ), + "next_resolution_review_prompt": ( + f"Review cleared revisit history starting from {latest_acknowledged.get('title') or latest_acknowledged.get('surface_id') or 'n/a'}." + if acknowledged + else None + ), + "resolution_filter_summary": resolution_filter_summary, + "next_revisit_surface_id": next_revisit.get("surface_id"), + "next_revisit_surface_title": next_revisit.get("title"), + "next_revisit_target_url": next_revisit.get("target_url"), + "revisit_surface_id": latest.get("surface_id"), + "revisit_surface_title": latest.get("title"), + "revisit_target_url": latest.get("target_url"), + "provenance_story": ( + f"Surface selections={len(entries)}; latest={latest.get('title') or latest.get('surface_id')}; " + f"kinds={selection_kind_counts}; groups={source_group_counts}." + ), + "dominant_surface_story": ( + f"Dominant surface={dominant_surface.get('value') or 'n/a'} ({dominant_surface.get('count') or 0}); " + f"dominant group={dominant_source_group.get('value') or 'n/a'}." + ), + "revisit_prompt": ( + f"Revisit {latest.get('title') or latest.get('surface_id') or 'external surface'} because the latest selection is {recency_status}." + if latest.get("target_url") + else None + ), + "next_review_prompt": f"Review external surface selection history before rotating the next visual evidence source.", + "next_revisit_prompt": ( + f"Revisit queue starts with {next_revisit.get('title') or next_revisit.get('surface_id') or 'n/a'} ({next_revisit.get('recency_status') or 'n/a'})." + if next_revisit + else None + ), + "items": enriched_entries, + } + + +def _rank_surface_history_counts(counts: dict[str, int]) -> list[dict]: + return [ + {"value": key, "count": value} + for key, value in sorted(counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0] or ""))) + ] + + +def _surface_history_age_seconds(value) -> float | None: + try: + ts = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + return max((datetime.now(timezone.utc) - ts).total_seconds(), 0.0) + + +def _surface_history_recency_status(latest_age_seconds: float | None) -> tuple[str, bool]: + if latest_age_seconds is None: + return "unknown", False + if latest_age_seconds <= 3600: + return "recent", False + if latest_age_seconds <= 6 * 3600: + return "aging", True + return "stale_revisit", True + + +def _decorate_surface_history_item(item: dict) -> dict: + payload = dict(item or {}) + age_seconds = _surface_history_age_seconds(payload.get("selected_ts")) + recency_status, revisit_needed = _surface_history_recency_status(age_seconds) + payload["age_seconds"] = age_seconds + payload["recency_status"] = recency_status + payload["revisit_acknowledged"] = bool(payload.get("revisit_acknowledged")) + payload["revisit_needed"] = bool(revisit_needed and not payload["revisit_acknowledged"]) + return payload + + +def _surface_history_average_gap_seconds(items: list[dict]) -> float | None: + if len(items) < 2: + return None + ordered = [] + for item in items: + try: + ts = datetime.fromisoformat(str(item.get("selected_ts")).replace("Z", "+00:00")) + except Exception: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + ordered.append(ts) + if len(ordered) < 2: + return None + gaps = [] + for idx in range(len(ordered) - 1): + gaps.append(max((ordered[idx] - ordered[idx + 1]).total_seconds(), 0.0)) + return sum(gaps) / len(gaps) if gaps else None + + +def _surface_history_cadence_status(*, average_gap_seconds: float | None, entry_count: int) -> str: + if entry_count < 2 or average_gap_seconds is None: + return "single_sample" + if average_gap_seconds <= 1800: + return "dense" + if average_gap_seconds <= 6 * 3600: + return "active" + return "sporadic" + + +def _format_duration_seconds(value: float | None) -> str: + if value is None: + return "n/a" + seconds = int(round(value)) + if seconds < 3600: + return f"{max(seconds // 60, 0)}m" + return f"{seconds // 3600}h" + + +def _surface_history_resolution_filter_summary(items: list[dict]) -> dict: + if not items: + return { + "status": "idle", + "note_count": 0, + "note_gap_count": 0, + "note_gap_queue_count": 0, + "note_gap_queue": [], + "note_gap_oldest_age_seconds": None, + "note_gap_oldest_title": None, + "note_gap_average_age_seconds": None, + "group_counts": {}, + "acknowledged_by_counts": {}, + "owner_spotlight": None, + "owner_cadence_owner": None, + "owner_cadence_status": "idle", + "owner_cadence_average_gap_seconds": None, + "owner_cadence_story": None, + "owner_trend_status": "idle", + "owner_trend_story": None, + "owner_handoff_status": "idle", + "owner_handoff_story": None, + "filter_options": [], + "next_filter_prompt": None, + "next_note_gap_prompt": None, + "owner_spotlight_prompt": None, + "note_gap_priority_lane_status": "idle", + "note_gap_priority_lane_count": 0, + "note_gap_priority_lane_items": [], + "note_gap_priority_lane_story": None, + "note_gap_priority_lane_carry_forward_status": "idle", + "note_gap_priority_lane_carry_forward_story": None, + "note_gap_priority_lane_carry_forward_age_seconds": None, + "note_gap_priority_lane_carry_forward_aging_status": "idle", + "note_gap_priority_lane_carry_forward_aging_story": None, + "next_priority_lane_prompt": None, + "owner_handoff_follow_up_status": "idle", + "owner_handoff_follow_up_story": None, + "resolved_owner_drift_status": "idle", + "resolved_owner_drift_story": None, + "carry_forward_follow_up_lane_status": "idle", + "carry_forward_follow_up_lane_count": 0, + "carry_forward_follow_up_lane_items": [], + "carry_forward_follow_up_lane_story": None, + "next_follow_up_lane_prompt": None, + "resolved_owner_drift_review_status": "idle", + "resolved_owner_drift_review_story": None, + "resolved_owner_drift_review_queue_status": "idle", + "resolved_owner_drift_review_queue_count": 0, + "resolved_owner_drift_review_queue_items": [], + "resolved_owner_drift_review_queue_story": None, + "resolved_owner_drift_review_queue_aging_status": "idle", + "resolved_owner_drift_review_queue_aging_story": None, + "carry_forward_follow_up_lane_rollover_status": "idle", + "carry_forward_follow_up_lane_rollover_story": None, + "carry_forward_follow_up_lane_rollover_handoff_status": "idle", + "carry_forward_follow_up_lane_rollover_handoff_story": None, + "next_follow_up_lane_rollover_prompt": None, + "next_rollover_handoff_prompt": None, + } + note_count = sum(1 for item in items if str(item.get("revisit_ack_note") or "").strip()) + note_gap_count = max(len(items) - note_count, 0) + note_gap_queue = [dict(item) for item in items if not str(item.get("revisit_ack_note") or "").strip()] + note_gap_ages = [float(item.get("age_seconds") or 0) for item in note_gap_queue if item.get("age_seconds") is not None] + oldest_note_gap = dict(sorted( + note_gap_queue, + key=lambda item: float(item.get("age_seconds") or 0), + reverse=True, + )[0] or {}) if note_gap_queue else {} + group_counts: dict[str, int] = {} + acknowledged_by_counts: dict[str, int] = {} + for item in items: + group = str(item.get("source_group") or "external_context") + who = str(item.get("revisit_acknowledged_by") or "unknown") + group_counts[group] = int(group_counts.get(group) or 0) + 1 + acknowledged_by_counts[who] = int(acknowledged_by_counts.get(who) or 0) + 1 + filter_options = [ + {"filter_id": "all", "label": "All Resolved", "count": len(items)}, + {"filter_id": "with_note", "label": "With Resolution Note", "count": note_count}, + {"filter_id": "without_note", "label": "Without Resolution Note", "count": note_gap_count}, + ] + for group, count in sorted(group_counts.items()): + filter_options.append({"filter_id": f"group:{group}", "label": f"{group} ({count})", "count": count}) + for who, count in sorted(acknowledged_by_counts.items()): + filter_options.append({"filter_id": f"ack:{who}", "label": f"{who} ({count})", "count": count}) + owner_spotlight = sorted( + acknowledged_by_counts.items(), + key=lambda item: (-int(item[1] or 0), str(item[0] or "")), + )[0] if acknowledged_by_counts else None + owner_cadence = _surface_history_owner_cadence(items, owner_spotlight[0] if owner_spotlight is not None else None) + priority_lane = _surface_history_note_gap_priority_lane(note_gap_queue) + owner_trend = _surface_history_owner_trend(acknowledged_by_counts, owner_spotlight, len(items)) + owner_handoff = _surface_history_owner_handoff(owner_spotlight, acknowledged_by_counts, len(items)) + priority_lane_carry_forward = _surface_history_priority_lane_carry_forward(priority_lane) + carry_forward_aging = _surface_history_priority_lane_carry_forward_aging(priority_lane) + owner_handoff_follow_up = _surface_history_owner_handoff_follow_up(owner_handoff, priority_lane) + resolved_owner_drift = _surface_history_resolved_owner_drift(items, acknowledged_by_counts, owner_spotlight) + carry_forward_follow_up_lane = _surface_history_carry_forward_follow_up_lane(priority_lane, owner_handoff_follow_up) + resolved_owner_drift_review = _surface_history_resolved_owner_drift_review( + resolved_owner_drift, + owner_handoff_follow_up, + carry_forward_follow_up_lane, + ) + resolved_owner_drift_review_queue = _surface_history_resolved_owner_drift_review_queue( + resolved_owner_drift_review, + carry_forward_follow_up_lane, + ) + resolved_owner_drift_review_queue_aging = _surface_history_resolved_owner_drift_review_queue_aging( + resolved_owner_drift_review_queue, + ) + carry_forward_follow_up_lane_rollover = _surface_history_carry_forward_follow_up_lane_rollover( + carry_forward_follow_up_lane, + ) + carry_forward_follow_up_lane_rollover_handoff = _surface_history_carry_forward_follow_up_lane_rollover_handoff( + carry_forward_follow_up_lane_rollover, + resolved_owner_drift_review_queue, + ) + return { + "status": "active", + "note_count": note_count, + "note_gap_count": note_gap_count, + "note_gap_queue_count": len(note_gap_queue), + "note_gap_queue": note_gap_queue[:5], + "note_gap_oldest_age_seconds": float(oldest_note_gap.get("age_seconds") or 0) if oldest_note_gap else None, + "note_gap_oldest_title": oldest_note_gap.get("title") or oldest_note_gap.get("surface_id"), + "note_gap_average_age_seconds": (sum(note_gap_ages) / len(note_gap_ages)) if note_gap_ages else None, + "group_counts": group_counts, + "acknowledged_by_counts": acknowledged_by_counts, + "owner_spotlight": { + "owner": owner_spotlight[0], + "count": owner_spotlight[1], + } if owner_spotlight is not None else None, + "owner_cadence_owner": owner_spotlight[0] if owner_spotlight is not None else None, + "owner_cadence_status": owner_cadence.get("status") or "idle", + "owner_cadence_average_gap_seconds": owner_cadence.get("average_gap_seconds"), + "owner_cadence_story": owner_cadence.get("story"), + "owner_trend_status": owner_trend.get("status") or "idle", + "owner_trend_story": owner_trend.get("story"), + "owner_handoff_status": owner_handoff.get("status") or "idle", + "owner_handoff_story": owner_handoff.get("story"), + "filter_options": filter_options, + "next_filter_prompt": "Filter cleared revisit history by note coverage or source group before pruning history.", + "next_note_gap_prompt": ( + f"Add a resolution note for {note_gap_queue[0].get('title') or note_gap_queue[0].get('surface_id') or 'n/a'}." + if note_gap_queue + else None + ), + "owner_spotlight_prompt": ( + f"Review {owner_spotlight[0]}'s cleared revisit items first." + if owner_spotlight is not None + else None + ), + "note_gap_priority_lane_status": priority_lane.get("status") or "idle", + "note_gap_priority_lane_count": priority_lane.get("count") or 0, + "note_gap_priority_lane_items": priority_lane.get("items") or [], + "note_gap_priority_lane_story": priority_lane.get("story"), + "note_gap_priority_lane_carry_forward_status": priority_lane_carry_forward.get("status") or "idle", + "note_gap_priority_lane_carry_forward_story": priority_lane_carry_forward.get("story"), + "note_gap_priority_lane_carry_forward_age_seconds": carry_forward_aging.get("age_seconds"), + "note_gap_priority_lane_carry_forward_aging_status": carry_forward_aging.get("status") or "idle", + "note_gap_priority_lane_carry_forward_aging_story": carry_forward_aging.get("story"), + "next_priority_lane_prompt": priority_lane.get("next_prompt"), + "owner_handoff_follow_up_status": owner_handoff_follow_up.get("status") or "idle", + "owner_handoff_follow_up_story": owner_handoff_follow_up.get("story"), + "resolved_owner_drift_status": resolved_owner_drift.get("status") or "idle", + "resolved_owner_drift_story": resolved_owner_drift.get("story"), + "carry_forward_follow_up_lane_status": carry_forward_follow_up_lane.get("status") or "idle", + "carry_forward_follow_up_lane_count": carry_forward_follow_up_lane.get("count") or 0, + "carry_forward_follow_up_lane_items": carry_forward_follow_up_lane.get("items") or [], + "carry_forward_follow_up_lane_story": carry_forward_follow_up_lane.get("story"), + "next_follow_up_lane_prompt": carry_forward_follow_up_lane.get("next_prompt"), + "resolved_owner_drift_review_status": resolved_owner_drift_review.get("status") or "idle", + "resolved_owner_drift_review_story": resolved_owner_drift_review.get("story"), + "resolved_owner_drift_review_queue_status": resolved_owner_drift_review_queue.get("status") or "idle", + "resolved_owner_drift_review_queue_count": resolved_owner_drift_review_queue.get("count") or 0, + "resolved_owner_drift_review_queue_items": resolved_owner_drift_review_queue.get("items") or [], + "resolved_owner_drift_review_queue_story": resolved_owner_drift_review_queue.get("story"), + "resolved_owner_drift_review_queue_aging_status": resolved_owner_drift_review_queue_aging.get("status") or "idle", + "resolved_owner_drift_review_queue_aging_story": resolved_owner_drift_review_queue_aging.get("story"), + "carry_forward_follow_up_lane_rollover_status": carry_forward_follow_up_lane_rollover.get("status") or "idle", + "carry_forward_follow_up_lane_rollover_story": carry_forward_follow_up_lane_rollover.get("story"), + "carry_forward_follow_up_lane_rollover_handoff_status": carry_forward_follow_up_lane_rollover_handoff.get("status") or "idle", + "carry_forward_follow_up_lane_rollover_handoff_story": carry_forward_follow_up_lane_rollover_handoff.get("story"), + "next_follow_up_lane_rollover_prompt": carry_forward_follow_up_lane_rollover.get("next_prompt"), + "next_rollover_handoff_prompt": carry_forward_follow_up_lane_rollover_handoff.get("next_prompt"), + } + + +def _surface_history_owner_cadence(items: list[dict], owner: str | None) -> dict: + if not owner: + return {"status": "idle", "average_gap_seconds": None, "story": None} + owner_items = [dict(item) for item in items if str(item.get("revisit_acknowledged_by") or "") == str(owner)] + if len(owner_items) < 2: + return { + "status": "single_sample", + "average_gap_seconds": None, + "story": f"{owner} has fewer than two acknowledged revisit items, so cadence is still a single-sample view.", + } + timestamps = [] + for item in owner_items: + try: + ts = datetime.fromisoformat(str(item.get("revisit_acknowledged_ts")).replace("Z", "+00:00")) + except Exception: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + timestamps.append(ts) + timestamps.sort(reverse=True) + if len(timestamps) < 2: + return { + "status": "single_sample", + "average_gap_seconds": None, + "story": f"{owner} has insufficient timestamp history for cadence review.", + } + gaps = [max((timestamps[idx] - timestamps[idx + 1]).total_seconds(), 0.0) for idx in range(len(timestamps) - 1)] + avg_gap = sum(gaps) / len(gaps) if gaps else None + if avg_gap is None: + status = "single_sample" + elif avg_gap <= 1800: + status = "dense" + elif avg_gap <= 6 * 3600: + status = "active" + else: + status = "sporadic" + return { + "status": status, + "average_gap_seconds": avg_gap, + "story": f"{owner} revisit cadence is {status}; average acknowledgment gap={_format_duration_seconds(avg_gap)}.", + } + + +def _surface_history_note_gap_priority_lane(note_gap_queue: list[dict]) -> dict: + if not note_gap_queue: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Priority lane is clear; there are no unresolved note gaps.", + "next_prompt": None, + } + ordered = sorted( + [dict(item) for item in note_gap_queue], + key=lambda item: (-float(item.get("age_seconds") or 0), str(item.get("title") or item.get("surface_id") or "")), + ) + top = dict(ordered[0] or {}) + return { + "status": "active", + "count": len(ordered), + "items": ordered[:5], + "story": ( + f"Priority lane has {len(ordered)} unresolved note gap(s); " + f"oldest={top.get('title') or top.get('surface_id') or 'n/a'} " + f"({_format_duration_seconds(top.get('age_seconds'))} old)." + ), + "next_prompt": ( + f"Work the priority lane starting with {top.get('title') or top.get('surface_id') or 'n/a'} " + f"({_format_duration_seconds(top.get('age_seconds'))} old)." + ), + } + + +def _surface_history_owner_trend(acknowledged_by_counts: dict[str, int], owner_spotlight, total_count: int) -> dict: + if not acknowledged_by_counts or total_count <= 0 or owner_spotlight is None: + return {"status": "idle", "story": None} + top_owner, top_count = owner_spotlight + owner_count = len(acknowledged_by_counts) + if owner_count == 1 and total_count == 1: + status = "single_sample" + elif owner_count == 1: + status = "stable_owner" + elif top_count / max(total_count, 1) >= 0.6: + status = "owner_dominant" + else: + status = "shared_owner" + return { + "status": status, + "story": ( + f"Owner trend={status}; {top_owner} accounts for {top_count}/{total_count} cleared revisit item(s) " + f"across {owner_count} owner(s)." + ), + } + + +def _surface_history_owner_handoff(owner_spotlight, acknowledged_by_counts: dict[str, int], total_count: int) -> dict: + if owner_spotlight is None or total_count <= 0: + return {"status": "idle", "story": None} + owner, count = owner_spotlight + status = "owner_handoff_ready" if count / max(total_count, 1) >= 0.5 else "shared_handoff" + return { + "status": status, + "story": ( + f"Handoff owner={owner}; coverage {count}/{total_count} cleared revisit item(s) " + f"across {len(acknowledged_by_counts)} owner(s)." + ), + } + + +def _surface_history_priority_lane_carry_forward(priority_lane: dict) -> dict: + status = str(priority_lane.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if status != "active" or not items: + return { + "status": "idle", + "story": "Priority lane is clear; nothing needs carry-forward.", + } + top = dict(items[0] or {}) + return { + "status": "carry_forward_ready", + "story": ( + f"Carry forward {top.get('title') or top.get('surface_id') or 'n/a'} as the next unresolved note-gap lane " + f"({_format_duration_seconds(top.get('age_seconds'))} old)." + ), + } + + +def _surface_history_priority_lane_carry_forward_aging(priority_lane: dict) -> dict: + status = str(priority_lane.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if status != "active" or not items: + return {"status": "idle", "age_seconds": None, "story": "Carry-forward aging is idle because the priority lane is clear."} + top = dict(items[0] or {}) + age_seconds = float(top.get("age_seconds") or 0) + if age_seconds <= 3600: + aging_status = "recent" + elif age_seconds <= 6 * 3600: + aging_status = "aging" + else: + aging_status = "stale" + return { + "status": aging_status, + "age_seconds": age_seconds, + "story": ( + f"Carry-forward aging is {aging_status}; {top.get('title') or top.get('surface_id') or 'n/a'} " + f"has been in the priority lane for {_format_duration_seconds(age_seconds)}." + ), + } + + +def _surface_history_owner_handoff_follow_up(owner_handoff: dict, priority_lane: dict) -> dict: + handoff_status = str(owner_handoff.get("status") or "idle") + lane_status = str(priority_lane.get("status") or "idle") + if handoff_status == "idle": + return {"status": "idle", "story": "Owner handoff follow-up is idle because no owner handoff is active."} + if lane_status == "active": + return { + "status": "follow_up_required", + "story": "Owner handoff should include the current priority-lane note gap before the next pass.", + } + return { + "status": "handoff_clear", + "story": "Owner handoff is clear; there is no active priority-lane note gap to carry forward.", + } + + +def _surface_history_resolved_owner_drift( + items: list[dict], + acknowledged_by_counts: dict[str, int], + owner_spotlight, +) -> dict: + if not items or owner_spotlight is None: + return {"status": "idle", "story": None} + ordered = sorted( + [dict(item) for item in items], + key=lambda item: str(item.get("revisit_acknowledged_ts") or ""), + reverse=True, + ) + if len(ordered) < 2: + return { + "status": "single_sample", + "story": "Resolved-owner drift needs at least two acknowledged revisit items.", + } + baseline_owner, baseline_count = owner_spotlight + recent_window = ordered[: min(3, len(ordered))] + latest_owner = str(recent_window[0].get("revisit_acknowledged_by") or "unknown") + recent_counts: dict[str, int] = {} + for item in recent_window: + owner = str(item.get("revisit_acknowledged_by") or "unknown") + recent_counts[owner] = int(recent_counts.get(owner) or 0) + 1 + recent_spotlight = sorted( + recent_counts.items(), + key=lambda item: (-int(item[1] or 0), str(item[0] or "")), + )[0] + if latest_owner != baseline_owner: + status = "drift_detected" + elif len(recent_counts) > 1: + status = "mixed_recent" + else: + status = "stable" + return { + "status": status, + "story": ( + f"Resolved-owner drift={status}; recent owner={recent_spotlight[0]} " + f"({recent_spotlight[1]}/{len(recent_window)}), latest owner={latest_owner}, baseline={baseline_owner} " + f"({baseline_count}/{len(items)})." + ), + } + + +def _surface_history_carry_forward_follow_up_lane(priority_lane: dict, owner_handoff_follow_up: dict) -> dict: + follow_up_status = str(owner_handoff_follow_up.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if follow_up_status != "follow_up_required" or not items: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Carry-forward follow-up lane is clear.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + return { + "status": "active", + "count": len(items), + "items": items[:3], + "story": ( + f"Carry-forward follow-up lane keeps {len(items)} unresolved note-gap surface(s) visible; " + f"start with {lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + "next_prompt": ( + f"Follow up on carry-forward lane starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + +def _surface_history_resolved_owner_drift_review( + resolved_owner_drift: dict, + owner_handoff_follow_up: dict, + carry_forward_follow_up_lane: dict, +) -> dict: + drift_status = str(resolved_owner_drift.get("status") or "idle") + follow_up_status = str(owner_handoff_follow_up.get("status") or "idle") + lane_status = str(carry_forward_follow_up_lane.get("status") or "idle") + if drift_status in {"idle", "single_sample"}: + return { + "status": drift_status, + "story": "Resolved-owner drift review is idle until more acknowledged history accumulates.", + } + if drift_status == "drift_detected" and lane_status == "active": + return { + "status": "review_required", + "story": "Resolved-owner drift should be reviewed together with the active carry-forward follow-up lane.", + } + if drift_status == "drift_detected": + return { + "status": "review_required", + "story": "Resolved-owner drift changed recently; review the new owner mix before the next pass.", + } + if drift_status == "mixed_recent" or follow_up_status == "follow_up_required": + return { + "status": "review_recommended", + "story": "Resolved-owner drift review is recommended because owner coverage is mixed across recent cleared items.", + } + return { + "status": "review_clear", + "story": "Resolved-owner drift review is clear; recent owner coverage still matches the baseline pattern.", + } + + +def _surface_history_resolved_owner_drift_review_queue( + resolved_owner_drift_review: dict, + carry_forward_follow_up_lane: dict, +) -> dict: + review_status = str(resolved_owner_drift_review.get("status") or "idle") + items = list(carry_forward_follow_up_lane.get("items") or []) + if review_status not in {"review_required", "review_recommended"} or not items: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Resolved-owner drift review queue is clear.", + } + lead = dict(items[0] or {}) + return { + "status": "active", + "count": len(items), + "items": items[:3], + "story": ( + f"Resolved-owner drift review queue has {len(items)} surface(s); " + f"start with {lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + +def _surface_history_resolved_owner_drift_review_queue_aging(resolved_owner_drift_review_queue: dict) -> dict: + status = str(resolved_owner_drift_review_queue.get("status") or "idle") + items = list(resolved_owner_drift_review_queue.get("items") or []) + if status != "active" or not items: + return { + "status": "idle", + "story": "Drift review queue aging is idle because the review queue is clear.", + } + lead = dict(items[0] or {}) + age_seconds = float(lead.get("age_seconds") or 0) + if age_seconds <= 3600: + aging_status = "recent" + elif age_seconds <= 6 * 3600: + aging_status = "aging" + else: + aging_status = "stale" + return { + "status": aging_status, + "story": ( + f"Drift review queue aging is {aging_status}; " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} has waited {_format_duration_seconds(age_seconds)}." + ), + } + + +def _surface_history_carry_forward_follow_up_lane_rollover(carry_forward_follow_up_lane: dict) -> dict: + lane_status = str(carry_forward_follow_up_lane.get("status") or "idle") + items = list(carry_forward_follow_up_lane.get("items") or []) + if lane_status != "active" or not items: + return { + "status": "idle", + "story": "Follow-up lane rollover is idle because there is no active carry-forward lane.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + age_seconds = float(lead.get("age_seconds") or 0) + if age_seconds <= 3600: + status = "same_pass" + elif age_seconds <= 6 * 3600: + status = "rollover_watch" + else: + status = "rollover_required" + return { + "status": status, + "story": ( + f"Follow-up lane rollover is {status}; " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} has carried for {_format_duration_seconds(age_seconds)}." + ), + "next_prompt": ( + f"Review follow-up lane rollover starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} ({_format_duration_seconds(age_seconds)} carried)." + ), + } + + +def _surface_history_carry_forward_follow_up_lane_rollover_handoff( + carry_forward_follow_up_lane_rollover: dict, + resolved_owner_drift_review_queue: dict, +) -> dict: + rollover_status = str(carry_forward_follow_up_lane_rollover.get("status") or "idle") + queue_status = str(resolved_owner_drift_review_queue.get("status") or "idle") + items = list(resolved_owner_drift_review_queue.get("items") or []) + if rollover_status == "idle" or queue_status != "active" or not items: + return { + "status": "idle", + "story": "Rollover handoff is idle because there is no active review queue to hand off.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + if rollover_status == "rollover_required": + status = "handoff_required" + elif rollover_status == "rollover_watch": + status = "handoff_watch" + else: + status = "same_pass" + return { + "status": status, + "story": ( + f"Rollover handoff is {status}; " + f"carry {lead.get('title') or lead.get('surface_id') or 'n/a'} into the next review owner pass." + ), + "next_prompt": ( + f"Handoff rollover review starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + def _build_digest_archive_structured_export_summary(archive: dict, compare: dict | None) -> dict: items = list(archive.get("items") or []) profile_counts: dict[str, int] = {} @@ -1403,6 +2696,8 @@ def _build_digest_archive_structured_export_summary(archive: dict, compare: dict kind_counts[history_kind] = int(kind_counts.get(history_kind) or 0) + 1 return { "status": archive.get("status") or "idle", + "normalization_status": "normalized", + "manifest_version": "decision_assistant_archive_bundle_manifest_v1", "profile_scope": archive.get("profile_scope"), "query_preset": archive.get("query_preset"), "profile_count": len(profile_counts), @@ -1428,12 +2723,16 @@ def _build_workflow_archive_structured_export_summary(archive: dict, compare: di phase_counts[phase_key] = int(phase_counts.get(phase_key) or 0) + 1 return { "status": archive.get("status") or "idle", + "normalization_status": "normalized", + "manifest_version": "decision_assistant_archive_bundle_manifest_v1", "profile_scope": archive.get("profile_scope"), "query_preset": archive.get("query_preset"), "profile_count": len(profile_counts), "profile_counts": profile_counts, "session_counts": session_counts, "phase_counts": phase_counts, + "day_group_count": len(list(archive.get("daily_groups") or [])), + "session_group_count": len(list(archive.get("session_groups") or [])), "compare_status": dict(compare or {}).get("status") or "idle", "latest_created_ts": (items[0] or {}).get("created_ts") if items else None, "oldest_created_ts": (items[-1] or {}).get("created_ts") if items else None, diff --git a/src/api/server.py b/src/api/server.py index a031f3d..72a767a 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -23,6 +23,7 @@ from src.api.alerts import ( get_operator_digest_history_archive, get_alert_quality_summary, get_alert_silent_window_status, + get_operator_external_context_surface_history, get_operator_digest, get_operator_archive_baselines, get_operator_digest_preferences, @@ -30,6 +31,9 @@ from src.api.alerts import ( get_operator_workflow_archive, list_operator_digest_preference_profiles, rename_operator_digest_preference_profile, + acknowledge_operator_external_context_surface_history, + save_operator_external_context_surface_history, + save_operator_workflow_archive_compare_writeback, save_operator_focus_state, save_operator_digest_preferences, save_operator_archive_baselines, @@ -78,9 +82,12 @@ from src.api.trading_runtime import ( get_trading_candidate_by_id, get_trading_market_state, get_trading_runtime_incidents, + get_trading_okx_sandbox_preflight, get_trading_operator_notes, get_trading_plan_by_id, get_trading_review_packet, + record_trading_shadow_paper_observation, + get_trading_shadow_paper_validation, get_trading_live_readiness, get_trading_launch_readiness, get_trading_runtime_status, @@ -148,12 +155,16 @@ def build_api_dependencies() -> dict: "trading_alert_operator_digest_preferences_save": save_operator_digest_preferences, "trading_archive_baselines": get_operator_archive_baselines, "trading_archive_baselines_save": save_operator_archive_baselines, + "trading_external_context_surface_history": get_operator_external_context_surface_history, + "trading_external_context_surface_history_save": save_operator_external_context_surface_history, + "trading_external_context_surface_history_ack": acknowledge_operator_external_context_surface_history, "trading_alert_operator_digest_history_archive": get_operator_digest_history_archive, "trading_alert_operator_digest_history_compare": compare_operator_digest_history_archive, "trading_alert_operator_digest_history_export": export_operator_digest_history_archive, "trading_workflow_archive": get_operator_workflow_archive, "trading_workflow_archive_compare": compare_operator_workflow_archive, "trading_workflow_archive_export": export_operator_workflow_archive, + "trading_workflow_archive_compare_writeback": save_operator_workflow_archive_compare_writeback, "trading_assistant_focus": get_operator_focus_state, "trading_assistant_focus_save": save_operator_focus_state, "trading_alert_silent_window_status": get_alert_silent_window_status, @@ -167,6 +178,7 @@ 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_shadow_paper_observation_record": record_trading_shadow_paper_observation, "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, @@ -176,7 +188,9 @@ def build_api_dependencies() -> dict: "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_okx_sandbox_preflight": get_trading_okx_sandbox_preflight, "trading_launch_readiness": get_trading_launch_readiness, + "trading_shadow_paper_validation": get_trading_shadow_paper_validation, "trading_watch_run": run_trading_watch, "trading_visual_watch_latest": get_latest_visual_watch_run, "trading_visual_candidate_snapshot": get_latest_candidate_snapshot, @@ -408,6 +422,34 @@ def route_request( digest_snapshot_id=_get_body_optional_int(body, "digest_snapshot_id"), workflow_snapshot_id=_get_body_optional_int(body, "workflow_snapshot_id"), ) + if method == "GET" and path == "/trading/external-context-surface-history": + instrument_id = _require_int(query, "instrument_id") + return 200, deps["trading_external_context_surface_history"]( + instrument_id=instrument_id, + profile_key=_get_single(query, "profile_key", "local_operator"), + ) + if method == "POST" and path == "/trading/external-context-surface-history": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_external_context_surface_history_save"]( + instrument_id=instrument_id, + profile_key=_get_body_value(body, "profile_key", "local_operator"), + surface_id=_get_body_value(body, "surface_id", "external_surface"), + title=_get_body_value(body, "title"), + source_group=_get_body_value(body, "source_group"), + selection_kind=_get_body_value(body, "selection_kind", "target_url"), + target_url=_get_body_value(body, "target_url"), + prompt=_get_body_value(body, "prompt"), + ) + if method == "POST" and path == "/trading/external-context-surface-history/acknowledge": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_external_context_surface_history_ack"]( + instrument_id=instrument_id, + profile_key=_get_body_value(body, "profile_key", "local_operator"), + surface_id=_get_body_value(body, "surface_id", ""), + selected_ts=_get_body_value(body, "selected_ts"), + acknowledged_by=_get_body_value(body, "acknowledged_by", "local_operator"), + note=_get_body_value(body, "note"), + ) if method == "GET" and path == "/trading/alerts/digest-history-archive": instrument_id = _require_int(query, "instrument_id") return 200, deps["trading_alert_operator_digest_history_archive"]( @@ -486,6 +528,8 @@ def route_request( created_before=_get_single(query, "created_before"), left_id=_get_optional_int(query, "left_id"), right_id=_get_optional_int(query, "right_id"), + group_date=_get_single(query, "group_date"), + session_group=_get_single(query, "session_group"), ) if method == "GET" and path == "/trading/workflow-archive/compare": instrument_id = _require_int(query, "instrument_id") @@ -500,6 +544,21 @@ def route_request( next_phase=_get_single(query, "next_phase"), query_text=_get_single(query, "query_text"), ) + if method == "POST" and path == "/trading/workflow-archive/compare-writeback": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_workflow_archive_compare_writeback"]( + instrument_id=instrument_id, + profile_key=_get_body_value(body, "profile_key", "local_operator"), + profile_scope=_get_body_value(body, "profile_scope", "selected_profile"), + query_preset=_get_body_value(body, "query_preset"), + left_id=_get_body_optional_int(body, "left_id"), + right_id=_get_body_optional_int(body, "right_id"), + current_session=_get_body_value(body, "current_session"), + next_phase=_get_body_value(body, "next_phase"), + query_text=_get_body_value(body, "query_text"), + author=_get_body_value(body, "author", "local_operator"), + note_scope=_get_body_value(body, "note_scope", "workflow_compare"), + ) if method == "GET" and path == "/trading/assistant-focus": instrument_id = _require_int(query, "instrument_id") return 200, deps["trading_assistant_focus"]( @@ -740,10 +799,31 @@ def route_request( instrument_id = _require_int(query, "instrument_id") timeframe = _get_single(query, "timeframe", "1m") return 200, deps["trading_live_readiness"](instrument_id=instrument_id, timeframe=timeframe) + if method == "GET" and path == "/trading/okx-sandbox-preflight": + instrument_id = _require_int(query, "instrument_id") + timeframe = _get_single(query, "timeframe", "1m") + return 200, deps["trading_okx_sandbox_preflight"](instrument_id=instrument_id, timeframe=timeframe) if method == "GET" and path == "/trading/launch-readiness": instrument_id = _require_int(query, "instrument_id") timeframe = _get_single(query, "timeframe", "1m") return 200, deps["trading_launch_readiness"](instrument_id=instrument_id, timeframe=timeframe) + if method == "GET" and path == "/trading/shadow-paper-validation": + instrument_id = _require_int(query, "instrument_id") + timeframe = _get_single(query, "timeframe", "1m") + return 200, deps["trading_shadow_paper_validation"](instrument_id=instrument_id, timeframe=timeframe) + if method == "POST" and path == "/trading/shadow-paper/observe": + return 201, deps["trading_shadow_paper_observation_record"]( + instrument_id=_require_json_int(body or {}, "instrument_id"), + timeframe=_get_body_value(body, "timeframe", "1m"), + session_code=_get_body_value(body, "session_code", None), + candidate_id=_get_body_optional_int(body, "candidate_id"), + decision_packet_id=_get_body_optional_int(body, "decision_packet_id"), + paper_ticket_id=_get_body_optional_int(body, "paper_ticket_id"), + paper_execution_id=_get_body_optional_int(body, "paper_execution_id"), + operator_agreement=_get_body_value(body, "operator_agreement", "pending"), + operator_note=_get_body_value(body, "operator_note", ""), + created_by=_get_body_value(body, "created_by", "local_operator"), + ) if method == "POST" and path == "/trading/watch/run": instrument_id = _require_json_int(body or {}, "instrument_id") candidate = deps["trading_watch_run"]( @@ -772,6 +852,7 @@ def route_request( execution_mode=_get_body_value(body, "execution_mode", "semi_auto"), account_equity=_get_body_float(body, "account_equity", 10000.0), risk_pct=_get_body_float(body, "risk_pct", 0.01), + allow_shadow_paper=_get_body_bool(body, "allow_shadow_paper", False), ) if payload is None: return 404, {"error": "not_found"} diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index dbbcb19..5abce67 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -7,7 +7,7 @@ from src.api.trading_plans import get_recent_trading_plans, normalize_trading_pl 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.broker import BrokerReadModelService, BROKER_STATE_OK +from src.services.broker import BrokerReadModelService, BROKER_STATE_OK, OKXSandboxPreflightService from src.services.broker import LiveBrokerService from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService @@ -15,7 +15,11 @@ from src.services.journal import TradingJournalService, WeeklyReviewService from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService from src.services.reconciliation import BrokerExecutionReconciliationService -from src.services.readiness import LaunchReadinessService +from src.services.readiness import ( + LaunchReadinessService, + ShadowPaperSampleCollectionService, + ShadowPaperValidationService, +) from src.services.runtime import get_trading_runtime_service from src.services.runtime.runtime_incident_journal_service import RuntimeIncidentJournalService from src.services.trade_plan_candidates import SetupWatcherService @@ -155,6 +159,7 @@ def build_ticket_from_candidate( execution_mode: str = "semi_auto", account_equity: float = 10000.0, risk_pct: float = 0.01, + allow_shadow_paper: bool = False, ) -> Optional[dict]: candidate_row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id) if not candidate_row: @@ -166,7 +171,12 @@ def build_ticket_from_candidate( instrument_id=int(candidate_row["instrument_id"]), model_code=str(candidate_row.get("model_code") or ""), ) - if not trust_policy.get("execution_allowed", False): + shadow_paper_allowed = ( + execution_mode == "paper" + and allow_shadow_paper + and not trust_policy.get("execution_allowed", False) + ) + if not trust_policy.get("execution_allowed", False) and not shadow_paper_allowed: raise ValueError("candidate_model_not_enabled_for_execution") signal_id = ensure_trade_signal_for_candidate(candidate_row) ticket = build_execution_service().create_execution_ticket( @@ -175,7 +185,15 @@ def build_ticket_from_candidate( account_equity=account_equity, risk_pct=risk_pct, ) - return ticket.to_dict() if ticket else None + payload = ticket.to_dict() if ticket else None + if payload is not None and shadow_paper_allowed: + payload["shadow_paper_validation"] = { + "status": "paper_only_shadow_allowed", + "trust_execution_allowed": False, + "execution_boundary": "real_live_still_blocked", + "reason": "explicit_shadow_paper_validation_sample", + } + return payload def get_trading_ticket_by_id(ticket_id: int) -> Optional[dict]: @@ -417,10 +435,59 @@ def get_trading_live_readiness(instrument_id: int, timeframe: str = "1m") -> dic return build_live_broker_service().build_live_readiness(instrument_id=instrument_id, timeframe=timeframe) +def get_trading_okx_sandbox_preflight(instrument_id: int, timeframe: str = "1m") -> dict: + return OKXSandboxPreflightService().build_summary(instrument_id=instrument_id, timeframe=timeframe) + + def get_trading_launch_readiness(instrument_id: int, timeframe: str = "1m") -> dict: return LaunchReadinessService().build_summary(instrument_id=instrument_id, timeframe=timeframe) +def get_trading_shadow_paper_validation(instrument_id: int, timeframe: str = "1m") -> dict: + runtime_status = get_trading_runtime_service().status() + weekly_review = WeeklyReviewService().build_summary(instrument_id=instrument_id) + return ShadowPaperValidationService().build_summary( + instrument_id=instrument_id, + timeframe=timeframe, + runtime_status=runtime_status, + weekly_review=weekly_review, + ) + + +def record_trading_shadow_paper_observation( + *, + instrument_id: int, + timeframe: str = "1m", + session_code: Optional[str] = None, + candidate_id: Optional[int] = None, + decision_packet_id: Optional[int] = None, + paper_ticket_id: Optional[int] = None, + paper_execution_id: Optional[int] = None, + operator_agreement: str = "pending", + operator_note: str = "", + created_by: str = "local_operator", +) -> dict: + runtime_status = get_trading_runtime_service().status() + payload = ShadowPaperSampleCollectionService().record_daily_observation( + instrument_id=instrument_id, + timeframe=timeframe, + session_code=session_code, + candidate_id=candidate_id, + decision_packet_id=decision_packet_id, + paper_ticket_id=paper_ticket_id, + paper_execution_id=paper_execution_id, + operator_agreement=operator_agreement, + operator_note=operator_note, + created_by=created_by, + runtime_status=runtime_status, + ) + payload["shadow_paper_validation_summary"] = get_trading_shadow_paper_validation( + instrument_id=instrument_id, + timeframe=timeframe, + ) + return payload + + def get_trading_session_view(instrument_id: int, limit: int = 12) -> dict: return SessionFocusService().build_summary(instrument_id=instrument_id, limit=limit) diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index 40a6212..29ecbb7 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -17,6 +17,8 @@ from src.repositories.research_council_link_repository import ResearchCouncilLin 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.shadow_paper_observation_repository import ShadowPaperObservationRepository +from src.repositories.shadow_paper_validation_repository import ShadowPaperValidationRepository from src.repositories.signal_repository import SignalRepository from src.repositories.structure_repository import StructureRepository from src.repositories.swing_repository import SwingRepository @@ -43,6 +45,8 @@ __all__ = [ "ResearchCouncilMessageRepository", "ResearchCouncilOptimizationRepository", "ResearchCouncilRunRepository", + "ShadowPaperObservationRepository", + "ShadowPaperValidationRepository", "SignalRepository", "StructureRepository", "SwingRepository", diff --git a/src/repositories/shadow_paper_observation_repository.py b/src/repositories/shadow_paper_observation_repository.py new file mode 100644 index 0000000..61a232d --- /dev/null +++ b/src/repositories/shadow_paper_observation_repository.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +from datetime import date, datetime +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ShadowPaperObservationRepository: + def upsert_daily_observation( + self, + *, + instrument_id: int, + observation_date: date, + timeframe: str, + session_code: Optional[str], + candidate_id: Optional[int], + decision_packet_id: Optional[int], + paper_ticket_id: Optional[int], + paper_execution_id: Optional[int], + observation_status: str, + system_recommendation: str, + operator_agreement: str, + operator_note: str, + created_by: str, + reason_codes: list[str], + payload: dict, + observed_ts: datetime, + ) -> dict: + sql = text( + """ + insert into shadow_paper_observations ( + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + operator_note, + created_by, + reason_codes, + payload, + observed_ts + ) values ( + :instrument_id, + :observation_date, + :timeframe, + :session_code, + :candidate_id, + :decision_packet_id, + :paper_ticket_id, + :paper_execution_id, + :observation_status, + :system_recommendation, + :operator_agreement, + :operator_note, + :created_by, + cast(:reason_codes as jsonb), + cast(:payload as jsonb), + :observed_ts + ) + on conflict (instrument_id, observation_date, timeframe, session_code) + do update set + candidate_id = coalesce(excluded.candidate_id, shadow_paper_observations.candidate_id), + decision_packet_id = coalesce(excluded.decision_packet_id, shadow_paper_observations.decision_packet_id), + paper_ticket_id = coalesce(excluded.paper_ticket_id, shadow_paper_observations.paper_ticket_id), + paper_execution_id = coalesce(excluded.paper_execution_id, shadow_paper_observations.paper_execution_id), + observation_status = case + when excluded.observation_status in ('candidate_observed', 'no_candidate_observed') + and shadow_paper_observations.operator_agreement <> 'pending' + then 'operator_feedback_recorded' + else excluded.observation_status + end, + system_recommendation = excluded.system_recommendation, + operator_agreement = case + when excluded.operator_agreement = 'pending' + and shadow_paper_observations.operator_agreement <> 'pending' + then shadow_paper_observations.operator_agreement + else excluded.operator_agreement + end, + operator_note = case + when excluded.operator_note <> '' then excluded.operator_note + else shadow_paper_observations.operator_note + end, + created_by = excluded.created_by, + reason_codes = excluded.reason_codes, + payload = shadow_paper_observations.payload || excluded.payload, + observed_ts = excluded.observed_ts, + updated_ts = now() + returning id, + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + operator_note, + created_by, + reason_codes, + payload, + observed_ts, + created_ts, + updated_ts + """ + ) + params = { + "instrument_id": instrument_id, + "observation_date": observation_date, + "timeframe": timeframe, + "session_code": _normalize_session_code(session_code), + "candidate_id": candidate_id, + "decision_packet_id": decision_packet_id, + "paper_ticket_id": paper_ticket_id, + "paper_execution_id": paper_execution_id, + "observation_status": observation_status, + "system_recommendation": system_recommendation, + "operator_agreement": operator_agreement, + "operator_note": operator_note, + "created_by": created_by, + "reason_codes": json.dumps(reason_codes, default=str), + "payload": json.dumps(payload, default=str), + "observed_ts": observed_ts, + } + with SessionLocal.begin() as session: + row = session.execute(sql, params).mappings().one() + return dict(row) + + def fetch_recent_observations(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select id, + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + operator_note, + created_by, + reason_codes, + payload, + observed_ts, + created_ts, + updated_ts + from shadow_paper_observations + where instrument_id = :instrument_id + and observed_ts >= :since + order by observation_date desc, observed_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_latest_candidate(self, *, instrument_id: int, since: datetime) -> Optional[dict]: + sql = text( + """ + select id, + instrument_id, + model_code, + status, + session_code, + candidate_state, + confirmation_state, + created_ts, + quality_score, + ai_explanation + from trade_plan_candidates + where instrument_id = :instrument_id + and created_ts >= :since + order by created_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id, "since": since}).mappings().first() + return dict(row) if row else None + + def fetch_latest_decision_packet(self, *, instrument_id: int, since: datetime) -> Optional[dict]: + sql = text( + """ + select id, + instrument_id, + candidate_id, + execution_ticket_id, + packet_ts, + status, + action_recommendation, + quality_score, + ai_explanation, + payload + from decision_packets + where instrument_id = :instrument_id + and packet_ts >= :since + order by packet_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id, "since": since}).mappings().first() + return dict(row) if row else None + + +def _normalize_session_code(value: Optional[str]) -> str: + normalized = str(value or "").strip() + return normalized or "ALL" + + +__all__ = ["ShadowPaperObservationRepository"] diff --git a/src/repositories/shadow_paper_validation_repository.py b/src/repositories/shadow_paper_validation_repository.py new file mode 100644 index 0000000..250b911 --- /dev/null +++ b/src/repositories/shadow_paper_validation_repository.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class ShadowPaperValidationRepository: + def fetch_recent_candidates(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select id, instrument_id, model_code, status, session_code, candidate_state, + created_ts, expires_ts, quality_score + from trade_plan_candidates + where instrument_id = :instrument_id + and created_ts >= :since + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_recent_tickets(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select et.id, et.signal_id, et.execution_mode, et.status, et.created_ts, + et.confirmed_by, et.confirmed_at, et.execution_id, + ts.instrument_id, ts.model_code, ts.session_code + from execution_tickets et + join trade_signals ts on ts.id = et.signal_id + where ts.instrument_id = :instrument_id + and et.created_ts >= :since + order by et.created_ts desc nulls last, et.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_recent_executions(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select te.id, te.signal_id, te.execution_ticket_id, te.execution_mode, + te.status, te.opened_ts, te.closed_ts, te.pnl, te.reconciliation_state, + ts.instrument_id, ts.model_code, ts.session_code + from trade_executions te + join trade_signals ts on ts.id = te.signal_id + where ts.instrument_id = :instrument_id + and coalesce(te.opened_ts, te.closed_ts, te.created_at) >= :since + order by coalesce(te.opened_ts, te.closed_ts, te.created_at) desc nulls last, te.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_recent_journal_entries(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select tje.id, tje.execution_id, tje.candidate_id, tje.session_code, + tje.execution_mode, tje.journal_ts, tje.journal_date, + tje.framework_execution, tje.model_compliance, tje.risk_compliance, + tje.real_trade_detected, tje.risk_violation, tje.execution_violation, + tje.model_failure, ts.instrument_id, ts.model_code + from trading_journal_entries tje + left join trade_signals ts on ts.id = tje.signal_id + where ts.instrument_id = :instrument_id + and tje.journal_ts >= :since + order by tje.journal_ts desc, tje.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_recent_operator_feedback(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select af.id, af.subject_type, af.subject_id, af.label, af.note, + af.created_by, af.created_ts, da.instrument_id + from alert_feedback af + join decision_alerts da on da.id = af.alert_id + where da.instrument_id = :instrument_id + and af.created_ts >= :since + union all + select af.id, af.subject_type, af.subject_id, af.label, af.note, + af.created_by, af.created_ts, vwr.instrument_id + from alert_feedback af + join visual_reviews vr on vr.id = af.visual_review_id + join visual_watch_runs vwr on vwr.id = vr.watch_run_id + where vwr.instrument_id = :instrument_id + and af.created_ts >= :since + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def fetch_recent_observations(self, *, instrument_id: int, since: datetime, limit: int = 500) -> list[dict]: + sql = text( + """ + select id, + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + operator_note, + created_by, + reason_codes, + payload, + observed_ts, + created_ts, + updated_ts + from shadow_paper_observations + where instrument_id = :instrument_id + and observed_ts >= :since + order by observation_date desc, observed_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since, "limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/services/broker/__init__.py b/src/services/broker/__init__.py index adf9f77..69f0a42 100644 --- a/src/services/broker/__init__.py +++ b/src/services/broker/__init__.py @@ -21,6 +21,12 @@ from src.services.broker.broker_read_model_service import ( BROKER_STATE_UNAVAILABLE, BrokerReadModelService, ) +from src.services.broker.okx_sandbox_preflight_service import ( + OKXSandboxPreflightService, + build_okx_client_order_id, + build_okx_error_normalization_catalog, + normalize_okx_order_error, +) __all__ = [ "BracketOrderRequest", @@ -36,10 +42,14 @@ __all__ = [ "ALERT_UNKNOWN_OPEN_POSITION", "ALERT_UNKNOWN_PENDING_ORDER", "BrokerReadModelService", + "OKXSandboxPreflightService", "build_bracket_semantics", + "build_okx_client_order_id", + "build_okx_error_normalization_catalog", "build_live_order_envelope", "build_operator_confirmation_protocol", "build_reconciliation_semantics", + "normalize_okx_order_error", "LiveBrokerService", "LiveOperationDecision", "OKXOrderAdapter", diff --git a/src/services/broker/live_readiness_service.py b/src/services/broker/live_readiness_service.py index 94bf9dc..a0ea8a3 100644 --- a/src/services/broker/live_readiness_service.py +++ b/src/services/broker/live_readiness_service.py @@ -12,6 +12,7 @@ from src.services.broker.broker_adapter import ( build_reconciliation_semantics, ) from src.services.broker.broker_read_model_service import BROKER_STATE_OK, BrokerReadModelService +from src.services.broker.okx_sandbox_preflight_service import OKXSandboxPreflightService from src.services.decision import DecisionPacketService from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.runtime import get_trading_runtime_service @@ -37,11 +38,13 @@ class LiveReadinessService: execution_repository: Optional[ExecutionRepository] = None, decision_packet_service: Optional[DecisionPacketService] = None, trust_service: Optional[ModelTrustService] = None, + okx_sandbox_preflight_service: Optional[OKXSandboxPreflightService] = None, ) -> None: self.broker_read_model_service = broker_read_model_service or BrokerReadModelService() self.execution_repository = execution_repository or ExecutionRepository() self.decision_packet_service = decision_packet_service or DecisionPacketService() self.trust_service = trust_service or ModelTrustService() + self.okx_sandbox_preflight_service = okx_sandbox_preflight_service or OKXSandboxPreflightService() if market_state_service is None: self.market_state_service = MarketStateService(market_truth_supervisor=get_market_truth_supervisor()) else: @@ -60,6 +63,11 @@ class LiveReadinessService: latest_ticket = self.execution_repository.fetch_recent_execution_ticket_rows(instrument_id=instrument_id, limit=1) latest_audits = self.execution_repository.fetch_recent_live_operation_audits(instrument_id=instrument_id, limit=10) decision_preview = self.decision_packet_service.build_preview(instrument_id=instrument_id) + okx_sandbox_preflight = self.okx_sandbox_preflight_service.build_summary( + instrument_id=instrument_id, + timeframe=timeframe, + now=now, + ) protocol = build_operator_confirmation_protocol() checklist = [ LiveReadinessCheck( @@ -97,6 +105,11 @@ class LiveReadinessService: passed=True, detail=f"protocol_version={protocol['protocol_version']}", ), + LiveReadinessCheck( + code="okx_sandbox_preflight_ready", + passed=bool(okx_sandbox_preflight.get("dry_run_ready")), + detail=f"status={okx_sandbox_preflight.get('status')}, next_gate={okx_sandbox_preflight.get('next_gate')}", + ), LiveReadinessCheck( code="adapter_real_live_not_ready", passed=False, @@ -112,6 +125,7 @@ class LiveReadinessService: "blocking_reasons": blocking_reasons, "checklist": [item.to_dict() for item in checklist], "operator_confirmation_protocol": protocol, + "okx_sandbox_preflight_summary": okx_sandbox_preflight, "live_order_schema": { "schema_version": "live-order-v1", "bracket_semantics": build_bracket_semantics(), diff --git a/src/services/broker/okx_sandbox_preflight_service.py b/src/services/broker/okx_sandbox_preflight_service.py new file mode 100644 index 0000000..29eac83 --- /dev/null +++ b/src/services/broker/okx_sandbox_preflight_service.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import base64 +import hashlib +import hmac +from http.client import IncompleteRead +import json +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import ProxyHandler, Request, build_opener, urlopen + +from src.config.settings import settings +from src.services.broker.broker_adapter import build_bracket_semantics, build_reconciliation_semantics +from src.services.market_state.market_truth_supervisor import build_okx_inst_id + + +OKX_SANDBOX_HEADER = "x-simulated-trading" +OKX_SANDBOX_HEADER_VALUE = "1" + + +@dataclass(frozen=True) +class OKXSignedRequestPreview: + operation: str + method: str + path: str + request_path: str + body: dict + signed: bool + header_names: list[str] + idempotency_key: Optional[str] + simulated_trading_header: str + + def to_dict(self) -> dict: + return dict(asdict(self)) + + +class OKXSandboxPreflightService: + """Build a safe OKX demo-trading preflight without submitting orders.""" + + def __init__( + self, + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + api_passphrase: Optional[str] = None, + proxy_url: Optional[str] = None, + ) -> None: + self.base_url = (base_url or settings.okx_order_base_url or settings.okx_base_url).rstrip("/") + self.api_key = settings.okx_api_key.strip() if api_key is None else str(api_key or "").strip() + self.api_secret = settings.okx_api_secret.strip() if api_secret is None else str(api_secret or "").strip() + self.api_passphrase = settings.okx_api_passphrase.strip() if api_passphrase is None else str(api_passphrase or "").strip() + self.proxy_url = str( + proxy_url + if proxy_url is not None + else (settings.broker_read_proxy_url or settings.market_truth_proxy_url or "") + ).strip() + + def build_summary( + self, + *, + instrument_id: int, + timeframe: str = "1m", + now: Optional[datetime] = None, + ) -> dict: + observed_now = _ensure_aware(now or datetime.now(timezone.utc)) + symbol = _resolve_symbol(instrument_id) + inst_id = build_okx_inst_id(symbol) + credentials_ready = bool(self.api_key and self.api_secret and self.api_passphrase) + idempotency_key = build_okx_client_order_id( + instrument_id=instrument_id, + timeframe=timeframe, + observed_ts=observed_now, + ) + previews = [ + self._build_signed_request_preview( + operation="submit_order", + method="POST", + path="/api/v5/trade/order", + body={ + "instId": inst_id, + "tdMode": "isolated", + "side": "buy", + "ordType": "limit", + "px": "1", + "sz": "1", + "clOrdId": idempotency_key, + }, + idempotency_key=idempotency_key, + observed_now=observed_now, + credentials_ready=credentials_ready, + ), + self._build_signed_request_preview( + operation="cancel_order", + method="POST", + path="/api/v5/trade/cancel-order", + body={"instId": inst_id, "clOrdId": idempotency_key}, + idempotency_key=idempotency_key, + observed_now=observed_now, + credentials_ready=credentials_ready, + ), + self._build_signed_request_preview( + operation="query_order", + method="GET", + path="/api/v5/trade/order", + body={"instId": inst_id, "clOrdId": idempotency_key}, + idempotency_key=idempotency_key, + observed_now=observed_now, + credentials_ready=credentials_ready, + ), + self._build_signed_request_preview( + operation="query_fills", + method="GET", + path="/api/v5/trade/fills", + body={"instId": inst_id, "limit": "100"}, + idempotency_key=None, + observed_now=observed_now, + credentials_ready=credentials_ready, + ), + ] + dry_run_ready = credentials_ready and all(item.signed for item in previews) + checklist = [ + { + "code": "okx_sandbox_credentials_present", + "passed": credentials_ready, + "detail": "OKX api key/secret/passphrase present for sandbox preflight." if credentials_ready else "OKX api key/secret/passphrase missing.", + }, + { + "code": "okx_simulated_trading_header_ready", + "passed": True, + "detail": f"{OKX_SANDBOX_HEADER}={OKX_SANDBOX_HEADER_VALUE}", + }, + { + "code": "okx_submit_cancel_query_fills_dry_run_ready", + "passed": dry_run_ready, + "detail": "submit/cancel/query/fills signed request previews built." if dry_run_ready else "signed previews require sandbox credentials.", + }, + { + "code": "okx_order_idempotency_key_ready", + "passed": bool(idempotency_key), + "detail": idempotency_key, + }, + { + "code": "okx_bracket_oco_mapping_ready", + "passed": True, + "detail": "bracket semantics and reconciliation semantics are available for adapter mapping.", + }, + { + "code": "okx_error_normalization_ready", + "passed": True, + "detail": "network/insufficient_balance/parameter_error/exchange_rejected/partial_fill categories are mapped.", + }, + { + "code": "okx_live_submit_still_disabled", + "passed": not bool(settings.live_trading_enabled), + "detail": f"LIVE_TRADING_ENABLED={bool(settings.live_trading_enabled)}", + }, + ] + failed = [str(item["code"]) for item in checklist if not item.get("passed")] + status = "sandbox_preflight_ready" if dry_run_ready and not bool(settings.live_trading_enabled) else "waiting_for_sandbox_credentials" + if bool(settings.live_trading_enabled): + status = "live_switch_enabled_requires_operator_review" + return { + "generated_ts": observed_now.isoformat(), + "status": status, + "adapter_name": "okx", + "mode": "okx_sandbox_preflight", + "instrument_id": instrument_id, + "symbol": symbol, + "okx_inst_id": inst_id, + "timeframe": timeframe, + "base_url": self.base_url, + "proxy_configured": bool(self.proxy_url), + "sandbox_header": {OKX_SANDBOX_HEADER: OKX_SANDBOX_HEADER_VALUE}, + "sandbox_credentials_present": credentials_ready, + "live_trading_enabled": bool(settings.live_trading_enabled), + "submit_dry_run_ready": dry_run_ready, + "cancel_dry_run_ready": dry_run_ready, + "query_order_dry_run_ready": dry_run_ready, + "query_fills_dry_run_ready": dry_run_ready, + "dry_run_ready": dry_run_ready, + "idempotency_key_preview": idempotency_key, + "bracket_semantics": build_bracket_semantics(), + "reconciliation_semantics": build_reconciliation_semantics(), + "error_normalization": build_okx_error_normalization_catalog(), + "signed_request_previews": [item.to_dict() for item in previews], + "checklist": checklist, + "failed_criteria": failed, + "next_gate": "sandbox_submit_cancel_reconcile_smoke" if dry_run_ready else "okx_sandbox_credentials", + "execution_boundary": "real_live_still_blocked", + "operator_summary": _build_operator_summary(status=status, failed_criteria=failed), + } + + def run_isolated_smoke( + self, + *, + instrument_id: int, + timeframe: str = "1m", + execute: bool = False, + order_price: Optional[str] = None, + order_size: Optional[str] = None, + order_side: str = "buy", + order_type: str = "post_only", + td_mode: str = "isolated", + timeout_seconds: float = 20.0, + now: Optional[datetime] = None, + opener=None, + ) -> dict: + observed_now = _ensure_aware(now or datetime.now(timezone.utc)) + preflight = self.build_summary(instrument_id=instrument_id, timeframe=timeframe, now=observed_now) + smoke = { + "generated_ts": observed_now.isoformat(), + "status": "blocked", + "mode": "okx_sandbox_isolated_smoke", + "instrument_id": instrument_id, + "timeframe": timeframe, + "execute_requested": bool(execute), + "external_channels_used": False, + "real_trade_required": False, + "main_account_live_submit_enabled": False, + "execution_boundary": "real_live_still_blocked", + "proxy_configured": bool(self.proxy_url), + "preflight_status": preflight.get("status"), + "preflight_next_gate": preflight.get("next_gate"), + "idempotency_key": preflight.get("idempotency_key_preview"), + "steps": [], + "warnings": [], + "blockers": [], + "next_gate": "sandbox_submit_cancel_reconcile_smoke", + } + if not execute: + smoke["blockers"] = ["okx_sandbox_smoke_execute_not_enabled"] + smoke["next_gate"] = "enable_explicit_sandbox_smoke_execute" + smoke["operator_summary"] = "Sandbox smoke is armed but not executed; set the explicit sandbox execute flag to call OKX demo trading." + return smoke + if bool(settings.live_trading_enabled): + smoke["blockers"] = ["live_trading_enabled_must_remain_false_for_sandbox_smoke"] + smoke["next_gate"] = "turn_off_live_trading_before_sandbox_smoke" + smoke["operator_summary"] = "Sandbox smoke refused to run while LIVE_TRADING_ENABLED is true." + return smoke + if not bool(preflight.get("dry_run_ready")): + smoke["blockers"] = list(preflight.get("failed_criteria") or ["okx_sandbox_preflight_not_ready"]) + smoke["next_gate"] = preflight.get("next_gate") or "okx_sandbox_credentials" + smoke["operator_summary"] = "Sandbox smoke cannot run until signed sandbox preflight is ready." + return smoke + + open_fn = opener or _build_urlopen(proxy_url=self.proxy_url) + inst_id = str(preflight.get("okx_inst_id") or build_okx_inst_id(_resolve_symbol(instrument_id))) + cl_ord_id = str(preflight.get("idempotency_key_preview") or build_okx_client_order_id( + instrument_id=instrument_id, + timeframe=timeframe, + observed_ts=observed_now, + )) + submit_body = { + "instId": inst_id, + "tdMode": td_mode, + "side": order_side, + "ordType": order_type, + "px": str(order_price or "1"), + "sz": str(order_size or "1"), + "clOrdId": cl_ord_id, + } + submit_step = self._call_private_endpoint( + operation="submit_order", + method="POST", + path="/api/v5/trade/order", + body=submit_body, + observed_now=observed_now, + timeout_seconds=timeout_seconds, + opener=open_fn, + ) + smoke["steps"].append(submit_step) + if not submit_step.get("ok"): + smoke["blockers"] = ["okx_sandbox_submit_failed"] + smoke["operator_summary"] = "OKX sandbox submit failed; inspect normalized_error before retrying." + return smoke + + order_ref = _extract_order_ref(submit_step, fallback_cl_ord_id=cl_ord_id) + query_body = {"instId": inst_id, **order_ref} + query_step = self._call_private_endpoint( + operation="query_order_after_submit", + method="GET", + path="/api/v5/trade/order", + body=query_body, + observed_now=observed_now, + timeout_seconds=timeout_seconds, + opener=open_fn, + ) + smoke["steps"].append(query_step) + + cancel_step = self._call_private_endpoint( + operation="cancel_order", + method="POST", + path="/api/v5/trade/cancel-order", + body=query_body, + observed_now=observed_now, + timeout_seconds=timeout_seconds, + opener=open_fn, + ) + smoke["steps"].append(cancel_step) + + query_after_cancel = self._call_private_endpoint( + operation="query_order_after_cancel", + method="GET", + path="/api/v5/trade/order", + body=query_body, + observed_now=observed_now, + timeout_seconds=timeout_seconds, + opener=open_fn, + ) + smoke["steps"].append(query_after_cancel) + + fills_step = self._call_private_endpoint( + operation="query_fills", + method="GET", + path="/api/v5/trade/fills", + body={"instId": inst_id, "limit": "100"}, + observed_now=observed_now, + timeout_seconds=timeout_seconds, + opener=open_fn, + ) + smoke["steps"].append(fills_step) + smoke["order_ref"] = order_ref + smoke["reconcile_check"] = _build_sandbox_reconcile_check( + submit_step=submit_step, + query_step=query_step, + cancel_step=cancel_step, + query_after_cancel=query_after_cancel, + fills_step=fills_step, + ) + failed_steps = [str(step.get("operation")) for step in smoke["steps"] if not step.get("ok")] + if failed_steps: + smoke["status"] = "blocked" + smoke["blockers"] = [f"okx_sandbox_step_failed:{failed_steps[0]}"] + smoke["operator_summary"] = f"OKX sandbox smoke stopped on {failed_steps[0]}." + return smoke + smoke["status"] = "passed" + smoke["next_gate"] = "sandbox_smoke_passed_keep_real_live_disabled" + smoke["operator_summary"] = "OKX sandbox submit/query/cancel/fills smoke passed in isolated simulated trading; real live submit remains blocked." + return smoke + + def _build_signed_request_preview( + self, + *, + operation: str, + method: str, + path: str, + body: dict, + idempotency_key: Optional[str], + observed_now: datetime, + credentials_ready: bool, + ) -> OKXSignedRequestPreview: + request_path = _build_request_path(path=path, method=method, body=body) + headers = self._build_headers( + method=method, + request_path=request_path, + body=body if method.upper() != "GET" else {}, + observed_now=observed_now, + credentials_ready=credentials_ready, + ) + return OKXSignedRequestPreview( + operation=operation, + method=method.upper(), + path=path, + request_path=request_path, + body=dict(body), + signed=credentials_ready and "OK-ACCESS-SIGN" in headers, + header_names=list(headers.keys()), + idempotency_key=idempotency_key, + simulated_trading_header=headers.get(OKX_SANDBOX_HEADER, ""), + ) + + def _build_headers( + self, + *, + method: str, + request_path: str, + body: dict, + observed_now: datetime, + credentials_ready: bool, + ) -> dict: + timestamp = observed_now.isoformat(timespec="milliseconds").replace("+00:00", "Z") + headers = { + "Content-Type": "application/json", + "User-Agent": "ai-ict/okx-sandbox-preflight", + OKX_SANDBOX_HEADER: OKX_SANDBOX_HEADER_VALUE, + } + if not credentials_ready: + return headers + body_json = _canonical_body_json(method=method, body=body) + sign_input = f"{timestamp}{method.upper()}{request_path}{body_json}".encode("utf-8") + signature = base64.b64encode(hmac.new(self.api_secret.encode("utf-8"), sign_input, hashlib.sha256).digest()).decode("utf-8") + headers.update( + { + "OK-ACCESS-KEY": self.api_key, + "OK-ACCESS-SIGN": signature, + "OK-ACCESS-TIMESTAMP": timestamp, + "OK-ACCESS-PASSPHRASE": self.api_passphrase, + } + ) + return headers + + def _build_http_request( + self, + *, + method: str, + path: str, + body: dict, + observed_now: datetime, + ) -> Request: + request_path = _build_request_path(path=path, method=method, body=body) + body_json = _canonical_body_json(method=method, body=body) + headers = self._build_headers( + method=method, + request_path=request_path, + body=body, + observed_now=observed_now, + credentials_ready=True, + ) + data = body_json.encode("utf-8") if method.upper() != "GET" else None + return Request( + f"{self.base_url}{request_path}", + data=data, + headers=headers, + method=method.upper(), + ) + + def _call_private_endpoint( + self, + *, + operation: str, + method: str, + path: str, + body: dict, + observed_now: datetime, + timeout_seconds: float, + opener, + ) -> dict: + request = self._build_http_request( + method=method, + path=path, + body=body, + observed_now=observed_now, + ) + try: + with opener(request, timeout_seconds) as response: + payload = json.loads(response.read().decode("utf-8")) + http_status = getattr(response, "status", 200) + except HTTPError as exc: + body_text = exc.read().decode("utf-8", errors="replace") + payload = _parse_json_or_error(body_text) + return _build_step_result( + operation=operation, + request=request, + ok=False, + http_status=exc.code, + payload=payload, + normalized_error=normalize_okx_order_error(payload=payload), + ) + except (IncompleteRead, URLError, TimeoutError, OSError) as exc: + return _build_step_result( + operation=operation, + request=request, + ok=False, + http_status=None, + payload={}, + normalized_error=normalize_okx_order_error(transport_error=str(exc)), + ) + except Exception as exc: + return _build_step_result( + operation=operation, + request=request, + ok=False, + http_status=None, + payload={}, + normalized_error=normalize_okx_order_error(transport_error=str(exc)), + ) + normalized_error = normalize_okx_order_error(payload=payload) + ok = normalized_error.get("category") == "ok" and _okx_rows_ok(payload) + if not ok and normalized_error.get("category") == "ok": + normalized_error = _normalize_row_error(payload) + return _build_step_result( + operation=operation, + request=request, + ok=ok, + http_status=http_status, + payload=payload, + normalized_error=normalized_error, + ) + + +def build_okx_client_order_id(*, instrument_id: int, timeframe: str, observed_ts: datetime) -> str: + timestamp = _ensure_aware(observed_ts).strftime("%Y%m%d%H%M%S") + clean_timeframe = "".join(ch for ch in str(timeframe or "1m") if ch.isalnum())[:8] or "1m" + return f"AI{int(instrument_id):02d}{clean_timeframe.upper()}{timestamp}"[:32] + + +def build_okx_error_normalization_catalog() -> dict: + return { + "network_error": ["transport_error", "timeout", "connection_refused"], + "permission_error": ["50120"], + "insufficient_balance": ["51008"], + "parameter_error": ["51000", "51001", "51006"], + "exchange_rejected": ["50000", "50001", "50011", "51100"], + "partial_fill": ["partially_filled", "partially_filled_then_cancelled"], + } + + +def normalize_okx_order_error(*, payload: Optional[dict] = None, transport_error: Optional[str] = None) -> dict: + if transport_error: + return { + "category": "network_error", + "reason_code": "okx_order_transport_error", + "detail": str(transport_error), + } + data = dict(payload or {}) + code = str(data.get("code") or "") + message = str(data.get("msg") or data.get("message") or "") + if code in {"", "0"}: + return {"category": "ok", "reason_code": "okx_order_ok", "detail": message or "ok"} + category = _resolve_error_category(code=code, message=message) + return { + "category": category, + "reason_code": f"okx_order_{category}", + "code": code, + "detail": message or code, + } + + +def _resolve_symbol(instrument_id: int) -> str: + if int(instrument_id) == 2: + return "ETH-USDT" + return "BTC-USDT" + + +def _build_request_path(*, path: str, method: str, body: dict) -> str: + if method.upper() == "GET" and body: + return f"{path}?{urlencode({key: str(value) for key, value in body.items() if value is not None})}" + return path + + +def _canonical_body_json(*, method: str, body: dict) -> str: + if method.upper() == "GET": + return "" + return json.dumps(body or {}, separators=(",", ":"), sort_keys=True) + + +def _urlopen(request: Request, timeout_seconds: float): + return urlopen(request, timeout=timeout_seconds) + + +def _build_urlopen(*, proxy_url: str): + if not proxy_url: + return _urlopen + opener = build_opener(ProxyHandler({"http": proxy_url, "https": proxy_url})) + + def _open(request: Request, timeout_seconds: float): + return opener.open(request, timeout=timeout_seconds) + + return _open + + +def _parse_json_or_error(value: str) -> dict: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"code": "invalid_payload", "msg": str(parsed)} + except Exception: + return {"code": "http_error", "msg": value} + + +def _okx_rows_ok(payload: dict) -> bool: + rows = list(payload.get("data") or []) + for row in rows: + item = dict(row or {}) + row_code = str(item.get("sCode") or item.get("code") or "0") + if row_code not in {"", "0"}: + return False + return True + + +def _normalize_row_error(payload: dict) -> dict: + for row in list(payload.get("data") or []): + item = dict(row or {}) + code = str(item.get("sCode") or item.get("code") or "") + message = str(item.get("sMsg") or item.get("msg") or "") + if code and code != "0": + return normalize_okx_order_error(payload={"code": code, "msg": message}) + return {"category": "exchange_rejected", "reason_code": "okx_order_unknown_row_error", "detail": "row_status_not_ok"} + + +def _preview_okx_data(payload: dict) -> list[dict]: + preview = [] + for row in list(payload.get("data") or [])[:3]: + item = dict(row or {}) + preview.append( + { + key: item.get(key) + for key in ( + "instId", + "ordId", + "clOrdId", + "sCode", + "sMsg", + "state", + "side", + "ordType", + "fillSz", + "fillPx", + "tradeId", + "ts", + ) + if item.get(key) not in (None, "") + } + ) + return preview + + +def _build_step_result( + *, + operation: str, + request: Request, + ok: bool, + http_status: Optional[int], + payload: dict, + normalized_error: dict, +) -> dict: + return { + "operation": operation, + "ok": bool(ok), + "http_status": http_status, + "method": request.get_method(), + "path": request.selector, + "simulated_trading_header": request.headers.get(OKX_SANDBOX_HEADER) or request.headers.get("X-simulated-trading") or OKX_SANDBOX_HEADER_VALUE, + "payload_code": payload.get("code"), + "payload_msg": payload.get("msg"), + "data_preview": _preview_okx_data(payload), + "normalized_error": dict(normalized_error or {}), + } + + +def _extract_order_ref(step: dict, *, fallback_cl_ord_id: str) -> dict: + for row in list(step.get("data_preview") or []): + ord_id = str(row.get("ordId") or "").strip() + if ord_id: + return {"ordId": ord_id} + cl_ord_id = str(row.get("clOrdId") or "").strip() + if cl_ord_id: + return {"clOrdId": cl_ord_id} + return {"clOrdId": fallback_cl_ord_id} + + +def _build_sandbox_reconcile_check( + *, + submit_step: dict, + query_step: dict, + cancel_step: dict, + query_after_cancel: dict, + fills_step: dict, +) -> dict: + submit_ref = _first_ref(submit_step) + query_ref = _first_ref(query_step) + cancel_ref = _first_ref(cancel_step) + return { + "status": "ready" if all(step.get("ok") for step in [submit_step, query_step, cancel_step, query_after_cancel, fills_step]) else "blocked", + "submit_query_ref_match": bool(submit_ref and (query_ref in {"", submit_ref} or submit_ref == query_ref)), + "submit_cancel_ref_match": bool(submit_ref and (cancel_ref in {"", submit_ref} or submit_ref == cancel_ref)), + "fills_endpoint_reachable": bool(fills_step.get("ok")), + "real_live_boundary": "real_live_still_blocked", + } + + +def _first_ref(step: dict) -> str: + for row in list(step.get("data_preview") or []): + return str(row.get("ordId") or row.get("clOrdId") or "") + return "" + + +def _resolve_error_category(*, code: str, message: str) -> str: + lowered = message.lower() + if code == "50120" or "permission" in lowered: + return "permission_error" + if code == "51008" or "balance" in lowered: + return "insufficient_balance" + if code in {"51000", "51001", "51006"} or "parameter" in lowered: + return "parameter_error" + if "partial" in lowered: + return "partial_fill" + return "exchange_rejected" + + +def _build_operator_summary(*, status: str, failed_criteria: list[str]) -> str: + if status == "sandbox_preflight_ready": + return "OKX sandbox preflight can build signed dry-run submit/cancel/query/fills requests; real live submit remains blocked." + if status == "live_switch_enabled_requires_operator_review": + return "LIVE_TRADING_ENABLED is on; keep this surface in operator review before any sandbox or live submit." + return f"OKX sandbox preflight is waiting on {', '.join(failed_criteria[:2]) or 'sandbox credentials'}." + + +def _ensure_aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +__all__ = [ + "OKXSandboxPreflightService", + "OKXSignedRequestPreview", + "build_okx_client_order_id", + "build_okx_error_normalization_catalog", + "normalize_okx_order_error", +] diff --git a/src/services/market_state/market_data_stream_service.py b/src/services/market_state/market_data_stream_service.py index 8593bc1..6b92943 100644 --- a/src/services/market_state/market_data_stream_service.py +++ b/src/services/market_state/market_data_stream_service.py @@ -127,12 +127,14 @@ class MarketDataStreamService: max_lag_seconds: Optional[int] = None, reconnect_backoff_seconds: Optional[int] = None, proxy_url: Optional[str] = None, + ws_factory=None, ) -> None: 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 settings.broker_read_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" + self._ws_factory = ws_factory or WebSocketApp self._lock = threading.RLock() self._subscriptions: dict[tuple[int, str], StreamSubscription] = {} self._snapshots: dict[tuple[int, str], StreamSnapshot] = {} @@ -216,27 +218,33 @@ class MarketDataStreamService: def _run_loop(self) -> None: while not self._stop_event.is_set(): self._mark_connecting() - ws = WebSocketApp( - self.url, - on_open=self._on_open, - on_message=self._on_message, - on_error=self._on_error, - on_close=self._on_close, - ) - with self._lock: - self._ws = ws - try: - kwargs = { - "ping_interval": max(1, self.heartbeat_seconds), - "ping_timeout": max(1, self.heartbeat_seconds // 2), - } - kwargs.update(_proxy_run_forever_kwargs(self.proxy_url)) - ws.run_forever(**kwargs) - except Exception as exc: - self._mark_disconnected(reason=str(exc), mark_error=True) - finally: + for attempt_name, attempt_kwargs in self._connection_attempts(): + ws = self._ws_factory( + self.url, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + ) with self._lock: - self._ws = None + self._ws = ws + try: + kwargs = { + "ping_interval": max(1, self.heartbeat_seconds), + "ping_timeout": max(1, self.heartbeat_seconds // 2), + } + kwargs.update(attempt_kwargs) + ws.run_forever(**kwargs) + except Exception as exc: + self._mark_disconnected(reason=str(exc), mark_error=True) + finally: + with self._lock: + self._ws = None + if self._stop_event.is_set(): + break + if attempt_name == "proxy" and self._should_try_direct_fallback(): + continue + break if self._stop_event.is_set(): break time.sleep(max(self.reconnect_backoff_seconds, 1)) @@ -375,6 +383,18 @@ class MarketDataStreamService: for snapshot in self._snapshots.values(): snapshot.reconnect_count += 1 + def _connection_attempts(self) -> list[tuple[str, dict]]: + proxy_kwargs = _proxy_run_forever_kwargs(self.proxy_url) + if proxy_kwargs: + return [("proxy", proxy_kwargs), ("direct", {})] + return [("direct", {})] + + def _should_try_direct_fallback(self) -> bool: + if self._stop_event.is_set(): + return False + with self._lock: + return not any(snapshot.connected for snapshot in self._snapshots.values()) + def build_data_gap_state( *, diff --git a/src/services/operator/archive_long_range_projection.py b/src/services/operator/archive_long_range_projection.py index f053af7..2f7ce66 100644 --- a/src/services/operator/archive_long_range_projection.py +++ b/src/services/operator/archive_long_range_projection.py @@ -61,9 +61,12 @@ def build_digest_archive_baseline_summary(items: list[dict], *, persisted_snapsh "compare_ready": False, "persistence_status": "not_saved", "suggested_compare_prompt": None, + "baseline_delta_status": "unknown", + "baseline_delta_story": None, } latest = dict(items[0] or {}) baseline = _resolve_archive_baseline_item(items, persisted_snapshot_id) + delta_status, delta_story = _digest_archive_baseline_delta(latest=latest, baseline=baseline) return { "status": "active" if len(items) >= 2 else "insufficient_history", "latest_snapshot_id": latest.get("snapshot_id"), @@ -75,6 +78,8 @@ def build_digest_archive_baseline_summary(items: list[dict], *, persisted_snapsh "persisted_baseline_snapshot_id": persisted_snapshot_id, "persistence_status": "persisted" if persisted_snapshot_id is not None else "not_saved", "compare_ready": len(items) >= 2, + "baseline_delta_status": delta_status, + "baseline_delta_story": delta_story, "suggested_compare_prompt": ( f"Pin digest snapshot {baseline.get('snapshot_id')} as the baseline and compare it against {latest.get('snapshot_id')}." if len(items) >= 2 @@ -140,9 +145,12 @@ def build_workflow_archive_baseline_summary(items: list[dict], *, persisted_snap "compare_ready": False, "persistence_status": "not_saved", "suggested_compare_prompt": None, + "baseline_delta_status": "unknown", + "baseline_delta_story": None, } latest = dict(items[0] or {}) baseline = _resolve_archive_baseline_item(items, persisted_snapshot_id) + delta_status, delta_story = _workflow_archive_baseline_delta(latest=latest, baseline=baseline) return { "status": "active" if len(items) >= 2 else "insufficient_history", "latest_snapshot_id": latest.get("snapshot_id"), @@ -156,6 +164,8 @@ def build_workflow_archive_baseline_summary(items: list[dict], *, persisted_snap "persisted_baseline_snapshot_id": persisted_snapshot_id, "persistence_status": "persisted" if persisted_snapshot_id is not None else "not_saved", "compare_ready": len(items) >= 2, + "baseline_delta_status": delta_status, + "baseline_delta_story": delta_story, "suggested_compare_prompt": ( f"Pin workflow snapshot {baseline.get('snapshot_id')} as the baseline and compare it against {latest.get('snapshot_id')}." if len(items) >= 2 @@ -226,3 +236,92 @@ def _workflow_archive_trend_story(*, trend_status: str, recent_7d_count: int, re if trend_status == "recent_window_only": return f"Workflow archive is still concentrated in a short recent window: 7d={recent_7d_count}, 30d={recent_30d_count}, sessions={distinct_session_count}." return "Workflow archive history is still too thin for long-range review." + + +def _digest_archive_baseline_delta(*, latest: dict, baseline: dict) -> tuple[str, str]: + latest_summary = str(latest.get("top_summary") or "") + baseline_summary = str(baseline.get("top_summary") or "") + latest_high_value = int(latest.get("high_value_reminder_count") or 0) + baseline_high_value = int(baseline.get("high_value_reminder_count") or 0) + latest_reminders = int(latest.get("reminder_count") or 0) + baseline_reminders = int(baseline.get("reminder_count") or 0) + span_days = _covered_day_span(_safe_dt(latest.get("created_ts")), _safe_dt(baseline.get("created_ts"))) + changed = ( + latest_summary != baseline_summary + or latest_high_value != baseline_high_value + or latest_reminders != baseline_reminders + ) + status = "changed" if changed else "flat" + story = ( + f"Latest digest vs baseline spans {span_days} day(s); " + f"high-value delta={latest_high_value - baseline_high_value}, " + f"reminder delta={latest_reminders - baseline_reminders}, " + f"summary {'changed' if latest_summary != baseline_summary else 'unchanged'}." + ) + return status, story + + +def _workflow_archive_baseline_delta(*, latest: dict, baseline: dict) -> tuple[str, str]: + latest_session = str(latest.get("current_session") or "unknown") + baseline_session = str(baseline.get("current_session") or "unknown") + latest_phase = str(latest.get("next_phase") or "unknown") + baseline_phase = str(baseline.get("next_phase") or "unknown") + latest_ready = int(latest.get("ready_for_handoff_count") or 0) + baseline_ready = int(baseline.get("ready_for_handoff_count") or 0) + latest_artifacts = int(latest.get("artifact_count") or 0) + baseline_artifacts = int(baseline.get("artifact_count") or 0) + span_days = _covered_day_span(_safe_dt(latest.get("created_ts")), _safe_dt(baseline.get("created_ts"))) + changed = ( + latest_session != baseline_session + or latest_phase != baseline_phase + or latest_ready != baseline_ready + or latest_artifacts != baseline_artifacts + ) + status = "changed" if changed else "flat" + story = ( + f"Latest workflow vs baseline spans {span_days} day(s); " + f"session {baseline_session}->{latest_session}, " + f"phase {baseline_phase}->{latest_phase}, " + f"ready delta={latest_ready - baseline_ready}, " + f"artifact delta={latest_artifacts - baseline_artifacts}." + ) + return status, story + + +def build_archive_operator_overview(*, digest_summary: dict, workflow_summary: dict) -> dict: + digest_summary = dict(digest_summary or {}) + workflow_summary = dict(workflow_summary or {}) + if not digest_summary and not workflow_summary: + return { + "status": "idle", + "operator_overview_story": "No archive operator overview is available yet.", + } + digest_trend = str(digest_summary.get("trend_status") or "unknown") + workflow_trend = str(workflow_summary.get("trend_status") or "unknown") + digest_snapshot_count = int(digest_summary.get("snapshot_count") or 0) + workflow_snapshot_count = int(workflow_summary.get("snapshot_count") or 0) + digest_baseline_status = str(dict(digest_summary.get("baseline_pin_summary") or {}).get("persistence_status") or "not_saved") + workflow_baseline_status = str(dict(workflow_summary.get("baseline_pin_summary") or {}).get("persistence_status") or "not_saved") + digest_baseline_delta_status = str(dict(digest_summary.get("baseline_pin_summary") or {}).get("baseline_delta_status") or "unknown") + workflow_baseline_delta_status = str(dict(workflow_summary.get("baseline_pin_summary") or {}).get("baseline_delta_status") or "unknown") + digest_baseline_delta_story = str(dict(digest_summary.get("baseline_pin_summary") or {}).get("baseline_delta_story") or "n/a") + workflow_baseline_delta_story = str(dict(workflow_summary.get("baseline_pin_summary") or {}).get("baseline_delta_story") or "n/a") + return { + "status": "active", + "digest_trend_status": digest_trend, + "workflow_trend_status": workflow_trend, + "digest_snapshot_count": digest_snapshot_count, + "workflow_snapshot_count": workflow_snapshot_count, + "digest_baseline_status": digest_baseline_status, + "workflow_baseline_status": workflow_baseline_status, + "digest_baseline_delta_status": digest_baseline_delta_status, + "workflow_baseline_delta_status": workflow_baseline_delta_status, + "digest_baseline_delta_story": digest_baseline_delta_story, + "workflow_baseline_delta_story": workflow_baseline_delta_story, + "operator_overview_story": ( + f"Digest trend={digest_trend} ({digest_snapshot_count} snapshots) | " + f"Workflow trend={workflow_trend} ({workflow_snapshot_count} snapshots) | " + f"Baseline persistence digest/workflow={digest_baseline_status}/{workflow_baseline_status}." + ), + "next_overview_prompt": "Review archive overview before choosing whether to deepen trend review or bridge work next.", + } diff --git a/src/services/operator/archive_retrieval_projection.py b/src/services/operator/archive_retrieval_projection.py new file mode 100644 index 0000000..854b48e --- /dev/null +++ b/src/services/operator/archive_retrieval_projection.py @@ -0,0 +1,82 @@ +from __future__ import annotations + + +def build_digest_archive_ranked_aggregation(items: list[dict]) -> dict: + if not items: + return { + "status": "idle", + "profile_rankings": [], + "history_kind_rankings": [], + "top_profile": None, + "top_history_kind": None, + "aggregation_story": "No digest archive rows are available for aggregation.", + } + profile_rankings = _rank_counts(item.get("profile_key") for item in items) + history_kind_rankings = _rank_counts(item.get("preferred_history_kind") for item in items) + top_profile = dict(profile_rankings[0] or {}) if profile_rankings else None + top_history_kind = dict(history_kind_rankings[0] or {}) if history_kind_rankings else None + return { + "status": "active", + "profile_rankings": profile_rankings[:6], + "history_kind_rankings": history_kind_rankings[:6], + "top_profile": top_profile, + "top_history_kind": top_history_kind, + "aggregation_story": ( + f"Top digest profile is {top_profile.get('value') if top_profile else 'n/a'} " + f"with {top_profile.get('count') if top_profile else 0} snapshot(s); " + f"top history kind is {top_history_kind.get('value') if top_history_kind else 'n/a'}." + ), + } + + +def build_workflow_archive_ranked_aggregation(items: list[dict]) -> dict: + if not items: + return { + "status": "idle", + "profile_rankings": [], + "session_rankings": [], + "phase_rankings": [], + "artifact_rankings": [], + "top_profile": None, + "top_session": None, + "top_phase": None, + "top_artifact": None, + "aggregation_story": "No workflow archive rows are available for aggregation.", + } + profile_rankings = _rank_counts(item.get("profile_key") for item in items) + session_rankings = _rank_counts(item.get("current_session") for item in items) + phase_rankings = _rank_counts(item.get("next_phase") for item in items) + artifact_rankings = _rank_counts(item.get("next_artifact_kind") for item in items) + top_profile = dict(profile_rankings[0] or {}) if profile_rankings else None + top_session = dict(session_rankings[0] or {}) if session_rankings else None + top_phase = dict(phase_rankings[0] or {}) if phase_rankings else None + top_artifact = dict(artifact_rankings[0] or {}) if artifact_rankings else None + return { + "status": "active", + "profile_rankings": profile_rankings[:6], + "session_rankings": session_rankings[:6], + "phase_rankings": phase_rankings[:6], + "artifact_rankings": artifact_rankings[:6], + "top_profile": top_profile, + "top_session": top_session, + "top_phase": top_phase, + "top_artifact": top_artifact, + "aggregation_story": ( + f"Top workflow profile is {top_profile.get('value') if top_profile else 'n/a'}, " + f"top session is {top_session.get('value') if top_session else 'n/a'}, " + f"and top phase is {top_phase.get('value') if top_phase else 'n/a'}." + ), + } + + +def _rank_counts(values) -> list[dict]: + counts: dict[str, int] = {} + for value in values: + key = str(value or "unknown") + counts[key] = int(counts.get(key) or 0) + 1 + rankings = [ + {"value": value, "count": count} + for value, count in counts.items() + ] + rankings.sort(key=lambda row: (-int(row.get("count") or 0), str(row.get("value") or ""))) + return rankings diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 0d9fddc..017fe92 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -25,11 +25,16 @@ from src.services.market_state.exchange_status_provider import ( ) from src.services.market_state.news_event_provider import build_default_news_providers from src.services.operator.archive_long_range_projection import ( + build_archive_operator_overview, build_digest_archive_baseline_summary, build_digest_archive_long_range_summary, build_workflow_archive_baseline_summary, build_workflow_archive_long_range_summary, ) +from src.services.operator.archive_retrieval_projection import ( + build_digest_archive_ranked_aggregation, + build_workflow_archive_ranked_aggregation, +) from src.services.operator.workflow_archive_projection import build_workflow_archive_operations_view from src.services.readiness import LaunchReadinessService from src.services.research_council import CouncilOptimizationReviewService, ResearchCouncilService @@ -322,6 +327,16 @@ class DecisionAssistantSummaryService: digest_archive_compare = _build_decision_assistant_digest_archive_compare(refreshed_digest_history) workflow_archive_compare = _build_decision_assistant_workflow_archive_compare(refreshed_workflow_history) workflow_bridge_persistence_summary = _decision_assistant_workflow_bridge_state(persisted_digest_preference) + archive_operator_overview = build_archive_operator_overview( + digest_summary={ + **dict(digest_history_archive.get("long_range_trend_summary") or {}), + "baseline_pin_summary": dict(digest_history_archive.get("baseline_pin_summary") or {}), + }, + workflow_summary={ + **dict(workflow_history_archive.get("long_range_trend_summary") or {}), + "baseline_pin_summary": dict(workflow_history_archive.get("baseline_pin_summary") or {}), + }, + ) summary["digest_history_archive"] = digest_history_archive summary["digest_archive_compare"] = digest_archive_compare summary["digest_persistence_status"] = persistence_result.get("status") @@ -331,6 +346,7 @@ class DecisionAssistantSummaryService: summary["workflow_archive_multi_day_drift_review"] = workflow_history_archive.get("multi_day_drift_review") summary["workflow_archive_bridge_summary"] = workflow_history_archive.get("bridge_summary") summary["workflow_archive_bridge_persistence_summary"] = workflow_bridge_persistence_summary + summary["archive_operator_overview"] = archive_operator_overview summary["workflow_persistence_status"] = workflow_persistence_result.get("status") digest_surface = dict(summary.get("digest_surface") or {}) digest_surface["digest_history_archive"] = digest_history_archive @@ -715,6 +731,441 @@ def _decision_assistant_workflow_bridge_state(persisted_digest_preference: dict) "handoff_packet_starter": dict(state.get("handoff_packet_starter") or {}), "replay_note_bridge_summary": dict(state.get("replay_note_bridge_summary") or {}), "artifact_bundle_summary": dict(state.get("artifact_bundle_summary") or {}), + "compare_writeback_summary": dict(state.get("compare_writeback_summary") or {}), + } + + +def _decision_assistant_external_context_surface_history(persisted_digest_preference: dict) -> dict: + payload = _decision_assistant_preference_payload(persisted_digest_preference) + items = [dict(item) for item in list(payload.get("external_context_surface_history") or []) if item][:10] + if not items: + return { + "status": "idle", + "entry_count": 0, + "latest_surface_id": None, + "latest_selection_kind": None, + "latest_target_url": None, + "source_group_counts": {}, + "selection_kind_counts": {}, + "surface_counts": {}, + "source_group_rankings": [], + "surface_rankings": [], + "dominant_surface_id": None, + "dominant_surface_title": None, + "dominant_surface_count": 0, + "dominant_source_group": None, + "latest_age_seconds": None, + "recency_status": "idle", + "revisit_needed": False, + "cadence_status": "idle", + "cadence_story": None, + "average_gap_seconds": None, + "revisit_queue_count": 0, + "revisit_queue": [], + "acknowledged_count": 0, + "latest_acknowledged_surface_id": None, + "latest_acknowledged_title": None, + "latest_acknowledged_ts": None, + "latest_acknowledged_by": None, + "resolution_history_count": 0, + "resolution_history": [], + "cleared_queue_status": "idle", + "cleared_queue_story": None, + "next_resolution_review_prompt": None, + "latest_acknowledged_note": None, + "resolution_filter_summary": { + "status": "idle", + "note_count": 0, + "note_gap_count": 0, + "group_counts": {}, + "acknowledged_by_counts": {}, + "filter_options": [], + "next_filter_prompt": None, + }, + "next_revisit_surface_id": None, + "next_revisit_surface_title": None, + "next_revisit_target_url": None, + "revisit_surface_id": None, + "revisit_surface_title": None, + "revisit_target_url": None, + "revisit_prompt": None, + "provenance_story": None, + "dominant_surface_story": None, + "items": [], + } + source_group_counts: dict[str, int] = {} + selection_kind_counts: dict[str, int] = {} + surface_counts: dict[str, int] = {} + for item in items: + source_group = str(item.get("source_group") or "external_context") + selection_kind = str(item.get("selection_kind") or "target_url") + surface_key = str(item.get("title") or item.get("surface_id") or "external_surface") + source_group_counts[source_group] = int(source_group_counts.get(source_group) or 0) + 1 + selection_kind_counts[selection_kind] = int(selection_kind_counts.get(selection_kind) or 0) + 1 + surface_counts[surface_key] = int(surface_counts.get(surface_key) or 0) + 1 + enriched_items = [_decorate_surface_history_item(item) for item in items] + latest = dict(enriched_items[0] or {}) + surface_rankings = _rank_surface_history_counts(surface_counts) + source_group_rankings = _rank_surface_history_counts(source_group_counts) + dominant_surface = dict(surface_rankings[0] or {}) if surface_rankings else {} + dominant_source_group = dict(source_group_rankings[0] or {}) if source_group_rankings else {} + latest_age_seconds = _surface_history_age_seconds(latest.get("selected_ts")) + recency_status, revisit_needed = _surface_history_recency_status(latest_age_seconds) + average_gap_seconds = _surface_history_average_gap_seconds(enriched_items) + cadence_status = _surface_history_cadence_status(average_gap_seconds=average_gap_seconds, entry_count=len(enriched_items)) + revisit_queue = [dict(item) for item in enriched_items if item.get("revisit_needed")] + next_revisit = dict(revisit_queue[0] or {}) if revisit_queue else {} + acknowledged = [dict(item) for item in enriched_items if item.get("revisit_acknowledged")] + latest_acknowledged = dict(sorted( + acknowledged, + key=lambda item: str(item.get("revisit_acknowledged_ts") or ""), + reverse=True, + )[0] or {}) if acknowledged else {} + resolution_filter_summary = _surface_history_resolution_filter_summary(acknowledged) + return { + "status": "active", + "entry_count": len(items), + "latest_surface_id": latest.get("surface_id"), + "latest_title": latest.get("title"), + "latest_selection_kind": latest.get("selection_kind"), + "latest_target_url": latest.get("target_url"), + "latest_selected_ts": latest.get("selected_ts"), + "source_group_counts": source_group_counts, + "selection_kind_counts": selection_kind_counts, + "surface_counts": surface_counts, + "source_group_rankings": source_group_rankings, + "surface_rankings": surface_rankings, + "dominant_surface_id": dominant_surface.get("value"), + "dominant_surface_title": dominant_surface.get("value"), + "dominant_surface_count": dominant_surface.get("count") or 0, + "dominant_source_group": dominant_source_group.get("value"), + "latest_age_seconds": latest_age_seconds, + "recency_status": recency_status, + "revisit_needed": revisit_needed, + "cadence_status": cadence_status, + "cadence_story": ( + f"Surface history cadence is {cadence_status}; average selection gap={format_duration_seconds(average_gap_seconds)}." + if average_gap_seconds is not None + else "Surface history cadence needs at least two selections." + ), + "average_gap_seconds": average_gap_seconds, + "revisit_queue_count": len(revisit_queue), + "revisit_queue": revisit_queue[:5], + "acknowledged_count": len(acknowledged), + "latest_acknowledged_surface_id": latest_acknowledged.get("surface_id"), + "latest_acknowledged_title": latest_acknowledged.get("title"), + "latest_acknowledged_ts": latest_acknowledged.get("revisit_acknowledged_ts"), + "latest_acknowledged_by": latest_acknowledged.get("revisit_acknowledged_by"), + "latest_acknowledged_note": latest_acknowledged.get("revisit_ack_note"), + "resolution_history_count": len(acknowledged), + "resolution_history": acknowledged[:5], + "cleared_queue_status": "active" if acknowledged else "idle", + "cleared_queue_story": ( + f"Cleared revisit queue entries={len(acknowledged)}; latest={latest_acknowledged.get('title') or latest_acknowledged.get('surface_id') or 'n/a'}." + if acknowledged + else "No revisit queue entries have been acknowledged yet." + ), + "next_resolution_review_prompt": ( + f"Review cleared revisit history starting from {latest_acknowledged.get('title') or latest_acknowledged.get('surface_id') or 'n/a'}." + if acknowledged + else None + ), + "resolution_filter_summary": resolution_filter_summary, + "next_revisit_surface_id": next_revisit.get("surface_id"), + "next_revisit_surface_title": next_revisit.get("title"), + "next_revisit_target_url": next_revisit.get("target_url"), + "revisit_surface_id": latest.get("surface_id"), + "revisit_surface_title": latest.get("title"), + "revisit_target_url": latest.get("target_url"), + "provenance_story": ( + f"Surface selections={len(items)}; latest={latest.get('title') or latest.get('surface_id')}; " + f"kinds={selection_kind_counts}; groups={source_group_counts}." + ), + "dominant_surface_story": ( + f"Dominant surface={dominant_surface.get('value') or 'n/a'} ({dominant_surface.get('count') or 0}); " + f"dominant group={dominant_source_group.get('value') or 'n/a'}." + ), + "revisit_prompt": ( + f"Revisit {latest.get('title') or latest.get('surface_id') or 'external surface'} because the latest selection is {recency_status}." + if latest.get("target_url") + else None + ), + "next_review_prompt": "Review external surface selection history before rotating the next visual evidence source.", + "next_revisit_prompt": ( + f"Revisit queue starts with {next_revisit.get('title') or next_revisit.get('surface_id') or 'n/a'} ({next_revisit.get('recency_status') or 'n/a'})." + if next_revisit + else None + ), + "items": enriched_items, + } + + +def _rank_surface_history_counts(counts: dict[str, int]) -> list[dict]: + return [ + {"value": key, "count": value} + for key, value in sorted(counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0] or ""))) + ] + + +def _surface_history_age_seconds(value) -> float | None: + try: + ts = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + return max((datetime.now(timezone.utc) - ts).total_seconds(), 0.0) + + +def _surface_history_recency_status(latest_age_seconds: float | None) -> tuple[str, bool]: + if latest_age_seconds is None: + return "unknown", False + if latest_age_seconds <= 3600: + return "recent", False + if latest_age_seconds <= 6 * 3600: + return "aging", True + return "stale_revisit", True + + +def _decorate_surface_history_item(item: dict) -> dict: + payload = dict(item or {}) + age_seconds = _surface_history_age_seconds(payload.get("selected_ts")) + recency_status, revisit_needed = _surface_history_recency_status(age_seconds) + payload["age_seconds"] = age_seconds + payload["recency_status"] = recency_status + payload["revisit_acknowledged"] = bool(payload.get("revisit_acknowledged")) + payload["revisit_needed"] = bool(revisit_needed and not payload["revisit_acknowledged"]) + return payload + + +def _surface_history_average_gap_seconds(items: list[dict]) -> float | None: + if len(items) < 2: + return None + ordered = [] + for item in items: + try: + ts = datetime.fromisoformat(str(item.get("selected_ts")).replace("Z", "+00:00")) + except Exception: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + ordered.append(ts) + if len(ordered) < 2: + return None + gaps = [] + for idx in range(len(ordered) - 1): + gaps.append(max((ordered[idx] - ordered[idx + 1]).total_seconds(), 0.0)) + return sum(gaps) / len(gaps) if gaps else None + + +def _surface_history_cadence_status(*, average_gap_seconds: float | None, entry_count: int) -> str: + if entry_count < 2 or average_gap_seconds is None: + return "single_sample" + if average_gap_seconds <= 1800: + return "dense" + if average_gap_seconds <= 6 * 3600: + return "active" + return "sporadic" + + +def format_duration_seconds(value: float | None) -> str: + if value is None: + return "n/a" + seconds = int(round(value)) + if seconds < 3600: + return f"{max(seconds // 60, 0)}m" + return f"{seconds // 3600}h" + + +def _surface_history_resolution_filter_summary(items: list[dict]) -> dict: + if not items: + return { + "status": "idle", + "note_count": 0, + "note_gap_count": 0, + "note_gap_queue_count": 0, + "note_gap_queue": [], + "note_gap_oldest_age_seconds": None, + "note_gap_oldest_title": None, + "note_gap_average_age_seconds": None, + "group_counts": {}, + "acknowledged_by_counts": {}, + "owner_spotlight": None, + "owner_cadence_owner": None, + "owner_cadence_status": "idle", + "owner_cadence_average_gap_seconds": None, + "owner_cadence_story": None, + "owner_trend_status": "idle", + "owner_trend_story": None, + "owner_handoff_status": "idle", + "owner_handoff_story": None, + "filter_options": [], + "next_filter_prompt": None, + "next_note_gap_prompt": None, + "owner_spotlight_prompt": None, + "note_gap_priority_lane_status": "idle", + "note_gap_priority_lane_count": 0, + "note_gap_priority_lane_items": [], + "note_gap_priority_lane_story": None, + "note_gap_priority_lane_carry_forward_status": "idle", + "note_gap_priority_lane_carry_forward_story": None, + "note_gap_priority_lane_carry_forward_age_seconds": None, + "note_gap_priority_lane_carry_forward_aging_status": "idle", + "note_gap_priority_lane_carry_forward_aging_story": None, + "next_priority_lane_prompt": None, + "owner_handoff_follow_up_status": "idle", + "owner_handoff_follow_up_story": None, + "resolved_owner_drift_status": "idle", + "resolved_owner_drift_story": None, + "carry_forward_follow_up_lane_status": "idle", + "carry_forward_follow_up_lane_count": 0, + "carry_forward_follow_up_lane_items": [], + "carry_forward_follow_up_lane_story": None, + "next_follow_up_lane_prompt": None, + "resolved_owner_drift_review_status": "idle", + "resolved_owner_drift_review_story": None, + "resolved_owner_drift_review_queue_status": "idle", + "resolved_owner_drift_review_queue_count": 0, + "resolved_owner_drift_review_queue_items": [], + "resolved_owner_drift_review_queue_story": None, + "resolved_owner_drift_review_queue_aging_status": "idle", + "resolved_owner_drift_review_queue_aging_story": None, + "carry_forward_follow_up_lane_rollover_status": "idle", + "carry_forward_follow_up_lane_rollover_story": None, + "carry_forward_follow_up_lane_rollover_handoff_status": "idle", + "carry_forward_follow_up_lane_rollover_handoff_story": None, + "next_follow_up_lane_rollover_prompt": None, + "next_rollover_handoff_prompt": None, + } + note_count = sum(1 for item in items if str(item.get("revisit_ack_note") or "").strip()) + note_gap_count = max(len(items) - note_count, 0) + note_gap_queue = [dict(item) for item in items if not str(item.get("revisit_ack_note") or "").strip()] + note_gap_ages = [float(item.get("age_seconds") or 0) for item in note_gap_queue if item.get("age_seconds") is not None] + oldest_note_gap = dict(sorted( + note_gap_queue, + key=lambda item: float(item.get("age_seconds") or 0), + reverse=True, + )[0] or {}) if note_gap_queue else {} + group_counts: dict[str, int] = {} + acknowledged_by_counts: dict[str, int] = {} + for item in items: + group = str(item.get("source_group") or "external_context") + who = str(item.get("revisit_acknowledged_by") or "unknown") + group_counts[group] = int(group_counts.get(group) or 0) + 1 + acknowledged_by_counts[who] = int(acknowledged_by_counts.get(who) or 0) + 1 + filter_options = [ + {"filter_id": "all", "label": "All Resolved", "count": len(items)}, + {"filter_id": "with_note", "label": "With Resolution Note", "count": note_count}, + {"filter_id": "without_note", "label": "Without Resolution Note", "count": note_gap_count}, + ] + for group, count in sorted(group_counts.items()): + filter_options.append({"filter_id": f"group:{group}", "label": f"{group} ({count})", "count": count}) + for who, count in sorted(acknowledged_by_counts.items()): + filter_options.append({"filter_id": f"ack:{who}", "label": f"{who} ({count})", "count": count}) + owner_spotlight = sorted( + acknowledged_by_counts.items(), + key=lambda item: (-int(item[1] or 0), str(item[0] or "")), + )[0] if acknowledged_by_counts else None + owner_cadence = _surface_history_owner_cadence(items, owner_spotlight[0] if owner_spotlight is not None else None) + priority_lane = _surface_history_note_gap_priority_lane(note_gap_queue) + owner_trend = _surface_history_owner_trend(acknowledged_by_counts, owner_spotlight, len(items)) + owner_handoff = _surface_history_owner_handoff(owner_spotlight, acknowledged_by_counts, len(items)) + priority_lane_carry_forward = _surface_history_priority_lane_carry_forward(priority_lane) + carry_forward_aging = _surface_history_priority_lane_carry_forward_aging(priority_lane) + owner_handoff_follow_up = _surface_history_owner_handoff_follow_up(owner_handoff, priority_lane) + resolved_owner_drift = _surface_history_resolved_owner_drift(items, acknowledged_by_counts, owner_spotlight) + carry_forward_follow_up_lane = _surface_history_carry_forward_follow_up_lane(priority_lane, owner_handoff_follow_up) + resolved_owner_drift_review = _surface_history_resolved_owner_drift_review( + resolved_owner_drift, + owner_handoff_follow_up, + carry_forward_follow_up_lane, + ) + resolved_owner_drift_review_queue = _surface_history_resolved_owner_drift_review_queue( + resolved_owner_drift_review, + carry_forward_follow_up_lane, + ) + resolved_owner_drift_review_queue_aging = _surface_history_resolved_owner_drift_review_queue_aging( + resolved_owner_drift_review_queue, + ) + carry_forward_follow_up_lane_rollover = _surface_history_carry_forward_follow_up_lane_rollover( + carry_forward_follow_up_lane, + ) + carry_forward_follow_up_lane_rollover_handoff = _surface_history_carry_forward_follow_up_lane_rollover_handoff( + carry_forward_follow_up_lane_rollover, + resolved_owner_drift_review_queue, + ) + return { + "status": "active", + "note_count": note_count, + "note_gap_count": note_gap_count, + "note_gap_queue_count": len(note_gap_queue), + "note_gap_queue": note_gap_queue[:5], + "note_gap_oldest_age_seconds": float(oldest_note_gap.get("age_seconds") or 0) if oldest_note_gap else None, + "note_gap_oldest_title": oldest_note_gap.get("title") or oldest_note_gap.get("surface_id"), + "note_gap_average_age_seconds": (sum(note_gap_ages) / len(note_gap_ages)) if note_gap_ages else None, + "group_counts": group_counts, + "acknowledged_by_counts": acknowledged_by_counts, + "owner_spotlight": { + "owner": owner_spotlight[0], + "count": owner_spotlight[1], + } if owner_spotlight is not None else None, + "owner_cadence_owner": owner_spotlight[0] if owner_spotlight is not None else None, + "owner_cadence_status": owner_cadence.get("status") or "idle", + "owner_cadence_average_gap_seconds": owner_cadence.get("average_gap_seconds"), + "owner_cadence_story": owner_cadence.get("story"), + "owner_trend_status": owner_trend.get("status") or "idle", + "owner_trend_story": owner_trend.get("story"), + "owner_handoff_status": owner_handoff.get("status") or "idle", + "owner_handoff_story": owner_handoff.get("story"), + "filter_options": filter_options, + "next_filter_prompt": "Filter cleared revisit history by note coverage or source group before pruning history.", + "next_note_gap_prompt": ( + f"Add a resolution note for {note_gap_queue[0].get('title') or note_gap_queue[0].get('surface_id') or 'n/a'}." + if note_gap_queue + else None + ), + "owner_spotlight_prompt": ( + f"Review {owner_spotlight[0]}'s cleared revisit items first." + if owner_spotlight is not None + else None + ), + "note_gap_priority_lane_status": priority_lane.get("status") or "idle", + "note_gap_priority_lane_count": priority_lane.get("count") or 0, + "note_gap_priority_lane_items": priority_lane.get("items") or [], + "note_gap_priority_lane_story": priority_lane.get("story"), + "note_gap_priority_lane_carry_forward_status": priority_lane_carry_forward.get("status") or "idle", + "note_gap_priority_lane_carry_forward_story": priority_lane_carry_forward.get("story"), + "note_gap_priority_lane_carry_forward_age_seconds": carry_forward_aging.get("age_seconds"), + "note_gap_priority_lane_carry_forward_aging_status": carry_forward_aging.get("status") or "idle", + "note_gap_priority_lane_carry_forward_aging_story": carry_forward_aging.get("story"), + "next_priority_lane_prompt": priority_lane.get("next_prompt"), + "owner_handoff_follow_up_status": owner_handoff_follow_up.get("status") or "idle", + "owner_handoff_follow_up_story": owner_handoff_follow_up.get("story"), + "resolved_owner_drift_status": resolved_owner_drift.get("status") or "idle", + "resolved_owner_drift_story": resolved_owner_drift.get("story"), + "carry_forward_follow_up_lane_status": carry_forward_follow_up_lane.get("status") or "idle", + "carry_forward_follow_up_lane_count": carry_forward_follow_up_lane.get("count") or 0, + "carry_forward_follow_up_lane_items": carry_forward_follow_up_lane.get("items") or [], + "carry_forward_follow_up_lane_story": carry_forward_follow_up_lane.get("story"), + "next_follow_up_lane_prompt": carry_forward_follow_up_lane.get("next_prompt"), + "resolved_owner_drift_review_status": resolved_owner_drift_review.get("status") or "idle", + "resolved_owner_drift_review_story": resolved_owner_drift_review.get("story"), + "resolved_owner_drift_review_queue_status": resolved_owner_drift_review_queue.get("status") or "idle", + "resolved_owner_drift_review_queue_count": resolved_owner_drift_review_queue.get("count") or 0, + "resolved_owner_drift_review_queue_items": resolved_owner_drift_review_queue.get("items") or [], + "resolved_owner_drift_review_queue_story": resolved_owner_drift_review_queue.get("story"), + "resolved_owner_drift_review_queue_aging_status": resolved_owner_drift_review_queue_aging.get("status") or "idle", + "resolved_owner_drift_review_queue_aging_story": resolved_owner_drift_review_queue_aging.get("story"), + "carry_forward_follow_up_lane_rollover_status": carry_forward_follow_up_lane_rollover.get("status") or "idle", + "carry_forward_follow_up_lane_rollover_story": carry_forward_follow_up_lane_rollover.get("story"), + "carry_forward_follow_up_lane_rollover_handoff_status": carry_forward_follow_up_lane_rollover_handoff.get("status") or "idle", + "carry_forward_follow_up_lane_rollover_handoff_story": carry_forward_follow_up_lane_rollover_handoff.get("story"), + "next_follow_up_lane_rollover_prompt": carry_forward_follow_up_lane_rollover.get("next_prompt"), + "next_rollover_handoff_prompt": carry_forward_follow_up_lane_rollover_handoff.get("next_prompt"), } @@ -727,6 +1178,394 @@ def _decision_assistant_importance_template_thresholds(*, persisted_digest_prefe return thresholds +def _surface_history_owner_cadence(items: list[dict], owner: str | None) -> dict: + if not owner: + return {"status": "idle", "average_gap_seconds": None, "story": None} + owner_items = [dict(item) for item in items if str(item.get("revisit_acknowledged_by") or "") == str(owner)] + if len(owner_items) < 2: + return { + "status": "single_sample", + "average_gap_seconds": None, + "story": f"{owner} has fewer than two acknowledged revisit items, so cadence is still a single-sample view.", + } + timestamps = [] + for item in owner_items: + try: + ts = datetime.fromisoformat(str(item.get("revisit_acknowledged_ts")).replace("Z", "+00:00")) + except Exception: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = ts.astimezone(timezone.utc) + timestamps.append(ts) + timestamps.sort(reverse=True) + if len(timestamps) < 2: + return { + "status": "single_sample", + "average_gap_seconds": None, + "story": f"{owner} has insufficient timestamp history for cadence review.", + } + gaps = [max((timestamps[idx] - timestamps[idx + 1]).total_seconds(), 0.0) for idx in range(len(timestamps) - 1)] + avg_gap = sum(gaps) / len(gaps) if gaps else None + if avg_gap is None: + status = "single_sample" + elif avg_gap <= 1800: + status = "dense" + elif avg_gap <= 6 * 3600: + status = "active" + else: + status = "sporadic" + return { + "status": status, + "average_gap_seconds": avg_gap, + "story": f"{owner} revisit cadence is {status}; average acknowledgment gap={format_duration_seconds(avg_gap)}.", + } + + +def _surface_history_note_gap_priority_lane(note_gap_queue: list[dict]) -> dict: + if not note_gap_queue: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Priority lane is clear; there are no unresolved note gaps.", + "next_prompt": None, + } + ordered = sorted( + [dict(item) for item in note_gap_queue], + key=lambda item: (-float(item.get("age_seconds") or 0), str(item.get("title") or item.get("surface_id") or "")), + ) + top = dict(ordered[0] or {}) + return { + "status": "active", + "count": len(ordered), + "items": ordered[:5], + "story": ( + f"Priority lane has {len(ordered)} unresolved note gap(s); " + f"oldest={top.get('title') or top.get('surface_id') or 'n/a'} " + f"({format_duration_seconds(top.get('age_seconds'))} old)." + ), + "next_prompt": ( + f"Work the priority lane starting with {top.get('title') or top.get('surface_id') or 'n/a'} " + f"({format_duration_seconds(top.get('age_seconds'))} old)." + ), + } + + +def _surface_history_owner_trend(acknowledged_by_counts: dict[str, int], owner_spotlight, total_count: int) -> dict: + if not acknowledged_by_counts or total_count <= 0: + return {"status": "idle", "story": None} + if owner_spotlight is None: + return {"status": "idle", "story": None} + top_owner, top_count = owner_spotlight + owner_count = len(acknowledged_by_counts) + if owner_count == 1 and total_count == 1: + status = "single_sample" + elif owner_count == 1: + status = "stable_owner" + elif top_count / max(total_count, 1) >= 0.6: + status = "owner_dominant" + else: + status = "shared_owner" + return { + "status": status, + "story": ( + f"Owner trend={status}; {top_owner} accounts for {top_count}/{total_count} cleared revisit item(s) " + f"across {owner_count} owner(s)." + ), + } + + +def _surface_history_owner_handoff(owner_spotlight, acknowledged_by_counts: dict[str, int], total_count: int) -> dict: + if owner_spotlight is None or total_count <= 0: + return {"status": "idle", "story": None} + owner, count = owner_spotlight + status = "owner_handoff_ready" if count / max(total_count, 1) >= 0.5 else "shared_handoff" + return { + "status": status, + "story": ( + f"Handoff owner={owner}; coverage {count}/{total_count} cleared revisit item(s) " + f"across {len(acknowledged_by_counts)} owner(s)." + ), + } + + +def _surface_history_priority_lane_carry_forward(priority_lane: dict) -> dict: + status = str(priority_lane.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if status != "active" or not items: + return { + "status": "idle", + "story": "Priority lane is clear; nothing needs carry-forward.", + } + top = dict(items[0] or {}) + return { + "status": "carry_forward_ready", + "story": ( + f"Carry forward {top.get('title') or top.get('surface_id') or 'n/a'} as the next unresolved note-gap lane " + f"({format_duration_seconds(top.get('age_seconds'))} old)." + ), + } + + +def _surface_history_priority_lane_carry_forward_aging(priority_lane: dict) -> dict: + status = str(priority_lane.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if status != "active" or not items: + return {"status": "idle", "age_seconds": None, "story": "Carry-forward aging is idle because the priority lane is clear."} + top = dict(items[0] or {}) + age_seconds = float(top.get("age_seconds") or 0) + if age_seconds <= 3600: + aging_status = "recent" + elif age_seconds <= 6 * 3600: + aging_status = "aging" + else: + aging_status = "stale" + return { + "status": aging_status, + "age_seconds": age_seconds, + "story": ( + f"Carry-forward aging is {aging_status}; {top.get('title') or top.get('surface_id') or 'n/a'} " + f"has been in the priority lane for {format_duration_seconds(age_seconds)}." + ), + } + + +def _surface_history_owner_handoff_follow_up(owner_handoff: dict, priority_lane: dict) -> dict: + handoff_status = str(owner_handoff.get("status") or "idle") + lane_status = str(priority_lane.get("status") or "idle") + if handoff_status == "idle": + return {"status": "idle", "story": "Owner handoff follow-up is idle because no owner handoff is active."} + if lane_status == "active": + return { + "status": "follow_up_required", + "story": "Owner handoff should include the current priority-lane note gap before the next pass.", + } + return { + "status": "handoff_clear", + "story": "Owner handoff is clear; there is no active priority-lane note gap to carry forward.", + } + + +def _surface_history_resolved_owner_drift( + items: list[dict], + acknowledged_by_counts: dict[str, int], + owner_spotlight, +) -> dict: + if not items or owner_spotlight is None: + return {"status": "idle", "story": None} + ordered = sorted( + [dict(item) for item in items], + key=lambda item: str(item.get("revisit_acknowledged_ts") or ""), + reverse=True, + ) + if len(ordered) < 2: + return { + "status": "single_sample", + "story": "Resolved-owner drift needs at least two acknowledged revisit items.", + } + baseline_owner, baseline_count = owner_spotlight + recent_window = ordered[: min(3, len(ordered))] + latest_owner = str(recent_window[0].get("revisit_acknowledged_by") or "unknown") + recent_counts: dict[str, int] = {} + for item in recent_window: + owner = str(item.get("revisit_acknowledged_by") or "unknown") + recent_counts[owner] = int(recent_counts.get(owner) or 0) + 1 + recent_spotlight = sorted( + recent_counts.items(), + key=lambda item: (-int(item[1] or 0), str(item[0] or "")), + )[0] + if latest_owner != baseline_owner: + status = "drift_detected" + elif len(recent_counts) > 1: + status = "mixed_recent" + else: + status = "stable" + return { + "status": status, + "story": ( + f"Resolved-owner drift={status}; recent owner={recent_spotlight[0]} " + f"({recent_spotlight[1]}/{len(recent_window)}), latest owner={latest_owner}, baseline={baseline_owner} " + f"({baseline_count}/{len(items)})." + ), + } + + +def _surface_history_carry_forward_follow_up_lane(priority_lane: dict, owner_handoff_follow_up: dict) -> dict: + follow_up_status = str(owner_handoff_follow_up.get("status") or "idle") + items = list(priority_lane.get("items") or []) + if follow_up_status != "follow_up_required" or not items: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Carry-forward follow-up lane is clear.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + return { + "status": "active", + "count": len(items), + "items": items[:3], + "story": ( + f"Carry-forward follow-up lane keeps {len(items)} unresolved note-gap surface(s) visible; " + f"start with {lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + "next_prompt": ( + f"Follow up on carry-forward lane starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + +def _surface_history_resolved_owner_drift_review( + resolved_owner_drift: dict, + owner_handoff_follow_up: dict, + carry_forward_follow_up_lane: dict, +) -> dict: + drift_status = str(resolved_owner_drift.get("status") or "idle") + follow_up_status = str(owner_handoff_follow_up.get("status") or "idle") + lane_status = str(carry_forward_follow_up_lane.get("status") or "idle") + if drift_status in {"idle", "single_sample"}: + return { + "status": drift_status, + "story": "Resolved-owner drift review is idle until more acknowledged history accumulates.", + } + if drift_status == "drift_detected" and lane_status == "active": + return { + "status": "review_required", + "story": "Resolved-owner drift should be reviewed together with the active carry-forward follow-up lane.", + } + if drift_status == "drift_detected": + return { + "status": "review_required", + "story": "Resolved-owner drift changed recently; review the new owner mix before the next pass.", + } + if drift_status == "mixed_recent" or follow_up_status == "follow_up_required": + return { + "status": "review_recommended", + "story": "Resolved-owner drift review is recommended because owner coverage is mixed across recent cleared items.", + } + return { + "status": "review_clear", + "story": "Resolved-owner drift review is clear; recent owner coverage still matches the baseline pattern.", + } + + +def _surface_history_resolved_owner_drift_review_queue( + resolved_owner_drift_review: dict, + carry_forward_follow_up_lane: dict, +) -> dict: + review_status = str(resolved_owner_drift_review.get("status") or "idle") + items = list(carry_forward_follow_up_lane.get("items") or []) + if review_status not in {"review_required", "review_recommended"} or not items: + return { + "status": "idle", + "count": 0, + "items": [], + "story": "Resolved-owner drift review queue is clear.", + } + lead = dict(items[0] or {}) + return { + "status": "active", + "count": len(items), + "items": items[:3], + "story": ( + f"Resolved-owner drift review queue has {len(items)} surface(s); " + f"start with {lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + +def _surface_history_resolved_owner_drift_review_queue_aging(resolved_owner_drift_review_queue: dict) -> dict: + status = str(resolved_owner_drift_review_queue.get("status") or "idle") + items = list(resolved_owner_drift_review_queue.get("items") or []) + if status != "active" or not items: + return { + "status": "idle", + "story": "Drift review queue aging is idle because the review queue is clear.", + } + lead = dict(items[0] or {}) + age_seconds = float(lead.get("age_seconds") or 0) + if age_seconds <= 3600: + aging_status = "recent" + elif age_seconds <= 6 * 3600: + aging_status = "aging" + else: + aging_status = "stale" + return { + "status": aging_status, + "story": ( + f"Drift review queue aging is {aging_status}; " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} has waited {format_duration_seconds(age_seconds)}." + ), + } + + +def _surface_history_carry_forward_follow_up_lane_rollover(carry_forward_follow_up_lane: dict) -> dict: + lane_status = str(carry_forward_follow_up_lane.get("status") or "idle") + items = list(carry_forward_follow_up_lane.get("items") or []) + if lane_status != "active" or not items: + return { + "status": "idle", + "story": "Follow-up lane rollover is idle because there is no active carry-forward lane.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + age_seconds = float(lead.get("age_seconds") or 0) + if age_seconds <= 3600: + status = "same_pass" + elif age_seconds <= 6 * 3600: + status = "rollover_watch" + else: + status = "rollover_required" + return { + "status": status, + "story": ( + f"Follow-up lane rollover is {status}; " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} has carried for {format_duration_seconds(age_seconds)}." + ), + "next_prompt": ( + f"Review follow-up lane rollover starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'} ({format_duration_seconds(age_seconds)} carried)." + ), + } + + +def _surface_history_carry_forward_follow_up_lane_rollover_handoff( + carry_forward_follow_up_lane_rollover: dict, + resolved_owner_drift_review_queue: dict, +) -> dict: + rollover_status = str(carry_forward_follow_up_lane_rollover.get("status") or "idle") + queue_status = str(resolved_owner_drift_review_queue.get("status") or "idle") + items = list(resolved_owner_drift_review_queue.get("items") or []) + if rollover_status == "idle" or queue_status != "active" or not items: + return { + "status": "idle", + "story": "Rollover handoff is idle because there is no active review queue to hand off.", + "next_prompt": None, + } + lead = dict(items[0] or {}) + if rollover_status == "rollover_required": + status = "handoff_required" + elif rollover_status == "rollover_watch": + status = "handoff_watch" + else: + status = "same_pass" + return { + "status": status, + "story": ( + f"Rollover handoff is {status}; " + f"carry {lead.get('title') or lead.get('surface_id') or 'n/a'} into the next review owner pass." + ), + "next_prompt": ( + f"Handoff rollover review starting with " + f"{lead.get('title') or lead.get('surface_id') or 'n/a'}." + ), + } + + def build_decision_assistant_summary( *, instrument_id: int, @@ -761,8 +1600,19 @@ def build_decision_assistant_summary( decision_status = str(decision_packet.get("status") or "unknown") why_blocked = [str(item) for item in list(decision_packet.get("why_blocked") or []) if item] why_allowed = [str(item) for item in list(decision_packet.get("why_allowed") or []) if item] - launch_blockers = [str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item] + strict_launch_blockers = [str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item] launch_warnings = [str(item) for item in list(launch_readiness.get("warning_reasons") or []) if item] + live_analysis_assistant_summary = dict(launch_readiness.get("live_analysis_assistant_summary") or {}) + readonly_live_health_summary = dict( + launch_readiness.get("readonly_live_health_summary") + or live_analysis_assistant_summary.get("health_summary") + or {} + ) + launch_blockers = ( + [str(item) for item in list(live_analysis_assistant_summary.get("blockers") or []) if item] + if live_analysis_assistant_summary.get("ready_for_readonly_live_analysis") + else strict_launch_blockers + ) current_session = str(session_state.get("session_code") or "OFF_HOURS") market_posture = _build_market_posture( current_session=current_session, @@ -784,6 +1634,9 @@ def build_decision_assistant_summary( launch_warnings=launch_warnings, ) no_trade_reasons = [str(item.get("reason") or "") for item in no_trade_reason_items][:6] + decision_support_boundary_summary = dict(launch_readiness.get("decision_support_boundary_summary") or {}) + decision_support_promotion_summary = dict(launch_readiness.get("decision_support_promotion_summary") or {}) + shadow_paper_validation_summary = dict(launch_readiness.get("shadow_paper_validation_summary") or {}) risk_posture = _build_risk_posture( no_trade_reason_items=no_trade_reason_items, decision_status=decision_status, @@ -855,9 +1708,11 @@ def build_decision_assistant_summary( exchange_status=exchange_status, announcements_snapshot=announcements_snapshot, external_chart_snapshot=external_chart_snapshot, + persisted_digest_preference=persisted_digest_preference, ) evidence_chain = _build_decision_assistant_evidence_chain( launch_readiness=launch_readiness, + decision_support_boundary_summary=decision_support_boundary_summary, decision_packet=decision_packet, operator_digest=operator_digest, weekly_review=weekly_review, @@ -870,6 +1725,7 @@ def build_decision_assistant_summary( no_trade_reason_items=no_trade_reason_items, operator_digest=operator_digest, launch_readiness=launch_readiness, + decision_support_boundary_summary=decision_support_boundary_summary, ) reason_citations = _build_decision_assistant_reason_citations( decision_packet=decision_packet, @@ -999,6 +1855,11 @@ def build_decision_assistant_summary( "risk_posture": risk_posture, "attention_reason": attention_reason, "current_session": current_session, + "decision_support_boundary_summary": decision_support_boundary_summary, + "decision_support_promotion_summary": decision_support_promotion_summary, + "live_analysis_assistant_summary": live_analysis_assistant_summary, + "readonly_live_health_summary": readonly_live_health_summary, + "shadow_paper_validation_summary": shadow_paper_validation_summary, "changed_since_last": changed_since_last, "top_priority_items": top_priority_items, "top_opportunities": assistant_top_opportunities, @@ -1052,6 +1913,16 @@ def build_decision_assistant_summary( "stream_status": stream_state.get("code"), "data_gap_status": gap_state.get("code"), "launch_mode": launch_readiness.get("product_operating_mode"), + "live_analysis_assistant_ready": bool(launch_readiness.get("live_analysis_assistant_ready")), + "readonly_live_health_status": readonly_live_health_summary.get("status"), + "readonly_account_context_status": launch_readiness.get("readonly_account_context_status"), + "must_not_trade": bool(live_analysis_assistant_summary.get("must_not_trade", True)), + "boundary_status": decision_support_boundary_summary.get("status"), + "boundary_label": decision_support_boundary_summary.get("label"), + "promotion_status": decision_support_promotion_summary.get("status"), + "next_promotion_gate": decision_support_promotion_summary.get("next_promotion_gate"), + "shadow_paper_status": shadow_paper_validation_summary.get("status"), + "next_shadow_paper_gate": shadow_paper_validation_summary.get("next_validation_gate"), "digest_status": operator_digest.get("status"), "high_value_alert_count": ((operator_digest.get("summary_counts") or {}).get("high_value_alert_count") or 0), }, @@ -2145,6 +3016,7 @@ def _build_decision_assistant_explanation_drilldown( no_trade_reason_items: list[dict], operator_digest: dict, launch_readiness: dict, + decision_support_boundary_summary: dict, ) -> list[dict]: council = dict(decision_packet.get("research_council") or {}) return [ @@ -2174,9 +3046,12 @@ def _build_decision_assistant_explanation_drilldown( { "title": "Launch Boundaries", "status": launch_readiness.get("product_operating_mode") or "unknown", - "detail": ", ".join([str(item) for item in list(launch_readiness.get("blocking_reasons") or [])[:3]]) or "analysis_surface_ready", + "detail": decision_support_boundary_summary.get("operator_summary") + or ", ".join([str(item) for item in list(launch_readiness.get("blocking_reasons") or [])[:3]]) + or "analysis_surface_ready", "reasons": _dedupe( [ + *[str(item) for item in list(decision_support_boundary_summary.get("blocked_operator_actions") or []) if item], *[str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item], *[str(item) for item in list(launch_readiness.get("warning_reasons") or []) if item], ] @@ -2253,6 +3128,7 @@ def _build_decision_assistant_external_context_summary( exchange_status: dict, announcements_snapshot: dict, external_chart_snapshot: dict, + persisted_digest_preference: dict, ) -> dict: market_state = dict(launch_readiness.get("market_state") or {}) news_window = dict(market_state.get("news_window") or market_state.get("news_state") or {}) @@ -2310,6 +3186,7 @@ def _build_decision_assistant_external_context_summary( ) capture_surface_summary = _build_external_context_capture_surface_summary(capture_surface_cards) screenshot_readiness_summary = _build_external_context_screenshot_readiness_summary(capture_surface_cards) + surface_history_summary = _decision_assistant_external_context_surface_history(persisted_digest_preference) chart_adapter_mode = _external_context_chart_adapter_mode(chart_readonly_sources) return { "status": "active" if active_events or external_waiting_items or council_watch_items or operator_questions or next_confirmation_targets or exchange_events or announcements else "idle", @@ -2363,6 +3240,7 @@ def _build_decision_assistant_external_context_summary( "capture_surface_status_counts": _external_context_inventory_status_counts(capture_surface_cards), "capture_surface_summary": capture_surface_summary, "screenshot_readiness_summary": screenshot_readiness_summary, + "surface_selection_history_summary": surface_history_summary, "source_cards": [ { "source_type": "news_provider", @@ -4007,6 +4885,7 @@ def _build_external_context_provider_health_summary(cards: list[dict]) -> dict: "embed_ready_count": 0, "next_health_target": None, "next_health_prompt": None, + "health_story": None, } capture_ready_count = sum(1 for item in cards if item.get("capture_supported")) embed_ready_count = sum(1 for item in cards if item.get("embed_url")) @@ -4023,6 +4902,10 @@ def _build_external_context_provider_health_summary(cards: list[dict]) -> dict: "next_health_target": next_target.get("title"), "next_health_status": next_target.get("status"), "next_health_prompt": next_target.get("next_visual_prompt"), + "health_story": ( + f"Providers live/fallback/degraded={len(cards) - len(fallback) - len(degraded)}/{len(fallback)}/{len(degraded)}; " + f"capture_ready={capture_ready_count}, embed_ready={embed_ready_count}." + ), } @@ -4039,6 +4922,7 @@ def _build_external_context_visual_evidence_bridge_summary( "embed_ready_count": 0, "next_bridge_target": None, "next_bridge_prompt": None, + "bridge_story": None, } capture_ready_count = sum(1 for item in visual_reference_cards if item.get("capture_supported")) embed_ready_count = sum(1 for item in visual_reference_cards if item.get("embed_url")) @@ -4051,6 +4935,11 @@ def _build_external_context_visual_evidence_bridge_summary( "next_bridge_target": next_card.get("title") or next_card.get("provider_name"), "next_bridge_prompt": next_card.get("visual_prompt") or next_card.get("next_visual_prompt"), "next_bridge_url": next_card.get("target_url") or next_card.get("embed_url"), + "bridge_story": ( + f"Visual bridge cards={len(visual_reference_cards)}; " + f"capture_ready={capture_ready_count}, embed_ready={embed_ready_count}; " + f"next={next_card.get('title') or next_card.get('provider_name') or 'n/a'}." + ), } @@ -4146,6 +5035,7 @@ def _build_external_context_capture_surface_summary(cards: list[dict]) -> dict: "embed_ready_count": 0, "next_capture_target": None, "next_capture_prompt": None, + "capture_story": None, } capture_ready_count = sum(1 for item in cards if str(item.get("capture_readiness") or "") in {"capture_and_embed_ready", "capture_ready"}) embed_ready_count = sum(1 for item in cards if bool(item.get("embed_ready"))) @@ -4160,6 +5050,10 @@ def _build_external_context_capture_surface_summary(cards: list[dict]) -> dict: "next_capture_readiness": next_target.get("capture_readiness"), "next_capture_prompt": next_target.get("capture_surface_prompt"), "next_capture_url": next_target.get("target_url") or next_target.get("embed_url"), + "capture_story": ( + f"Capture surfaces={len(cards)}; capture_ready={capture_ready_count}, " + f"embed_ready={embed_ready_count}, bridge_ready={sum(1 for item in cards if str(item.get('evidence_bridge_status') or '') == 'active')}." + ), } @@ -4174,6 +5068,7 @@ def _build_external_context_screenshot_readiness_summary(cards: list[dict]) -> d "embed_ready_count": 0, "next_screenshot_target": None, "next_screenshot_prompt": None, + "screenshot_story": None, } screenshot_ready_count = sum( 1 @@ -4214,6 +5109,9 @@ def _build_external_context_screenshot_readiness_summary(cards: list[dict]) -> d "next_screenshot_status": next_target.get("screenshot_capture_status"), "next_screenshot_prompt": next_target.get("capture_surface_prompt"), "next_screenshot_url": next_target.get("target_url") or next_target.get("embed_url"), + "screenshot_story": ( + f"Screenshot readiness ready/autodiscovery/embed={screenshot_ready_count}/{autodiscovery_needed_count}/{embed_ready_count}." + ), } @@ -5057,6 +5955,7 @@ def _build_decision_assistant_digest_history_archive(rows: list[dict], *, persis items.append( { "snapshot_id": row.get("id"), + "profile_key": row.get("profile_key"), "created_ts": row.get("created_ts"), "digest_mode": row.get("digest_mode"), "preferred_history_kind": row.get("preferred_history_kind"), @@ -5075,6 +5974,7 @@ def _build_decision_assistant_digest_history_archive(rows: list[dict], *, persis latest = dict(items[0] or {}) long_range_summary = build_digest_archive_long_range_summary(items) baseline_summary = build_digest_archive_baseline_summary(items, persisted_snapshot_id=persisted_snapshot_id) + ranked_summary = build_digest_archive_ranked_aggregation(items) return { "status": "active", "snapshot_count": len(rows), @@ -5088,6 +5988,7 @@ def _build_decision_assistant_digest_history_archive(rows: list[dict], *, persis "items": items, "long_range_trend_summary": long_range_summary, "baseline_pin_summary": baseline_summary, + "ranked_aggregation_summary": ranked_summary, } @@ -5107,6 +6008,7 @@ def _build_decision_assistant_workflow_history_archive(rows: list[dict], *, pers items.append( { "snapshot_id": row.get("id"), + "profile_key": row.get("profile_key"), "created_ts": row.get("created_ts"), "current_session": row.get("current_session"), "next_phase": row.get("next_phase"), @@ -5125,6 +6027,7 @@ def _build_decision_assistant_workflow_history_archive(rows: list[dict], *, pers operations_view = build_workflow_archive_operations_view(items) long_range_summary = build_workflow_archive_long_range_summary(items) baseline_summary = build_workflow_archive_baseline_summary(items, persisted_snapshot_id=persisted_snapshot_id) + ranked_summary = build_workflow_archive_ranked_aggregation(items) return { "status": "active", "snapshot_count": len(rows), @@ -5138,6 +6041,7 @@ def _build_decision_assistant_workflow_history_archive(rows: list[dict], *, pers "items": items, "long_range_trend_summary": long_range_summary, "baseline_pin_summary": baseline_summary, + "ranked_aggregation_summary": ranked_summary, **operations_view, } @@ -9181,6 +10085,7 @@ def _build_decision_assistant_timeline( def _build_decision_assistant_evidence_chain( *, launch_readiness: dict, + decision_support_boundary_summary: dict, decision_packet: dict, operator_digest: dict, weekly_review: dict, @@ -9203,8 +10108,14 @@ def _build_decision_assistant_evidence_chain( { "title": "Launch Mode", "status": launch_readiness.get("product_operating_mode") or "unknown", - "detail": ", ".join(blockers[:3]) if blockers else ", ".join(warnings[:3]) or "analysis_surface_ready", - "reason": "blocking_reasons" if blockers else ("warning_reasons" if warnings else "no_launch_blockers"), + "detail": ( + decision_support_boundary_summary.get("label") + or (", ".join(blockers[:3]) if blockers else ", ".join(warnings[:3]) or "analysis_surface_ready") + ), + "reason": ( + decision_support_boundary_summary.get("next_boundary_gate") + or ("blocking_reasons" if blockers else ("warning_reasons" if warnings else "no_launch_blockers")) + ), }, { "title": "Session Context", diff --git a/src/services/readiness/__init__.py b/src/services/readiness/__init__.py index 8a7ad93..577950f 100644 --- a/src/services/readiness/__init__.py +++ b/src/services/readiness/__init__.py @@ -1,3 +1,32 @@ -from src.services.readiness.launch_readiness_service import LaunchReadinessService +"""Readiness service exports. -__all__ = ["LaunchReadinessService"] +The module uses lazy exports because launch readiness imports runtime state, +while runtime also needs the shadow-paper sample collector. +""" + +__all__ = [ + "LaunchReadinessService", + "ShadowPaperObservationBackfillService", + "ShadowPaperSampleCollectionService", + "ShadowPaperValidationService", +] + + +def __getattr__(name: str): + if name == "LaunchReadinessService": + from src.services.readiness.launch_readiness_service import LaunchReadinessService + + return LaunchReadinessService + if name == "ShadowPaperSampleCollectionService": + from src.services.readiness.shadow_paper_sample_collection_service import ShadowPaperSampleCollectionService + + return ShadowPaperSampleCollectionService + if name == "ShadowPaperObservationBackfillService": + from src.services.readiness.shadow_paper_observation_backfill_service import ShadowPaperObservationBackfillService + + return ShadowPaperObservationBackfillService + if name == "ShadowPaperValidationService": + from src.services.readiness.shadow_paper_validation_service import ShadowPaperValidationService + + return ShadowPaperValidationService + raise AttributeError(name) diff --git a/src/services/readiness/launch_readiness_service.py b/src/services/readiness/launch_readiness_service.py index 70a0b14..6445e28 100644 --- a/src/services/readiness/launch_readiness_service.py +++ b/src/services/readiness/launch_readiness_service.py @@ -9,6 +9,7 @@ from src.repositories.trading_journal_repository import default_week_range from src.services.broker import BROKER_STATE_OK, BrokerReadModelService, LiveReadinessService from src.services.journal import WeeklyReviewService from src.services.market_state import MarketStateService, get_market_truth_supervisor +from src.services.readiness.shadow_paper_validation_service import ShadowPaperValidationService from src.services.runtime import get_trading_runtime_service from src.services.trust import ModelTrustService @@ -33,12 +34,14 @@ class LaunchReadinessService: weekly_review_service: Optional[WeeklyReviewService] = None, trust_service: Optional[ModelTrustService] = None, live_readiness_service: Optional[LiveReadinessService] = None, + shadow_paper_validation_service: Optional[ShadowPaperValidationService] = None, ) -> None: self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor()) self.broker_read_model_service = broker_read_model_service or BrokerReadModelService() self.weekly_review_service = weekly_review_service or WeeklyReviewService() self.trust_service = trust_service or ModelTrustService() self.live_readiness_service = live_readiness_service or LiveReadinessService() + self.shadow_paper_validation_service = shadow_paper_validation_service or ShadowPaperValidationService() def build_summary( self, @@ -136,6 +139,18 @@ 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"] + account_context_available = broker_state.status == BROKER_STATE_OK + live_analysis_blockers = [ + item.code + for item in checklist + if not item.passed + and item.severity == "blocker" + and item.code != "broker_state_ready" + ] + live_analysis_warnings = list(warnings) + if not account_context_available: + live_analysis_warnings.append("account_context_unavailable") + live_analysis_assistant_ready = not live_analysis_blockers 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")) @@ -148,15 +163,18 @@ class LaunchReadinessService: restricted_decision_support_ready=restricted_decision_support_ready, decision_support_ready=decision_support_ready, execution_assist_ready=execution_assist_ready, + live_analysis_assistant_ready=live_analysis_assistant_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, + live_analysis_assistant_ready=live_analysis_assistant_ready, ) product_boundary_notes = _build_product_boundary_notes(product_operating_mode=product_operating_mode) operator_soft_rollout_ready = product_operating_mode in { + "readonly_live_analysis", "restricted_decision_support", "decision_support_only", "execution_assist", @@ -168,11 +186,55 @@ class LaunchReadinessService: warnings=warnings, product_operating_mode=product_operating_mode, operator_soft_rollout_ready=operator_soft_rollout_ready, + live_analysis_assistant_ready=live_analysis_assistant_ready, + ) + decision_support_boundary_summary = _build_decision_support_boundary_summary( + 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, + live_analysis_assistant_ready=live_analysis_assistant_ready, + real_live_ready=bool(live_readiness.get("ready_for_real_live")), + product_operating_mode=product_operating_mode, + trust_score=trust_score, + live_readiness=live_readiness, + external_waiting_items=external_waiting_items, + ) + decision_support_promotion_summary = _build_decision_support_promotion_summary( + analysis_only_ready=analysis_only_ready, + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + blockers=blockers, + trust_score=trust_score, + weekly_review=weekly_review, + ) + shadow_paper_validation_summary = self.shadow_paper_validation_service.build_summary( + instrument_id=instrument_id, + timeframe=timeframe, + runtime_status=runtime_status, + weekly_review=weekly_review, + now=now, + ) + live_analysis_assistant_summary = _build_live_analysis_assistant_summary( + ready=live_analysis_assistant_ready, + generated_ts=now.isoformat(), + blockers=live_analysis_blockers, + warnings=live_analysis_warnings, + market_state=market_state.to_dict(), + broker_state=broker_state.to_dict(), + runtime_status=runtime_status, + shadow_paper_validation_summary=shadow_paper_validation_summary, + product_operating_mode=product_operating_mode, ) return { "generated_ts": now.isoformat(), "instrument_id": instrument_id, "timeframe": timeframe, + "live_analysis_assistant_ready": live_analysis_assistant_ready, + "live_analysis_assistant_summary": live_analysis_assistant_summary, + "readonly_live_health_summary": live_analysis_assistant_summary.get("health_summary") or {}, + "readonly_account_context_status": "available" if account_context_available else "unavailable", + "readonly_account_context_available": account_context_available, "analysis_only_ready": analysis_only_ready, "restricted_decision_support_ready": restricted_decision_support_ready, "decision_support_ready": decision_support_ready, @@ -188,6 +250,9 @@ class LaunchReadinessService: "product_operating_mode": product_operating_mode, "product_capabilities": product_capabilities, "product_boundary_notes": product_boundary_notes, + "decision_support_boundary_summary": decision_support_boundary_summary, + "decision_support_promotion_summary": decision_support_promotion_summary, + "shadow_paper_validation_summary": shadow_paper_validation_summary, "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( @@ -229,14 +294,17 @@ def _build_next_actions( warnings: list[str], product_operating_mode: str, operator_soft_rollout_ready: bool, + live_analysis_assistant_ready: bool, ) -> list[str]: actions = [] if "news_provider_usable" in blockers: actions.append("stabilize_news_event_source") - if blockers: + if blockers and not live_analysis_assistant_ready: return actions[:4] or ["resolve_launch_readiness_blockers"] if operator_soft_rollout_ready: - if product_operating_mode == "restricted_decision_support": + if product_operating_mode == "readonly_live_analysis": + actions.append("start_readonly_live_analysis_trial") + elif 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") @@ -272,6 +340,7 @@ def _resolve_product_operating_mode( restricted_decision_support_ready: bool, decision_support_ready: bool, execution_assist_ready: bool, + live_analysis_assistant_ready: bool, ) -> str: if execution_assist_ready: return "execution_assist" @@ -281,6 +350,8 @@ def _resolve_product_operating_mode( return "restricted_decision_support" if analysis_only_ready: return "observe_only" + if live_analysis_assistant_ready: + return "readonly_live_analysis" return "blocked" @@ -290,8 +361,10 @@ def _build_product_capabilities( restricted_decision_support_ready: bool, decision_support_ready: bool, execution_assist_ready: bool, + live_analysis_assistant_ready: bool, ) -> dict: return { + "can_live_analysis_assistant": live_analysis_assistant_ready, "can_analyze": analysis_only_ready, "can_restricted_decision_support": restricted_decision_support_ready, "can_decision_support": decision_support_ready, @@ -311,6 +384,13 @@ def _build_product_boundary_notes(*, product_operating_mode: str) -> list[str]: ] if product_operating_mode == "observe_only": return ["analysis_only_mode_active"] + if product_operating_mode == "readonly_live_analysis": + return [ + "readonly_live_analysis_available", + "not_buy_sell_instruction", + "no_order_submission", + "execution_remains_disabled", + ] return ["runtime_or_broker_blockers_prevent_assisted_operation"] @@ -338,6 +418,14 @@ def _build_operator_soft_rollout_boundary_notes( 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") + elif product_operating_mode == "readonly_live_analysis": + notes.extend( + [ + "operator_can_use_readonly_live_analysis", + "no_order_submission", + "execution_assist_remains_disabled", + ] + ) else: notes.append("operator_soft_rollout_blocked") if "real_trade_sample_verified" in external_waiting_items: @@ -347,4 +435,508 @@ def _build_operator_soft_rollout_boundary_notes( return notes +def _build_decision_support_boundary_summary( + *, + analysis_only_ready: bool, + restricted_decision_support_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, + live_analysis_assistant_ready: bool, + real_live_ready: bool, + product_operating_mode: str, + trust_score: dict, + live_readiness: dict, + external_waiting_items: list[str], +) -> dict: + top_model = dict((trust_score.get("models") or [None])[0] or {}) + current_model_boundary, current_model_reason = _resolve_current_model_boundary( + trust_score=trust_score, + top_model=top_model, + ) + live_execution_boundary, live_execution_reason = _resolve_live_execution_boundary( + real_live_ready=real_live_ready, + live_readiness=live_readiness, + ) + status, label, operator_summary = _resolve_decision_support_boundary_status( + 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, + live_analysis_assistant_ready=live_analysis_assistant_ready, + product_operating_mode=product_operating_mode, + ) + return { + "status": status, + "label": label, + "operator_summary": operator_summary, + "current_model_boundary": current_model_boundary, + "current_model_reason": current_model_reason, + "live_execution_boundary": live_execution_boundary, + "live_execution_reason": live_execution_reason, + "allowed_operator_actions": _build_allowed_operator_actions( + 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, + live_analysis_assistant_ready=live_analysis_assistant_ready, + ), + "blocked_operator_actions": _build_blocked_operator_actions( + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + real_live_ready=real_live_ready, + ), + "next_boundary_gate": _resolve_next_boundary_gate( + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + real_live_ready=real_live_ready, + current_model_boundary=current_model_boundary, + external_waiting_items=external_waiting_items, + live_readiness=live_readiness, + ), + "next_boundary_gate_reason": _resolve_next_boundary_gate_reason( + decision_support_ready=decision_support_ready, + execution_assist_ready=execution_assist_ready, + real_live_ready=real_live_ready, + current_model_reason=current_model_reason, + live_execution_reason=live_execution_reason, + live_readiness=live_readiness, + ), + } + + +def _build_decision_support_promotion_summary( + *, + analysis_only_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, + blockers: list[str], + trust_score: dict, + weekly_review: dict, +) -> dict: + top_model = dict((trust_score.get("models") or [None])[0] or {}) + model_code = str(top_model.get("model_code") or (trust_score.get("decision_support_list") or [None])[0] or "") + classification = str(top_model.get("classification") or ("decision_support" if trust_score.get("decision_support_list") else "unknown")) + sample_size = _safe_int(top_model.get("sample_size"), fallback=int(weekly_review.get("entry_count") or 0)) + operator_review_count = int(weekly_review.get("entry_count") or sample_size or 0) + quality_gate_passed = bool(top_model.get("quality_gate_passed")) if top_model else bool(trust_score.get("decision_support_list")) + expectancy_r = _safe_float(top_model.get("expectancy_r"), fallback=0.0) + framework_execution_rate = _safe_float( + weekly_review.get("framework_execution_rate"), + fallback=_safe_float(top_model.get("framework_execution_rate"), fallback=0.0), + ) + risk_violation_count = int(weekly_review.get("risk_violation_count") or 0) + criteria = [ + { + "code": "runtime_market_broker_ready", + "passed": bool(analysis_only_ready), + "detail": "ready" if analysis_only_ready else ",".join(blockers[:4]) or "launch_blockers_present", + }, + { + "code": "decision_support_trust_candidate", + "passed": bool(trust_score.get("decision_support_list")), + "detail": ",".join([str(item) for item in list(trust_score.get("decision_support_list") or [])[:3]]) or "no_decision_support_candidate", + }, + { + "code": "model_not_blacklist", + "passed": classification != "blacklist", + "detail": classification or "unknown", + }, + { + "code": "backtest_quality_gate_passed", + "passed": bool(quality_gate_passed), + "detail": "passed" if quality_gate_passed else "quality_gate_not_passed", + }, + { + "code": "positive_expectancy", + "passed": expectancy_r > 0, + "detail": f"expectancy_r={expectancy_r:.4f}", + }, + { + "code": "operator_feedback_sample_gate", + "passed": operator_review_count >= 5, + "detail": f"operator_review_samples={operator_review_count}/5", + }, + { + "code": "framework_execution_rate_gate", + "passed": framework_execution_rate >= 0.5, + "detail": f"framework_execution_rate={framework_execution_rate:.2f}", + }, + { + "code": "risk_violation_gate", + "passed": risk_violation_count == 0, + "detail": f"risk_violation_count={risk_violation_count}", + }, + ] + failed = [str(item["code"]) for item in criteria if not item.get("passed")] + status = _resolve_decision_support_promotion_status( + failed_criteria=failed, + analysis_only_ready=analysis_only_ready, + ) + next_gate = failed[0] if failed else "decision_support_ready_can_be_true_without_execution" + return { + "status": status, + "strict_decision_support_ready": not failed, + "current_decision_support_ready": bool(decision_support_ready), + "execution_assist_ready": bool(execution_assist_ready), + "execution_boundary_after_promotion": "execution_still_blocked", + "real_live_boundary_after_promotion": "real_live_still_blocked", + "candidate_model_code": model_code or None, + "candidate_classification": classification, + "candidate_sample_size": sample_size, + "operator_review_sample_count": operator_review_count, + "framework_execution_rate": framework_execution_rate, + "risk_violation_count": risk_violation_count, + "criteria": criteria, + "failed_criteria": failed, + "passed_criteria_count": len(criteria) - len(failed), + "required_criteria_count": len(criteria), + "next_promotion_gate": next_gate, + "operator_summary": _build_decision_support_promotion_operator_summary( + status=status, + failed_criteria=failed, + decision_support_ready=decision_support_ready, + ), + } + + +def _resolve_decision_support_promotion_status( + *, + failed_criteria: list[str], + analysis_only_ready: bool, +) -> str: + if not analysis_only_ready: + return "blocked_by_runtime_market_or_broker" + if not failed_criteria: + return "ready_for_decision_support_non_execution" + if "decision_support_trust_candidate" in failed_criteria or "model_not_blacklist" in failed_criteria: + return "needs_trust_promotion" + if "operator_feedback_sample_gate" in failed_criteria: + return "needs_operator_feedback_samples" + if "risk_violation_gate" in failed_criteria: + return "needs_risk_stabilization" + return "needs_quality_or_framework_evidence" + + +def _build_decision_support_promotion_operator_summary( + *, + status: str, + failed_criteria: list[str], + decision_support_ready: bool, +) -> str: + if status == "ready_for_decision_support_non_execution": + return "Decision support can be marked ready for human review; execution assist and live submit remain blocked." + if decision_support_ready: + return "Decision support is already available, but the strict promotion surface still records non-execution gates." + if failed_criteria: + return f"Decision-support promotion is waiting on {', '.join(failed_criteria[:3])}." + return "Decision-support promotion is waiting for stricter non-execution evidence." + + +def _resolve_decision_support_boundary_status( + *, + analysis_only_ready: bool, + restricted_decision_support_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, + live_analysis_assistant_ready: bool, + product_operating_mode: str, +) -> tuple[str, str, str]: + if execution_assist_ready: + return ( + "execution_assist_available", + "Execution assist available", + "Execution assist is available for human review within the current runtime and trust guards, but live submit still depends on the real adapter and explicit confirmation.", + ) + if decision_support_ready: + return ( + "decision_support_available", + "Decision support available", + "Decision support is available for human review, but execution assist and live submit remain gated.", + ) + if restricted_decision_support_ready: + return ( + "restricted_assisted_analysis_available", + "Restricted assisted analysis available", + "Use the cockpit for live market review, evidence gathering, and no-trade explanations. Do not treat the output as a direct live trade instruction.", + ) + if analysis_only_ready: + return ( + "observe_only_analysis_available", + "Observe-only analysis available", + "The system can monitor and explain current context, but it should only be used for observation until stronger decision-support gates clear.", + ) + if live_analysis_assistant_ready: + return ( + "readonly_live_analysis_available", + "Read-only live analysis available", + "Use this cockpit only for market review, evidence organization, risk prompts, and post-session review. It is not a buy/sell instruction and order submission stays disabled.", + ) + return ( + "analysis_blocked", + "Assisted analysis blocked", + f"Product mode `{product_operating_mode}` is still blocked by runtime or market-truth issues.", + ) + + +def _resolve_current_model_boundary(*, trust_score: dict, top_model: dict) -> tuple[str, str]: + if top_model: + operator_mode = str(top_model.get("operator_mode") or "").strip() + classification = str(top_model.get("classification") or "").strip() or "unknown" + reason = ", ".join([str(item) for item in list(top_model.get("reasons") or [])[:3] if item]) or f"classification={classification}" + if operator_mode == "trade_inside_framework": + return "execution_assist_candidate_available", reason + if operator_mode == "decision_support_only": + return "decision_support_candidate_available", reason + if operator_mode == "observe_only": + return "observe_only_current_model", reason + if trust_score.get("execution_candidate_list"): + return "execution_assist_candidate_available", "execution_candidate_list_present" + if trust_score.get("decision_support_list"): + return "decision_support_candidate_available", "decision_support_list_present" + if trust_score.get("restricted_decision_support_list"): + return "restricted_decision_support_only", "restricted_decision_support_list_present" + if trust_score.get("analysis_only_list"): + return "analysis_only_only", "analysis_only_list_present" + return "trust_summary_missing", "no_trust_candidates_available" + + +def _resolve_live_execution_boundary(*, real_live_ready: bool, live_readiness: dict) -> tuple[str, str]: + blocking_reasons = [str(item) for item in list(live_readiness.get("blocking_reasons") or []) if item] + if real_live_ready: + return "live_submit_ready_for_manual_confirmation", "ready_for_real_live" + if blocking_reasons: + return "live_submit_blocked", ", ".join(blocking_reasons[:3]) + return "live_submit_blocked", "ready_for_real_live_false" + + +def _build_allowed_operator_actions( + *, + analysis_only_ready: bool, + restricted_decision_support_ready: bool, + decision_support_ready: bool, + execution_assist_ready: bool, + live_analysis_assistant_ready: bool, +) -> list[str]: + actions: list[str] = [] + if live_analysis_assistant_ready or analysis_only_ready: + actions.extend( + [ + "review_market_context", + "review_candidates_and_confirmation_gaps", + "review_no_trade_reasons", + "review_archive_and_replay", + "record_shadow_observation_or_operator_note", + ] + ) + if restricted_decision_support_ready: + actions.append("use_restricted_decision_support_for_manual_review") + if decision_support_ready: + actions.append("use_decision_support_for_manual_trade_review") + if execution_assist_ready: + actions.append("prepare_execution_assist_with_human_confirmation") + return actions + + +def _build_blocked_operator_actions( + *, + decision_support_ready: bool, + execution_assist_ready: bool, + real_live_ready: bool, +) -> list[str]: + actions = [ + "treat_output_as_live_trade_instruction", + "enable_default_auto_trading", + ] + if not decision_support_ready: + actions.append("treat_output_as_unrestricted_trade_guidance") + if not execution_assist_ready: + actions.append("skip_operator_confirmation_for_execution") + if not real_live_ready: + actions.append("submit_live_orders") + return actions + + +def _resolve_next_boundary_gate( + *, + decision_support_ready: bool, + execution_assist_ready: bool, + real_live_ready: bool, + current_model_boundary: str, + external_waiting_items: list[str], + live_readiness: dict, +) -> str: + if not decision_support_ready: + if current_model_boundary in {"observe_only_current_model", "restricted_decision_support_only", "analysis_only_only"}: + return "promote_model_out_of_observe_only_or_blacklist" + return "decision_support_model_gate" + if not execution_assist_ready: + return "execution_assist_trust_gate" + if not real_live_ready: + return str((live_readiness.get("blocking_reasons") or ["real_live_path_ready"])[0]) + if external_waiting_items: + return str(external_waiting_items[0]) + return "manual_confirmation_and_runtime_stability" + + +def _resolve_next_boundary_gate_reason( + *, + decision_support_ready: bool, + execution_assist_ready: bool, + real_live_ready: bool, + current_model_reason: str, + live_execution_reason: str, + live_readiness: dict, +) -> str: + if not decision_support_ready: + return current_model_reason + if not execution_assist_ready: + return "execution_candidate_list_missing" + if not real_live_ready: + return live_execution_reason or ", ".join([str(item) for item in list(live_readiness.get("blocking_reasons") or [])[:3] if item]) + return "current_boundary_gates_clear" + + +def _safe_int(value, *, fallback: int = 0) -> int: + try: + return int(value) + except Exception: + return fallback + + +def _safe_float(value, *, fallback: float = 0.0) -> float: + try: + return float(value) + except Exception: + return fallback + + +def _build_live_analysis_assistant_summary( + *, + ready: bool, + generated_ts: str, + blockers: list[str], + warnings: list[str], + market_state: dict, + broker_state: dict, + runtime_status: dict, + shadow_paper_validation_summary: dict, + product_operating_mode: str, +) -> dict: + market_freshness = str(((market_state.get("freshness") or {}).get("code")) or "unknown") + stream_status = str(((market_state.get("stream_state") or {}).get("code")) or "unknown") + gap_status = str(((market_state.get("data_gap_state") or {}).get("code")) or "unknown") + runtime_blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item] + account_available = str(broker_state.get("status") or "") == BROKER_STATE_OK + candidate_count = int(shadow_paper_validation_summary.get("candidate_count") or 0) + recent_samples = dict(shadow_paper_validation_summary.get("recent_samples") or {}) + latest_candidate_ts = _first_timestamp(recent_samples.get("candidates"), "created_ts") + latest_observation_ts = _first_timestamp(recent_samples.get("shadow_observations"), "observed_ts") + status = "ready_for_readonly_live_analysis" if ready else "blocked_for_readonly_live_analysis" + health_status = "usable" if ready else "blocked" + current_blocker = blockers[0] if blockers else "" + recommendation = "can_use_for_live_market_analysis" if ready else "wait_for_market_or_runtime_recovery" + if market_freshness != "market_data_fresh": + recommendation = "wait_for_market_freshness_recovery" + if runtime_blockers: + recommendation = "wait_for_runtime_clearance" + return { + "status": status, + "ready": bool(ready), + "ready_for_readonly_live_analysis": bool(ready), + "must_not_trade": True, + "execution_allowed": False, + "real_live_ready": False, + "product_operating_mode": product_operating_mode, + "operator_summary": _build_live_analysis_operator_summary( + ready=ready, + account_available=account_available, + blockers=blockers, + warnings=warnings, + ), + "launch_boundary": { + "mode_label": "Read-only Live Analysis", + "not_buy_sell_instruction": True, + "no_order_submission": True, + "execution_remains_disabled": True, + "operator_confirmation_required_for_any_future_execution": True, + }, + "health_summary": { + "status": health_status, + "api_health": "available", + "runtime_health": "available" if not runtime_blockers else "blocked", + "market_freshness": market_freshness, + "market_stream": stream_status, + "market_data_gap": gap_status, + "broker_account_context": "available" if account_available else "unavailable", + "latest_decision_packet_timestamp": latest_observation_ts or generated_ts, + "latest_review_packet_timestamp": latest_observation_ts or generated_ts, + "latest_candidate_timestamp": latest_candidate_ts, + "current_candidate_count": candidate_count, + "current_blocker": current_blocker, + "recommendation": recommendation, + "generated_ts": generated_ts, + }, + "allowed_actions": [ + "review_market_context", + "review_candidates_and_confirmation_gaps", + "review_no_trade_reasons", + "record_shadow_observation_or_operator_note", + "prepare_readonly_session_summary", + "prepare_weekly_review_draft", + ], + "blocked_actions": [ + "submit_live_orders", + "cancel_live_orders", + "semi_auto_submit", + "live_execute", + "treat_output_as_live_trade_instruction", + ], + "blockers": list(blockers), + "warnings": _dedupe(warnings), + "operational_closure": { + "daily_shadow_observation_supported": True, + "operator_feedback_supported": True, + "read_only_session_summary_supported": True, + "weekly_review_draft_supported": True, + "paper_ticket_required_for_launch": False, + }, + } + + +def _build_live_analysis_operator_summary( + *, + ready: bool, + account_available: bool, + blockers: list[str], + warnings: list[str], +) -> str: + if ready and account_available: + return "Read-only live analysis is available. No order submission is allowed, and outputs are not buy/sell instructions." + if ready: + return "Read-only live analysis is available with account context unavailable. No order submission is allowed, and outputs are not buy/sell instructions." + if blockers: + return f"Read-only live analysis is blocked by {', '.join(blockers[:3])}." + if warnings: + return f"Read-only live analysis is waiting on warnings: {', '.join(warnings[:3])}." + return "Read-only live analysis is waiting for market or runtime readiness." + + +def _first_timestamp(rows, key: str): + for row in list(rows or []): + if isinstance(row, dict) and row.get(key): + return row.get(key) + return None + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen = set() + for value in values: + item = str(value or "").strip() + if item and item not in seen: + result.append(item) + seen.add(item) + return result + + __all__ = ["LaunchReadinessService", "LaunchReadinessCheck"] diff --git a/src/services/readiness/shadow_paper_observation_backfill_service.py b/src/services/readiness/shadow_paper_observation_backfill_service.py new file mode 100644 index 0000000..21deba5 --- /dev/null +++ b/src/services/readiness/shadow_paper_observation_backfill_service.py @@ -0,0 +1,525 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from datetime import date, datetime, time, timedelta, timezone +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +DEFAULT_WINDOW_DAYS = 14 +DEFAULT_MIN_SHADOW_DAYS = 10 + + +class ShadowPaperObservationBackfillService: + """Audit and backfill shadow observations from real historical candidates only.""" + + def __init__(self, repository: Optional["ShadowPaperObservationBackfillRepository"] = None) -> None: + self.repository = repository or ShadowPaperObservationBackfillRepository() + + def build_backfill_audit( + self, + *, + instrument_id: int, + timeframe: str = "1m", + window_days: int = DEFAULT_WINDOW_DAYS, + apply: bool = False, + created_by: str = "shadow_paper_backfill_audit", + now: Optional[datetime] = None, + ) -> dict: + observed_now = _coerce_utc(now) + window_days = max(int(window_days or DEFAULT_WINDOW_DAYS), 1) + window_start_date = observed_now.date() - timedelta(days=window_days - 1) + since = datetime.combine(window_start_date, time.min, tzinfo=timezone.utc) + candidate_summaries = self.repository.fetch_candidate_day_summaries( + instrument_id=instrument_id, + since=since, + ) + existing_observations = self.repository.fetch_observation_day_summaries( + instrument_id=instrument_id, + timeframe=timeframe, + since=since, + ) + candidate_day_map = _candidate_day_map(candidate_summaries) + observation_day_map = _observation_day_map(existing_observations) + calendar_dates = [window_start_date + timedelta(days=index) for index in range(window_days)] + eligible_days = [ + current_date + for current_date in calendar_dates + if candidate_day_map.get(current_date, {}).get("candidate_count", 0) > 0 + and observation_day_map.get(current_date, {}).get("observation_count", 0) == 0 + ] + + created = [] + if apply: + for current_date in eligible_days: + day_start = datetime.combine(current_date, time.min, tzinfo=timezone.utc) + day_end = day_start + timedelta(days=1) + candidate = self.repository.fetch_latest_candidate_for_day( + instrument_id=instrument_id, + day_start=day_start, + day_end=day_end, + ) + if not candidate: + continue + decision_packet = self.repository.fetch_latest_decision_packet_for_day( + instrument_id=instrument_id, + candidate_id=_optional_int(candidate.get("id")), + day_start=day_start, + day_end=day_end, + ) + observation = self.repository.insert_shadow_observation( + instrument_id=instrument_id, + observation_date=current_date, + timeframe=str(timeframe or "1m"), + session_code=str(candidate.get("session_code") or "ALL"), + candidate=candidate, + decision_packet=decision_packet, + created_by=created_by, + ) + if observation: + created.append(observation) + + observed_shadow_days = len( + { + current_date + for current_date in calendar_dates + if candidate_day_map.get(current_date, {}).get("candidate_count", 0) > 0 + or observation_day_map.get(current_date, {}).get("observation_count", 0) > 0 + } + ) + projected_shadow_days = len( + { + current_date + for current_date in calendar_dates + if candidate_day_map.get(current_date, {}).get("candidate_count", 0) > 0 + or observation_day_map.get(current_date, {}).get("observation_count", 0) > 0 + or current_date in set(eligible_days) + } + ) + daily_coverage = [ + _build_day_payload( + current_date=current_date, + candidate_day_map=candidate_day_map, + observation_day_map=observation_day_map, + eligible_days=set(eligible_days), + ) + for current_date in calendar_dates + ] + return { + "status": "applied" if apply else "dry_run", + "mode": "shadow_paper_observation_backfill", + "generated_ts": observed_now.isoformat(), + "instrument_id": instrument_id, + "timeframe": str(timeframe or "1m"), + "window_days": window_days, + "window_start_date": window_start_date.isoformat(), + "window_end_date": observed_now.date().isoformat(), + "apply": bool(apply), + "execution_boundary": "real_live_still_blocked", + "paper_backfill_allowed": False, + "operator_feedback_backfill_allowed": False, + "eligible_backfill_dates": [item.isoformat() for item in eligible_days], + "eligible_backfill_count": len(eligible_days), + "created_observation_count": len(created), + "created_observations": [_observation_payload(row) for row in created], + "current_observed_shadow_day_count": observed_shadow_days, + "projected_observed_shadow_day_count": projected_shadow_days, + "remaining_shadow_day_gap_after_backfill": max(0, DEFAULT_MIN_SHADOW_DAYS - projected_shadow_days), + "daily_coverage": daily_coverage, + "blocked_backfill_items": [ + "paper_ticket_backfill_not_allowed_without_executable_candidate", + "paper_execution_backfill_not_allowed_without_paper_ticket", + "operator_feedback_requires_human_review", + ], + "operator_summary": _build_operator_summary( + apply=apply, + eligible_count=len(eligible_days), + created_count=len(created), + projected_shadow_days=projected_shadow_days, + ), + } + + +class ShadowPaperObservationBackfillRepository: + def fetch_candidate_day_summaries(self, *, instrument_id: int, since: datetime) -> list[dict]: + sql = text( + """ + select date(created_ts at time zone 'UTC') as day, + status, + count(*) as count + from trade_plan_candidates + where instrument_id = :instrument_id + and created_ts >= :since + group by day, status + order by day, status + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "since": since}).mappings().all() + return [dict(row) for row in rows] + + def fetch_observation_day_summaries(self, *, instrument_id: int, timeframe: str, since: datetime) -> list[dict]: + sql = text( + """ + select observation_date as day, + count(*) as count, + count(*) filter (where coalesce(operator_agreement, 'pending') <> 'pending') as feedback_count + from shadow_paper_observations + where instrument_id = :instrument_id + and timeframe = :timeframe + and observed_ts >= :since + group by observation_date + order by observation_date + """ + ) + with SessionLocal() as session: + rows = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": str(timeframe or "1m"), "since": since}, + ).mappings().all() + return [dict(row) for row in rows] + + def fetch_latest_candidate_for_day(self, *, instrument_id: int, day_start: datetime, day_end: datetime) -> Optional[dict]: + sql = text( + """ + select id, + instrument_id, + model_code, + status, + session_code, + candidate_state, + confirmation_state, + created_ts, + quality_score, + ai_explanation + from trade_plan_candidates + where instrument_id = :instrument_id + and created_ts >= :day_start + and created_ts < :day_end + order by created_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"instrument_id": instrument_id, "day_start": day_start, "day_end": day_end}, + ).mappings().first() + return dict(row) if row else None + + def fetch_latest_decision_packet_for_day( + self, + *, + instrument_id: int, + candidate_id: Optional[int], + day_start: datetime, + day_end: datetime, + ) -> Optional[dict]: + sql = text( + """ + select id, + instrument_id, + candidate_id, + execution_ticket_id, + packet_ts, + status, + action_recommendation, + quality_score, + ai_explanation, + payload + from decision_packets + where instrument_id = :instrument_id + and packet_ts >= :day_start + and packet_ts < :day_end + and (:candidate_id is null or candidate_id = :candidate_id) + order by packet_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + { + "instrument_id": instrument_id, + "candidate_id": candidate_id, + "day_start": day_start, + "day_end": day_end, + }, + ).mappings().first() + return dict(row) if row else None + + def insert_shadow_observation( + self, + *, + instrument_id: int, + observation_date: date, + timeframe: str, + session_code: str, + candidate: dict, + decision_packet: Optional[dict], + created_by: str, + ) -> Optional[dict]: + candidate_id = _optional_int(candidate.get("id")) + decision_packet_id = _optional_int((decision_packet or {}).get("id")) + candidate_status = str(candidate.get("status") or "unknown") + reason_codes = _dedupe( + [ + "shadow_observation_backfill", + f"candidate_status:{candidate_status}", + f"candidate_id:{candidate_id}" if candidate_id is not None else "", + f"decision_packet_id:{decision_packet_id}" if decision_packet_id is not None else "", + ] + ) + payload = { + "mode": "shadow_paper_observation_backfill", + "source": "historical_candidate_day", + "latest_candidate": _candidate_payload(candidate), + "latest_decision_packet": _decision_packet_payload(decision_packet), + "paper_ticket_id": None, + "paper_execution_id": None, + "operator_feedback_recorded": False, + "execution_boundary": "real_live_still_blocked", + } + sql = text( + """ + insert into shadow_paper_observations ( + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + operator_note, + created_by, + reason_codes, + payload, + observed_ts + ) values ( + :instrument_id, + :observation_date, + :timeframe, + :session_code, + :candidate_id, + :decision_packet_id, + null, + null, + 'candidate_observed', + :system_recommendation, + 'pending', + '', + :created_by, + cast(:reason_codes as jsonb), + cast(:payload as jsonb), + :observed_ts + ) + on conflict (instrument_id, observation_date, timeframe, session_code) + do nothing + returning id, + instrument_id, + observation_date, + timeframe, + session_code, + candidate_id, + decision_packet_id, + paper_ticket_id, + paper_execution_id, + observation_status, + system_recommendation, + operator_agreement, + reason_codes, + observed_ts, + created_ts + """ + ) + params = { + "instrument_id": instrument_id, + "observation_date": observation_date, + "timeframe": str(timeframe or "1m"), + "session_code": _normalize_session_code(session_code), + "candidate_id": candidate_id, + "decision_packet_id": decision_packet_id, + "system_recommendation": _resolve_system_recommendation(candidate_status), + "created_by": str(created_by or "shadow_paper_backfill_audit"), + "reason_codes": json.dumps(reason_codes, default=str), + "payload": json.dumps(payload, default=str), + "observed_ts": _coerce_utc(candidate.get("created_ts")), + } + with SessionLocal.begin() as session: + row = session.execute(sql, params).mappings().first() + return dict(row) if row else None + + +def _candidate_day_map(rows: list[dict]) -> dict[date, dict]: + result: dict[date, dict] = {} + for row in rows: + row_date = _coerce_date(row.get("day")) + if row_date is None: + continue + bucket = result.setdefault(row_date, {"candidate_count": 0, "candidate_status_counts": defaultdict(int)}) + count = int(row.get("count") or 0) + status = str(row.get("status") or "unknown") + bucket["candidate_count"] += count + bucket["candidate_status_counts"][status] += count + return result + + +def _observation_day_map(rows: list[dict]) -> dict[date, dict]: + result = {} + for row in rows: + row_date = _coerce_date(row.get("day")) + if row_date is None: + continue + result[row_date] = { + "observation_count": int(row.get("count") or 0), + "observation_feedback_count": int(row.get("feedback_count") or 0), + } + return result + + +def _build_day_payload( + *, + current_date: date, + candidate_day_map: dict[date, dict], + observation_day_map: dict[date, dict], + eligible_days: set[date], +) -> dict: + candidate_bucket = candidate_day_map.get(current_date, {}) + observation_bucket = observation_day_map.get(current_date, {}) + return { + "date": current_date.isoformat(), + "candidate_count": int(candidate_bucket.get("candidate_count") or 0), + "candidate_status_counts": dict(candidate_bucket.get("candidate_status_counts") or {}), + "observation_count": int(observation_bucket.get("observation_count") or 0), + "observation_feedback_count": int(observation_bucket.get("observation_feedback_count") or 0), + "eligible_for_shadow_observation_backfill": current_date in eligible_days, + } + + +def _resolve_system_recommendation(candidate_status: str) -> str: + normalized = str(candidate_status or "").strip().lower() + if normalized in {"executable", "actionable"}: + return "wait_for_operator_confirmation" + if normalized in {"blocked", "rejected", "expired"}: + return "stand_down_blocked_candidate" + return "continue_shadow_tracking" + + +def _candidate_payload(candidate: Optional[dict]) -> dict: + if not candidate: + return {} + return { + "id": candidate.get("id"), + "model_code": candidate.get("model_code"), + "status": candidate.get("status"), + "session_code": candidate.get("session_code"), + "quality_score": candidate.get("quality_score"), + "created_ts": _iso(candidate.get("created_ts")), + } + + +def _decision_packet_payload(packet: Optional[dict]) -> dict: + if not packet: + return {} + return { + "id": packet.get("id"), + "status": packet.get("status"), + "action_recommendation": packet.get("action_recommendation"), + "candidate_id": packet.get("candidate_id"), + "packet_ts": _iso(packet.get("packet_ts")), + } + + +def _observation_payload(row: dict) -> dict: + return { + "id": row.get("id"), + "observation_date": _iso(row.get("observation_date")), + "timeframe": row.get("timeframe"), + "session_code": row.get("session_code"), + "candidate_id": row.get("candidate_id"), + "decision_packet_id": row.get("decision_packet_id"), + "paper_ticket_id": row.get("paper_ticket_id"), + "paper_execution_id": row.get("paper_execution_id"), + "observation_status": row.get("observation_status"), + "system_recommendation": row.get("system_recommendation"), + "operator_agreement": row.get("operator_agreement"), + "reason_codes": row.get("reason_codes"), + "observed_ts": _iso(row.get("observed_ts")), + } + + +def _build_operator_summary(*, apply: bool, eligible_count: int, created_count: int, projected_shadow_days: int) -> str: + if apply: + return f"Applied {created_count}/{eligible_count} historical shadow observations; projected shadow days={projected_shadow_days}." + return f"Dry-run found {eligible_count} historical candidate days eligible for shadow observation backfill; projected shadow days={projected_shadow_days}." + + +def _normalize_session_code(value: str) -> str: + normalized = str(value or "").strip() + return normalized or "ALL" + + +def _coerce_utc(value: Optional[datetime]) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _coerce_date(value) -> Optional[date]: + if value is None: + return None + if isinstance(value, datetime): + return _coerce_utc(value).date() + if isinstance(value, date): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).date() + except ValueError: + return date.fromisoformat(value[:10]) + return None + + +def _optional_int(value) -> Optional[int]: + if value is None or value == "": + return None + return int(value) + + +def _iso(value) -> Optional[str]: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def _dedupe(items: list[str]) -> list[str]: + seen = set() + result = [] + for item in items: + if not item or item in seen: + continue + seen.add(item) + result.append(item) + return result + + +__all__ = [ + "DEFAULT_MIN_SHADOW_DAYS", + "DEFAULT_WINDOW_DAYS", + "ShadowPaperObservationBackfillRepository", + "ShadowPaperObservationBackfillService", +] diff --git a/src/services/readiness/shadow_paper_sample_collection_service.py b/src/services/readiness/shadow_paper_sample_collection_service.py new file mode 100644 index 0000000..50fd9cd --- /dev/null +++ b/src/services/readiness/shadow_paper_sample_collection_service.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from datetime import datetime, time, timezone +from typing import Optional + +from src.repositories.shadow_paper_observation_repository import ShadowPaperObservationRepository + + +OPERATOR_AGREEMENT_PENDING = "pending" +OPERATOR_AGREEMENT_VALUES = {"agree", "disagree", "unclear", OPERATOR_AGREEMENT_PENDING} + + +class ShadowPaperSampleCollectionService: + def __init__(self, repository: Optional[ShadowPaperObservationRepository] = None) -> None: + self.repository = repository or ShadowPaperObservationRepository() + + def record_daily_observation( + self, + *, + instrument_id: int, + timeframe: str = "1m", + session_code: Optional[str] = None, + candidate_id: Optional[int] = None, + decision_packet_id: Optional[int] = None, + paper_ticket_id: Optional[int] = None, + paper_execution_id: Optional[int] = None, + operator_agreement: str = OPERATOR_AGREEMENT_PENDING, + operator_note: str = "", + created_by: str = "runtime_shadow_collector", + runtime_status: Optional[dict] = None, + now: Optional[datetime] = None, + ) -> dict: + now = _coerce_utc(now) + observation_date = now.date() + day_start = datetime.combine(observation_date, time.min, tzinfo=timezone.utc) + runtime_blockers = [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + normalized_agreement = _normalize_operator_agreement(operator_agreement) + latest_candidate = self.repository.fetch_latest_candidate(instrument_id=instrument_id, since=day_start) + latest_packet = self.repository.fetch_latest_decision_packet(instrument_id=instrument_id, since=day_start) + resolved_candidate_id = candidate_id or _optional_int((latest_candidate or {}).get("id")) + resolved_packet_id = decision_packet_id or _optional_int((latest_packet or {}).get("id")) + resolved_session_code = session_code or str((latest_candidate or {}).get("session_code") or "").strip() or "ALL" + observation_status = _resolve_observation_status( + runtime_blockers=runtime_blockers, + candidate=latest_candidate, + paper_ticket_id=paper_ticket_id, + paper_execution_id=paper_execution_id, + operator_agreement=normalized_agreement, + ) + system_recommendation = _resolve_system_recommendation( + runtime_blockers=runtime_blockers, + candidate=latest_candidate, + decision_packet=latest_packet, + ) + reason_codes = _extract_reason_codes( + runtime_blockers=runtime_blockers, + candidate=latest_candidate, + decision_packet=latest_packet, + observation_status=observation_status, + ) + payload = { + "mode": "shadow_paper_sample_collection", + "runtime_blockers": runtime_blockers, + "latest_candidate": _candidate_payload(latest_candidate), + "latest_decision_packet": _packet_payload(latest_packet), + "paper_ticket_id": paper_ticket_id, + "paper_execution_id": paper_execution_id, + "operator_feedback_recorded": normalized_agreement != OPERATOR_AGREEMENT_PENDING, + "execution_boundary": "real_live_still_blocked", + } + observation = self.repository.upsert_daily_observation( + instrument_id=instrument_id, + observation_date=observation_date, + timeframe=str(timeframe or "1m"), + session_code=resolved_session_code, + candidate_id=resolved_candidate_id, + decision_packet_id=resolved_packet_id, + paper_ticket_id=paper_ticket_id, + paper_execution_id=paper_execution_id, + observation_status=observation_status, + system_recommendation=system_recommendation, + operator_agreement=normalized_agreement, + operator_note=str(operator_note or ""), + created_by=str(created_by or "runtime_shadow_collector"), + reason_codes=reason_codes, + payload=payload, + observed_ts=now, + ) + return { + "status": "recorded", + "mode": "shadow_paper_sample_collection", + "instrument_id": instrument_id, + "timeframe": str(timeframe or "1m"), + "observation_id": observation.get("id"), + "observation_date": observation_date.isoformat(), + "observation_status": observation.get("observation_status"), + "system_recommendation": observation.get("system_recommendation"), + "operator_agreement": observation.get("operator_agreement"), + "candidate_id": observation.get("candidate_id"), + "decision_packet_id": observation.get("decision_packet_id"), + "paper_ticket_id": observation.get("paper_ticket_id"), + "paper_execution_id": observation.get("paper_execution_id"), + "reason_codes": list(observation.get("reason_codes") or []), + "execution_boundary": "real_live_still_blocked", + "next_collection_actions": _build_next_collection_actions( + runtime_blockers=runtime_blockers, + candidate_id=observation.get("candidate_id"), + candidate_status=str((latest_candidate or {}).get("status") or ""), + paper_ticket_id=observation.get("paper_ticket_id"), + paper_execution_id=observation.get("paper_execution_id"), + operator_agreement=str(observation.get("operator_agreement") or OPERATOR_AGREEMENT_PENDING), + ), + "observation": _sample_observation(observation), + } + + +def _coerce_utc(value: Optional[datetime]) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _normalize_operator_agreement(value: str) -> str: + normalized = str(value or OPERATOR_AGREEMENT_PENDING).strip().lower() + if normalized not in OPERATOR_AGREEMENT_VALUES: + raise ValueError(f"invalid_operator_agreement:{normalized}") + return normalized + + +def _resolve_observation_status( + *, + runtime_blockers: list[str], + candidate: Optional[dict], + paper_ticket_id: Optional[int], + paper_execution_id: Optional[int], + operator_agreement: str, +) -> str: + if runtime_blockers: + return "runtime_blocked" + if paper_execution_id is not None: + return "paper_execution_recorded" + if paper_ticket_id is not None: + return "paper_ticket_recorded" + if operator_agreement != OPERATOR_AGREEMENT_PENDING: + return "operator_feedback_recorded" + if candidate: + return "candidate_observed" + return "no_candidate_observed" + + +def _resolve_system_recommendation( + *, + runtime_blockers: list[str], + candidate: Optional[dict], + decision_packet: Optional[dict], +) -> str: + if runtime_blockers: + return "pause_runtime_blocked" + action = str((decision_packet or {}).get("action_recommendation") or "").strip().lower() + if action in {"confirm_ticket", "build_execution_ticket"}: + return "wait_for_operator_confirmation" + if action in {"no_trade", "stand_down", "hold"}: + return "stand_down" + if action in {"monitor", "observe"}: + return "continue_observation" + if candidate: + return "continue_shadow_tracking" + return "no_candidate_monitor" + + +def _extract_reason_codes( + *, + runtime_blockers: list[str], + candidate: Optional[dict], + decision_packet: Optional[dict], + observation_status: str, +) -> list[str]: + codes = [observation_status] + codes.extend(runtime_blockers) + payload = dict((decision_packet or {}).get("payload") or {}) + decision = dict(payload.get("decision") or {}) + for value in list(decision.get("reason_codes") or payload.get("reason_codes") or []): + if value: + codes.append(str(value)) + confirmation_state = dict((candidate or {}).get("confirmation_state") or {}) + for value in list(confirmation_state.get("reason_codes") or []): + if value: + codes.append(str(value)) + if candidate and candidate.get("status"): + codes.append(f"candidate_status:{candidate.get('status')}") + if decision_packet and decision_packet.get("status"): + codes.append(f"decision_packet_status:{decision_packet.get('status')}") + return _dedupe(codes) + + +def _build_next_collection_actions( + *, + runtime_blockers: list[str], + candidate_id, + candidate_status: str, + paper_ticket_id, + paper_execution_id, + operator_agreement: str, +) -> list[str]: + if runtime_blockers: + return ["clear_runtime_blockers_before_counting_shadow_day"] + actions = ["keep_daily_shadow_observation_running"] + normalized_status = str(candidate_status or "").strip().lower() + if candidate_id is not None and paper_ticket_id is None and normalized_status in {"executable", "actionable"}: + actions.append("build_shadow_paper_ticket_for_latest_candidate") + elif candidate_id is not None and paper_ticket_id is None: + actions.append("wait_for_executable_candidate_before_paper_ticket") + if paper_ticket_id is not None and paper_execution_id is None: + actions.append("paper_execute_confirmed_ticket") + if operator_agreement == OPERATOR_AGREEMENT_PENDING: + actions.append("record_operator_agreement_or_disagreement") + return actions + + +def _candidate_payload(candidate: Optional[dict]) -> dict: + if not candidate: + return {} + return { + "id": candidate.get("id"), + "model_code": candidate.get("model_code"), + "status": candidate.get("status"), + "session_code": candidate.get("session_code"), + "quality_score": candidate.get("quality_score"), + "created_ts": _iso(candidate.get("created_ts")), + } + + +def _packet_payload(packet: Optional[dict]) -> dict: + if not packet: + return {} + return { + "id": packet.get("id"), + "status": packet.get("status"), + "action_recommendation": packet.get("action_recommendation"), + "candidate_id": packet.get("candidate_id"), + "execution_ticket_id": packet.get("execution_ticket_id"), + "packet_ts": _iso(packet.get("packet_ts")), + } + + +def _sample_observation(row: dict) -> dict: + return { + "id": row.get("id"), + "observation_date": _iso(row.get("observation_date")), + "timeframe": row.get("timeframe"), + "session_code": row.get("session_code"), + "observation_status": row.get("observation_status"), + "system_recommendation": row.get("system_recommendation"), + "operator_agreement": row.get("operator_agreement"), + "candidate_id": row.get("candidate_id"), + "paper_ticket_id": row.get("paper_ticket_id"), + "paper_execution_id": row.get("paper_execution_id"), + "observed_ts": _iso(row.get("observed_ts")), + } + + +def _optional_int(value) -> Optional[int]: + if value in (None, ""): + return None + return int(value) + + +def _iso(value) -> Optional[str]: + if value is None: + return None + if isinstance(value, (datetime,)): + return value.isoformat() + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def _dedupe(items: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + result.append(item) + return result + + +__all__ = ["ShadowPaperSampleCollectionService"] diff --git a/src/services/readiness/shadow_paper_validation_service.py b/src/services/readiness/shadow_paper_validation_service.py new file mode 100644 index 0000000..fb49576 --- /dev/null +++ b/src/services/readiness/shadow_paper_validation_service.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime, timedelta, timezone +from typing import Optional + +from src.repositories.shadow_paper_validation_repository import ShadowPaperValidationRepository + + +DEFAULT_WINDOW_DAYS = 14 +DEFAULT_MIN_SHADOW_DAYS = 10 +DEFAULT_MIN_CANDIDATES = 10 +DEFAULT_MIN_PAPER_SAMPLES = 5 +DEFAULT_MIN_OPERATOR_FEEDBACK = 5 + + +class ShadowPaperValidationService: + def __init__(self, repository: Optional[ShadowPaperValidationRepository] = None) -> None: + self.repository = repository or ShadowPaperValidationRepository() + + def build_summary( + self, + *, + instrument_id: int, + timeframe: str = "1m", + runtime_status: Optional[dict] = None, + weekly_review: Optional[dict] = None, + now: Optional[datetime] = None, + window_days: int = DEFAULT_WINDOW_DAYS, + ) -> dict: + now = now or datetime.now(timezone.utc) + window_days = max(int(window_days or DEFAULT_WINDOW_DAYS), 1) + window_start_date = now.date() - timedelta(days=window_days - 1) + since = datetime.combine(window_start_date, datetime.min.time(), tzinfo=timezone.utc) + try: + candidates = self.repository.fetch_recent_candidates(instrument_id=instrument_id, since=since) + tickets = self.repository.fetch_recent_tickets(instrument_id=instrument_id, since=since) + executions = self.repository.fetch_recent_executions(instrument_id=instrument_id, since=since) + journal_entries = self.repository.fetch_recent_journal_entries(instrument_id=instrument_id, since=since) + feedback_rows = self.repository.fetch_recent_operator_feedback(instrument_id=instrument_id, since=since) + observations = ( + self.repository.fetch_recent_observations(instrument_id=instrument_id, since=since) + if hasattr(self.repository, "fetch_recent_observations") + else [] + ) + except Exception as exc: # pragma: no cover - protects live readiness from repository drift + return _unavailable_summary( + instrument_id=instrument_id, + timeframe=timeframe, + now=now, + window_days=window_days, + error=str(exc), + ) + + runtime_blockers = [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + weekly_review = dict(weekly_review or {}) + paper_tickets = [row for row in tickets if _is_paper_mode(row.get("execution_mode"))] + paper_executions = [row for row in executions if _is_paper_mode(row.get("execution_mode"))] + feedback_observations = [row for row in observations if _has_operator_feedback(row)] + operator_feedback_count = len(feedback_rows) + len(feedback_observations) + daily_coverage = _build_daily_coverage( + start_date=window_start_date, + window_days=window_days, + candidates=candidates, + paper_tickets=paper_tickets, + paper_executions=paper_executions, + journal_entries=journal_entries, + feedback_rows=feedback_rows, + observations=observations, + ) + observed_shadow_day_count = sum( + 1 + for item in daily_coverage + if item["candidate_count"] > 0 or item["shadow_observation_count"] > 0 + ) + shadow_observation_day_count = sum(1 for item in daily_coverage if item["shadow_observation_count"] > 0) + paper_sample_day_count = sum( + 1 + for item in daily_coverage + if item["paper_ticket_count"] > 0 or item["paper_execution_count"] > 0 + ) + feedback_day_count = sum(1 for item in daily_coverage if item["operator_feedback_count"] > 0) + risk_violation_count = int( + weekly_review.get("risk_violation_count") + if weekly_review.get("risk_violation_count") is not None + else sum(1 for row in journal_entries if bool(row.get("risk_violation"))) + ) + weekly_review_entry_count = int(weekly_review.get("entry_count") or len(journal_entries)) + criteria = _build_validation_criteria( + runtime_blockers=runtime_blockers, + observed_shadow_day_count=observed_shadow_day_count, + candidate_count=len(candidates), + paper_ticket_count=len(paper_tickets), + paper_execution_count=len(paper_executions), + operator_feedback_count=operator_feedback_count, + weekly_review_entry_count=weekly_review_entry_count, + risk_violation_count=risk_violation_count, + ) + failed_criteria = [item["code"] for item in criteria if not item["passed"]] + status = _resolve_validation_status(failed_criteria) + return { + "status": status, + "mode": "shadow_paper_validation", + "generated_ts": now.isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "window_days": window_days, + "window_start_date": window_start_date.isoformat(), + "window_end_date": now.date().isoformat(), + "runtime_blocker_count": len(runtime_blockers), + "runtime_blockers": runtime_blockers, + "candidate_count": len(candidates), + "observed_shadow_day_count": observed_shadow_day_count, + "shadow_observation_count": len(observations), + "shadow_observation_day_count": shadow_observation_day_count, + "paper_sample_day_count": paper_sample_day_count, + "paper_ticket_count": len(paper_tickets), + "paper_execution_count": len(paper_executions), + "journal_entry_count": len(journal_entries), + "weekly_review_entry_count": weekly_review_entry_count, + "operator_feedback_count": operator_feedback_count, + "operator_alert_feedback_count": len(feedback_rows), + "operator_observation_feedback_count": len(feedback_observations), + "operator_feedback_day_count": feedback_day_count, + "framework_execution_rate": weekly_review.get("framework_execution_rate"), + "risk_violation_count": risk_violation_count, + "criteria": criteria, + "failed_criteria": failed_criteria, + "passed_criteria_count": len(criteria) - len(failed_criteria), + "required_criteria_count": len(criteria), + "next_validation_gate": failed_criteria[0] if failed_criteria else "shadow_paper_review_ready", + "sample_collection_plan": _build_sample_collection_plan( + failed_criteria=failed_criteria, + observed_shadow_day_count=observed_shadow_day_count, + paper_ticket_count=len(paper_tickets), + paper_execution_count=len(paper_executions), + operator_feedback_count=operator_feedback_count, + ), + "execution_boundary": "real_live_still_blocked", + "operator_summary": _build_operator_summary( + status=status, + candidate_count=len(candidates), + observed_shadow_day_count=observed_shadow_day_count, + shadow_observation_count=len(observations), + paper_ticket_count=len(paper_tickets), + paper_execution_count=len(paper_executions), + operator_feedback_count=operator_feedback_count, + failed_criteria=failed_criteria, + ), + "daily_coverage": daily_coverage, + "recent_samples": { + "shadow_observations": [_sample_observation(row) for row in observations[:5]], + "candidates": [_sample_candidate(row) for row in candidates[:5]], + "paper_tickets": [_sample_ticket(row) for row in paper_tickets[:5]], + "paper_executions": [_sample_execution(row) for row in paper_executions[:5]], + "operator_feedback": [_sample_feedback(row) for row in feedback_rows[:5]], + }, + } + + +def _build_validation_criteria( + *, + runtime_blockers: list[str], + observed_shadow_day_count: int, + candidate_count: int, + paper_ticket_count: int, + paper_execution_count: int, + operator_feedback_count: int, + weekly_review_entry_count: int, + risk_violation_count: int, +) -> list[dict]: + return [ + { + "code": "runtime_clear_for_shadow", + "passed": not runtime_blockers, + "detail": f"runtime_blockers={runtime_blockers}", + }, + { + "code": "shadow_candidate_day_coverage", + "passed": observed_shadow_day_count >= DEFAULT_MIN_SHADOW_DAYS, + "detail": f"observed_shadow_days={observed_shadow_day_count}/{DEFAULT_MIN_SHADOW_DAYS}", + }, + { + "code": "shadow_candidate_sample_gate", + "passed": candidate_count >= DEFAULT_MIN_CANDIDATES, + "detail": f"candidates={candidate_count}/{DEFAULT_MIN_CANDIDATES}", + }, + { + "code": "paper_ticket_sample_gate", + "passed": paper_ticket_count >= DEFAULT_MIN_PAPER_SAMPLES, + "detail": f"paper_tickets={paper_ticket_count}/{DEFAULT_MIN_PAPER_SAMPLES}", + }, + { + "code": "paper_execution_sample_gate", + "passed": paper_execution_count >= DEFAULT_MIN_PAPER_SAMPLES, + "detail": f"paper_executions={paper_execution_count}/{DEFAULT_MIN_PAPER_SAMPLES}", + }, + { + "code": "operator_feedback_sample_gate", + "passed": operator_feedback_count >= DEFAULT_MIN_OPERATOR_FEEDBACK, + "detail": f"operator_feedback={operator_feedback_count}/{DEFAULT_MIN_OPERATOR_FEEDBACK}", + }, + { + "code": "weekly_review_visible", + "passed": weekly_review_entry_count > 0, + "detail": f"weekly_review_entries={weekly_review_entry_count}", + }, + { + "code": "risk_violation_stable", + "passed": risk_violation_count == 0, + "detail": f"risk_violation_count={risk_violation_count}", + }, + ] + + +def _resolve_validation_status(failed_criteria: list[str]) -> str: + failed = set(failed_criteria) + if "runtime_clear_for_shadow" in failed: + return "blocked_by_runtime" + if "shadow_candidate_day_coverage" in failed or "shadow_candidate_sample_gate" in failed: + return "collecting_shadow_candidates" + if "paper_ticket_sample_gate" in failed or "paper_execution_sample_gate" in failed: + return "collecting_paper_samples" + if "operator_feedback_sample_gate" in failed: + return "collecting_operator_feedback" + if "weekly_review_visible" in failed: + return "waiting_for_weekly_review" + if "risk_violation_stable" in failed: + return "needs_risk_stabilization" + return "ready_for_shadow_paper_review" + + +def _build_daily_coverage( + *, + start_date: date, + window_days: int, + candidates: list[dict], + paper_tickets: list[dict], + paper_executions: list[dict], + journal_entries: list[dict], + feedback_rows: list[dict], + observations: list[dict], +) -> list[dict]: + buckets = { + start_date + timedelta(days=index): defaultdict(int) + for index in range(window_days) + } + for row in candidates: + _increment_bucket(buckets, _row_date(row.get("created_ts")), "candidate_count") + for row in paper_tickets: + _increment_bucket(buckets, _row_date(row.get("created_ts")), "paper_ticket_count") + for row in paper_executions: + _increment_bucket(buckets, _row_date(row.get("opened_ts") or row.get("closed_ts")), "paper_execution_count") + for row in journal_entries: + _increment_bucket(buckets, _row_date(row.get("journal_date") or row.get("journal_ts")), "journal_entry_count") + for row in feedback_rows: + _increment_bucket(buckets, _row_date(row.get("created_ts")), "operator_feedback_count") + for row in observations: + row_date = _row_date(row.get("observation_date") or row.get("observed_ts")) + if str(row.get("observation_status") or "") != "runtime_blocked": + _increment_bucket(buckets, row_date, "shadow_observation_count") + if _has_operator_feedback(row): + _increment_bucket(buckets, row_date, "operator_feedback_count") + return [ + { + "date": day.isoformat(), + "shadow_observation_count": int(values.get("shadow_observation_count", 0)), + "candidate_count": int(values.get("candidate_count", 0)), + "paper_ticket_count": int(values.get("paper_ticket_count", 0)), + "paper_execution_count": int(values.get("paper_execution_count", 0)), + "journal_entry_count": int(values.get("journal_entry_count", 0)), + "operator_feedback_count": int(values.get("operator_feedback_count", 0)), + } + for day, values in sorted(buckets.items()) + ] + + +def _increment_bucket(buckets: dict[date, defaultdict], row_date: Optional[date], key: str) -> None: + if row_date in buckets: + buckets[row_date][key] += 1 + + +def _row_date(value) -> Optional[date]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).date() + if isinstance(value, date): + return value + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).date() + except ValueError: + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + return None + + +def _is_paper_mode(value) -> bool: + return str(value or "").lower() == "paper" + + +def _has_operator_feedback(row: dict) -> bool: + return str(row.get("operator_agreement") or "pending").strip().lower() not in {"", "pending"} + + +def _dedupe(items: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _build_operator_summary( + *, + status: str, + candidate_count: int, + observed_shadow_day_count: int, + shadow_observation_count: int, + paper_ticket_count: int, + paper_execution_count: int, + operator_feedback_count: int, + failed_criteria: list[str], +) -> str: + if status == "ready_for_shadow_paper_review": + return "Shadow/paper validation has enough candidate, paper, feedback, and review samples for operator review." + next_gate = failed_criteria[0] if failed_criteria else "shadow_paper_review_ready" + return ( + f"Shadow/paper validation is {status}; next gate={next_gate}; " + f"candidates={candidate_count}, shadow_days={observed_shadow_day_count}, " + f"observations={shadow_observation_count}, " + f"paper_tickets={paper_ticket_count}, paper_executions={paper_execution_count}, " + f"operator_feedback={operator_feedback_count}." + ) + + +def _build_sample_collection_plan( + *, + failed_criteria: list[str], + observed_shadow_day_count: int, + paper_ticket_count: int, + paper_execution_count: int, + operator_feedback_count: int, +) -> dict: + next_gate = failed_criteria[0] if failed_criteria else "shadow_paper_review_ready" + actions: list[str] = [] + if "runtime_clear_for_shadow" in failed_criteria: + actions.append("clear_runtime_blockers_before_counting_shadow_samples") + if "shadow_candidate_day_coverage" in failed_criteria: + actions.extend( + [ + "record_shadow_observation_daily", + "keep_runtime_shadow_paper_observation_task_running", + ] + ) + if "paper_ticket_sample_gate" in failed_criteria: + actions.append("build_shadow_paper_ticket_for_executable_candidate") + if "paper_execution_sample_gate" in failed_criteria: + actions.append("confirm_and_paper_execute_shadow_ticket") + if "operator_feedback_sample_gate" in failed_criteria: + actions.append("record_operator_agreement_or_disagreement") + if not actions: + actions.append("prepare_shadow_paper_weekly_review") + return { + "status": "collecting" if failed_criteria else "ready_for_review", + "next_gate": next_gate, + "shadow_days": observed_shadow_day_count, + "paper_tickets": paper_ticket_count, + "paper_executions": paper_execution_count, + "operator_feedback": operator_feedback_count, + "next_actions": _dedupe(actions), + } + + +def _sample_candidate(row: dict) -> dict: + return { + "id": row.get("id"), + "model_code": row.get("model_code"), + "status": row.get("status"), + "session_code": row.get("session_code"), + "created_ts": _iso(row.get("created_ts")), + } + + +def _sample_ticket(row: dict) -> dict: + return { + "id": row.get("id"), + "signal_id": row.get("signal_id"), + "status": row.get("status"), + "created_ts": _iso(row.get("created_ts")), + } + + +def _sample_execution(row: dict) -> dict: + return { + "id": row.get("id"), + "signal_id": row.get("signal_id"), + "status": row.get("status"), + "opened_ts": _iso(row.get("opened_ts")), + "closed_ts": _iso(row.get("closed_ts")), + } + + +def _sample_feedback(row: dict) -> dict: + return { + "id": row.get("id"), + "subject_type": row.get("subject_type"), + "label": row.get("label"), + "created_by": row.get("created_by"), + "created_ts": _iso(row.get("created_ts")), + } + + +def _sample_observation(row: dict) -> dict: + return { + "id": row.get("id"), + "observation_date": _iso(row.get("observation_date")), + "timeframe": row.get("timeframe"), + "session_code": row.get("session_code"), + "observation_status": row.get("observation_status"), + "system_recommendation": row.get("system_recommendation"), + "operator_agreement": row.get("operator_agreement"), + "candidate_id": row.get("candidate_id"), + "decision_packet_id": row.get("decision_packet_id"), + "paper_ticket_id": row.get("paper_ticket_id"), + "paper_execution_id": row.get("paper_execution_id"), + "observed_ts": _iso(row.get("observed_ts")), + } + + +def _iso(value) -> Optional[str]: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if value is None: + return None + return str(value) + + +def _unavailable_summary( + *, + instrument_id: int, + timeframe: str, + now: datetime, + window_days: int, + error: str, +) -> dict: + return { + "status": "unavailable", + "mode": "shadow_paper_validation", + "generated_ts": now.isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "window_days": window_days, + "runtime_blocker_count": 0, + "candidate_count": 0, + "observed_shadow_day_count": 0, + "shadow_observation_count": 0, + "shadow_observation_day_count": 0, + "paper_sample_day_count": 0, + "paper_ticket_count": 0, + "paper_execution_count": 0, + "journal_entry_count": 0, + "weekly_review_entry_count": 0, + "operator_feedback_count": 0, + "operator_alert_feedback_count": 0, + "operator_observation_feedback_count": 0, + "operator_feedback_day_count": 0, + "criteria": [], + "failed_criteria": ["shadow_paper_repository_unavailable"], + "passed_criteria_count": 0, + "required_criteria_count": 0, + "next_validation_gate": "shadow_paper_repository_unavailable", + "execution_boundary": "real_live_still_blocked", + "operator_summary": f"Shadow/paper validation ledger is unavailable: {error}", + "sample_collection_plan": { + "status": "unavailable", + "next_gate": "shadow_paper_repository_unavailable", + "next_actions": ["restore_shadow_paper_repository"], + }, + "daily_coverage": [], + "recent_samples": { + "shadow_observations": [], + "candidates": [], + "paper_tickets": [], + "paper_executions": [], + "operator_feedback": [], + }, + "error": error, + } diff --git a/src/services/runtime/trading_runtime_service.py b/src/services/runtime/trading_runtime_service.py index 1db8fdd..8946239 100644 --- a/src/services/runtime/trading_runtime_service.py +++ b/src/services/runtime/trading_runtime_service.py @@ -12,6 +12,7 @@ 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.readiness.shadow_paper_sample_collection_service import ShadowPaperSampleCollectionService from src.services.research_council import ResearchCouncilService from src.services.runtime.runtime_incident_journal_service import RuntimeIncidentJournalService from src.services.runtime.runtime_task_watchdog_service import RuntimeTaskWatchdogService @@ -66,6 +67,7 @@ class TradingRuntimeScheduler: research_council_service: Optional[ResearchCouncilService] = None, visual_review_service: Optional[VisualReviewService] = None, visual_capture_retention_service: Optional[VisualCaptureRetentionService] = None, + shadow_paper_sample_service: Optional[ShadowPaperSampleCollectionService] = None, runtime_task_watchdog_service: Optional[RuntimeTaskWatchdogService] = None, runtime_incident_journal_service: Optional[RuntimeIncidentJournalService] = None, ) -> None: @@ -81,6 +83,7 @@ class TradingRuntimeScheduler: self.research_council_service = research_council_service or ResearchCouncilService() self.visual_review_service = visual_review_service or VisualReviewService() self.visual_capture_retention_service = visual_capture_retention_service or VisualCaptureRetentionService() + self.shadow_paper_sample_service = shadow_paper_sample_service or ShadowPaperSampleCollectionService() self.runtime_task_watchdog_service = runtime_task_watchdog_service or RuntimeTaskWatchdogService( stale_multiplier=settings.runtime_task_stale_multiplier, min_stuck_seconds=settings.runtime_task_stuck_min_seconds, @@ -97,6 +100,7 @@ class TradingRuntimeScheduler: "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), + "shadow_paper_observation": RuntimeTaskState("shadow_paper_observation", settings.trading_runtime_decision_packet_interval_seconds), "visual_watch": RuntimeTaskState("visual_watch", settings.trading_runtime_decision_packet_interval_seconds), "visual_capture_cleanup": RuntimeTaskState("visual_capture_cleanup", settings.visual_capture_cleanup_interval_seconds), "alert_dispatch": RuntimeTaskState("alert_dispatch", settings.alert_dispatch_interval_seconds), @@ -172,6 +176,13 @@ class TradingRuntimeScheduler: warnings=warnings, ) self._run_decision_packet_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) + self._run_shadow_paper_observation_task( + symbol=symbol, + instrument_id=instrument_id, + now=now, + blockers=blockers, + warnings=warnings, + ) self._run_visual_watch_task(symbol=symbol, instrument_id=instrument_id, now=now, warnings=warnings) self._run_visual_capture_cleanup_task(now=now, warnings=warnings) self._run_alert_dispatch_task(now=now, blockers=blockers, warnings=warnings) @@ -459,6 +470,45 @@ class TradingRuntimeScheduler: blockers.append(code) self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now) + def _run_shadow_paper_observation_task( + self, + *, + symbol: str, + instrument_id: int, + now: datetime, + blockers: list[str], + warnings: list[str], + ) -> None: + task = self.tasks["shadow_paper_observation"] + if not _task_due(task, now): + return + _task_start(task, now) + try: + payload = self.shadow_paper_sample_service.record_daily_observation( + instrument_id=instrument_id, + timeframe=self.trigger_tf, + created_by="runtime_shadow_collector", + runtime_status={"current_blockers": list(blockers)}, + now=now, + ) + task.details = { + "symbol": symbol, + "instrument_id": instrument_id, + "status": payload.get("status"), + "observation_id": payload.get("observation_id"), + "observation_date": payload.get("observation_date"), + "observation_status": payload.get("observation_status"), + "system_recommendation": payload.get("system_recommendation"), + "operator_agreement": payload.get("operator_agreement"), + "next_collection_actions": list(payload.get("next_collection_actions") or []), + } + _task_success(task, now) + except Exception as exc: + _task_failure(task, now, str(exc)) + code = f"shadow_paper_observation_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_research_council_task( self, *, diff --git a/src/web/app.js b/src/web/app.js index 9eefe59..c7a48a3 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -76,6 +76,9 @@ let assistantWorkflowArchiveState = { pinned_baseline_id: null, }; let assistantWorkflowArchiveBridgeContext = null; +let assistantDigestArchiveLastExport = null; +let assistantWorkflowArchiveLastExport = null; +let assistantSurfaceResolutionFilter = 'all'; async function fetchJson(url, options = {}) { const fetchOptions = { ...options }; @@ -286,6 +289,22 @@ async function persistWorkflowArchiveBridgeStateServer({ summary, exportPayload, return saved; } +async function persistWorkflowArchiveCompareWritebackServer({ profileKey, compare, author = 'local_operator' }) { + return postJson('/trading/workflow-archive/compare-writeback', { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: profileKey || assistantDigestProfileKey || 'local_operator', + profile_scope: assistantWorkflowArchiveState.profile_scope || 'selected_profile', + query_preset: assistantWorkflowArchiveState.query_preset || '', + left_id: compare?.left?.snapshot_id ?? assistantWorkflowArchiveState.selected_left_id ?? null, + right_id: compare?.right?.snapshot_id ?? assistantWorkflowArchiveState.selected_right_id ?? null, + current_session: assistantWorkflowArchiveState.current_session || compare?.left?.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || compare?.left?.next_phase || '', + query_text: assistantWorkflowArchiveState.query_text || '', + author, + note_scope: 'workflow_compare', + }); +} + function loadAssistantScanHandoffMarker() { try { const raw = window.localStorage?.getItem('assistantScanHandoffMarker'); @@ -912,11 +931,91 @@ async function runAction(url, body = {}) { } } +async function recordShadowPaperObservation(operatorAgreement = 'pending') { + await runAction('/trading/shadow-paper/observe', { + instrument_id: Number(instrumentIdInput.value || 1), + timeframe: '1m', + session_code: sessionFilterInput.value === 'all' ? null : sessionFilterInput.value, + operator_agreement: operatorAgreement, + operator_note: operatorAgreement === 'pending' + ? 'Daily shadow observation captured from cockpit.' + : `Operator marked shadow observation as ${operatorAgreement}.`, + created_by: 'local_operator', + }); +} + function openExternalContextLink(url) { if (!url) return; window.open(url, '_blank', 'noopener,noreferrer'); } +async function persistExternalContextSurfaceHistory(meta = {}) { + try { + return await postJson('/trading/external-context-surface-history', { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantDigestProfileKey || 'local_operator', + surface_id: meta.surface_id || meta.title || 'external_surface', + title: meta.title || meta.surface_id || 'external surface', + source_group: meta.source_group || 'external_context', + selection_kind: meta.selection_kind || 'target_url', + target_url: meta.target_url || '', + prompt: meta.prompt || '', + }); + } catch { + return null; + } +} + +async function acknowledgeExternalContextSurfaceHistory(meta = {}) { + try { + return await postJson('/trading/external-context-surface-history/acknowledge', { + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantDigestProfileKey || 'local_operator', + surface_id: meta.surface_id || '', + selected_ts: meta.selected_ts || '', + acknowledged_by: meta.acknowledged_by || 'local_operator', + note: meta.note || '', + }); + } catch { + return null; + } +} + +function matchesSurfaceResolutionFilter(item, filterId) { + const normalized = String(filterId || 'all'); + if (normalized === 'all') return true; + if (normalized === 'with_note') return Boolean(String(item?.revisit_ack_note || '').trim()); + if (normalized === 'without_note') return !String(item?.revisit_ack_note || '').trim(); + if (normalized.startsWith('group:')) return String(item?.source_group || '') === normalized.slice(6); + if (normalized.startsWith('ack:')) return String(item?.revisit_acknowledged_by || '') === normalized.slice(4); + return true; +} + +function getDefaultSurfaceResolutionFilterOptions(summary = {}) { + const entryCount = Number(summary?.resolution_history_count ?? 0); + const noteCount = Number(summary?.resolution_filter_summary?.note_count ?? 0); + const noteGapCount = Number(summary?.resolution_filter_summary?.note_gap_count ?? 0); + return [ + { filter_id: 'all', label: `All Resolved (${entryCount})`, count: entryCount }, + { filter_id: 'with_note', label: `With Resolution Note (${noteCount})`, count: noteCount }, + { filter_id: 'without_note', label: `Without Resolution Note (${noteGapCount})`, count: noteGapCount }, + ]; +} + +function openTrackedExternalContextLink(url, meta = null) { + if (!url) return; + openExternalContextLink(url); + if (meta) { + persistExternalContextSurfaceHistory({ ...meta, target_url: meta.target_url || url }) + .then((result) => { + if (result) { + refreshDashboard().catch(() => {}); + } + }) + .catch(() => {}); + } +} + async function saveOperatorTemplateNote(template) { const focusContext = getAssistantFocusContext(); try { @@ -1010,6 +1109,11 @@ function renderDecisionAssistant(summary) { const reasonCitations = summary.reason_citations || []; const learningRollup = summary.learning_rollup || null; const externalContext = summary.external_context_summary || null; + const decisionSupportBoundarySummary = summary.decision_support_boundary_summary || null; + const decisionSupportPromotionSummary = summary.decision_support_promotion_summary || null; + const liveAnalysisAssistantSummary = summary.live_analysis_assistant_summary || null; + const readonlyLiveHealthSummary = summary.readonly_live_health_summary || liveAnalysisAssistantSummary?.health_summary || null; + const shadowPaperValidationSummary = summary.shadow_paper_validation_summary || null; const reviewEntrypoints = summary.review_entrypoints || null; const personalizedReminders = summary.personalized_reminders || []; const digestHistoryPreview = summary.digest_history_preview || []; @@ -1031,6 +1135,7 @@ function renderDecisionAssistant(summary) { const digestArchiveLongRangeSummary = digestArchiveData?.long_range_trend_summary || digestSurface?.digest_history_archive?.long_range_trend_summary || null; const digestArchiveBaselineSummary = digestArchiveData?.baseline_pin_summary || digestSurface?.digest_history_archive?.baseline_pin_summary || null; const digestArchiveSavedDefaults = digestPreferenceProfile?.archive_defaults?.digest || { profile_scope: 'selected_profile', query_preset: '' }; + const digestArchiveRankedAggregation = digestArchiveData?.ranked_aggregation_summary || digestSurface?.digest_history_archive?.ranked_aggregation_summary || null; const workflowArchiveData = window.__assistantWorkflowArchiveResponse?.instrument_id === Number(instrumentIdInput.value || 1) ? window.__assistantWorkflowArchiveResponse : summary.workflow_artifact_summary?.workflow_history_archive; @@ -1040,6 +1145,7 @@ function renderDecisionAssistant(summary) { const workflowArchiveLongRangeSummary = workflowArchiveData?.long_range_trend_summary || summary.workflow_artifact_summary?.workflow_history_archive?.long_range_trend_summary || null; const workflowArchiveBaselineSummary = workflowArchiveData?.baseline_pin_summary || summary.workflow_artifact_summary?.workflow_history_archive?.baseline_pin_summary || null; const workflowArchiveSavedDefaults = digestPreferenceProfile?.archive_defaults?.workflow || { profile_scope: 'selected_profile', query_preset: '' }; + const workflowArchiveRankedAggregation = workflowArchiveData?.ranked_aggregation_summary || summary.workflow_artifact_summary?.workflow_history_archive?.ranked_aggregation_summary || null; const workflowArchiveOperationsSummary = workflowArchiveData?.operations_summary || summary.workflow_artifact_summary?.workflow_archive_operations_summary || summary.workflow_archive_operations_summary @@ -1055,6 +1161,7 @@ function renderDecisionAssistant(summary) { const workflowArchiveBridgePersistenceSummary = summary.workflow_artifact_summary?.workflow_archive_bridge_persistence_summary || summary.workflow_archive_bridge_persistence_summary || null; + const archiveOperatorOverview = summary.archive_operator_overview || null; const workflowArchiveDailyGroups = workflowArchiveData?.daily_groups || []; const workflowArchiveSessionGroups = workflowArchiveData?.session_groups || []; const workflowArchiveBridgeContext = assistantWorkflowArchiveBridgeContext; @@ -1102,6 +1209,7 @@ function renderDecisionAssistant(summary) { (watchlistTiers.review_now || []).length ? { title: 'Watchlist: Review Now' } : null, noTradeItems.length ? { title: 'No-Trade Reasons' } : null, setupLearningRollup ? { title: 'Setup Replay' } : null, + shadowPaperValidationSummary ? { title: 'Shadow Paper' } : null, externalContext ? { title: 'External Context' } : null, decisionTimeline.length ? { title: 'Timeline' } : null, explanationDrilldown.length ? { title: 'Explain' } : null, @@ -1432,6 +1540,30 @@ function renderDecisionAssistant(summary) { }); decisionAssistantPanel.appendChild(railGrid); } + if (liveAnalysisAssistantSummary) { + appendSectionTitle(decisionAssistantPanel, 'Read-only Live Analysis'); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['read-only live analysis', liveAnalysisAssistantSummary.status || 'n/a'], + ['ready_for_readonly_live_analysis', String(Boolean(liveAnalysisAssistantSummary.ready_for_readonly_live_analysis))], + ['must_not_trade', String(Boolean(liveAnalysisAssistantSummary.must_not_trade))], + ['No order submission', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.no_order_submission))], + ['Not a buy/sell instruction', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.not_buy_sell_instruction))], + ['Execution remains disabled', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.execution_remains_disabled))], + ['health', readonlyLiveHealthSummary?.status || 'n/a'], + ['runtime health', readonlyLiveHealthSummary?.runtime_health || 'n/a'], + ['market freshness', readonlyLiveHealthSummary?.market_freshness || 'n/a'], + ['broker/account context', readonlyLiveHealthSummary?.broker_account_context || 'n/a'], + ['latest decision packet timestamp', formatTimestamp(readonlyLiveHealthSummary?.latest_decision_packet_timestamp)], + ['latest review packet timestamp', formatTimestamp(readonlyLiveHealthSummary?.latest_review_packet_timestamp)], + ['current candidate count', String(readonlyLiveHealthSummary?.current_candidate_count ?? 0)], + ['current blocker', readonlyLiveHealthSummary?.current_blocker || 'none'], + ['recommendation', readonlyLiveHealthSummary?.recommendation || 'n/a'], + ['allowed actions', formatList(liveAnalysisAssistantSummary.allowed_actions || [])], + ['blocked actions', formatList(liveAnalysisAssistantSummary.blocked_actions || [])], + ]) + ); + } decisionAssistantPanel.appendChild( buildInfoGrid([ ['focus mode', summary.focus_mode || 'monitor_and_wait'], @@ -1445,6 +1577,12 @@ function renderDecisionAssistant(summary) { ['trust / mode', `${evidence.trust_classification || 'unknown'} / ${evidence.operator_mode || 'unknown'}`], ['market truth', `${evidence.market_freshness || 'unknown'} | ${evidence.stream_status || 'unknown'} | ${evidence.data_gap_status || 'unknown'}`], ['launch mode', evidence.launch_mode || 'unknown'], + ['live analysis assistant ready', String(Boolean(evidence.live_analysis_assistant_ready))], + ['readonly live health', evidence.readonly_live_health_status || 'n/a'], + ['readonly account context', evidence.readonly_account_context_status || 'n/a'], + ['must_not_trade', String(Boolean(evidence.must_not_trade))], + ['shadow paper validation', evidence.shadow_paper_status || shadowPaperValidationSummary?.status || 'n/a'], + ['next shadow gate', evidence.next_shadow_paper_gate || shadowPaperValidationSummary?.next_validation_gate || 'n/a'], ['top opportunity', opportunities[0]?.title || 'n/a'], ['watchlist tiers', `review ${watchlistSummary.review_now_count ?? 0} | strong ${watchlistSummary.strong_watch_count ?? 0} | soft ${watchlistSummary.soft_watch_count ?? 0}`], ['next watchlist focus', watchlistSummary.focus_matrix?.next_focus?.title || 'n/a'], @@ -1454,6 +1592,66 @@ function renderDecisionAssistant(summary) { ['next actions', formatList(summary.next_actions || [])], ]) ); + if (decisionSupportBoundarySummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['operator boundary', decisionSupportBoundarySummary.label || 'n/a'], + ['boundary summary', decisionSupportBoundarySummary.operator_summary || 'n/a'], + ['current model boundary', decisionSupportBoundarySummary.current_model_boundary || 'n/a'], + ['live execution boundary', decisionSupportBoundarySummary.live_execution_boundary || 'n/a'], + ['allowed operator actions', formatList(decisionSupportBoundarySummary.allowed_operator_actions || [])], + ['blocked operator actions', formatList(decisionSupportBoundarySummary.blocked_operator_actions || [])], + ['boundary next gate', decisionSupportBoundarySummary.next_boundary_gate || 'n/a'], + ['boundary gate reason', decisionSupportBoundarySummary.next_boundary_gate_reason || 'n/a'], + ]) + ); + } + if (decisionSupportPromotionSummary) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['decision support promotion', decisionSupportPromotionSummary.status || 'n/a'], + ['strict decision support ready', String(Boolean(decisionSupportPromotionSummary.strict_decision_support_ready))], + ['current decision support ready', String(Boolean(decisionSupportPromotionSummary.current_decision_support_ready))], + ['promotion model', decisionSupportPromotionSummary.candidate_model_code || 'n/a'], + ['promotion classification', decisionSupportPromotionSummary.candidate_classification || 'n/a'], + ['operator feedback samples', String(decisionSupportPromotionSummary.operator_review_sample_count ?? 0)], + ['promotion criteria', `${decisionSupportPromotionSummary.passed_criteria_count ?? 0}/${decisionSupportPromotionSummary.required_criteria_count ?? 0}`], + ['next promotion gate', decisionSupportPromotionSummary.next_promotion_gate || 'n/a'], + ['promotion summary', decisionSupportPromotionSummary.operator_summary || 'n/a'], + ]) + ); + } + if (shadowPaperValidationSummary) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { label: 'Record Shadow Observation', onClick: () => recordShadowPaperObservation('pending') }, + { label: 'Agree Shadow Observation', onClick: () => recordShadowPaperObservation('agree') }, + { label: 'Disagree Shadow Observation', onClick: () => recordShadowPaperObservation('disagree') }, + ]) + ); + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['shadow paper validation', shadowPaperValidationSummary.status || 'n/a'], + ['shadow validation status', shadowPaperValidationSummary.status || 'n/a'], + ['shadow observed days', `${shadowPaperValidationSummary.observed_shadow_day_count ?? 0}/${shadowPaperValidationSummary.window_days ?? 0}`], + ['shadow observation samples', String(shadowPaperValidationSummary.shadow_observation_count ?? 0)], + ['shadow observation days', String(shadowPaperValidationSummary.shadow_observation_day_count ?? 0)], + ['shadow candidates', String(shadowPaperValidationSummary.candidate_count ?? 0)], + ['paper ticket samples', String(shadowPaperValidationSummary.paper_ticket_count ?? 0)], + ['paper execution samples', String(shadowPaperValidationSummary.paper_execution_count ?? 0)], + ['operator feedback samples', String(shadowPaperValidationSummary.operator_feedback_count ?? 0)], + ['operator observation feedback', String(shadowPaperValidationSummary.operator_observation_feedback_count ?? 0)], + ['journal samples', String(shadowPaperValidationSummary.journal_entry_count ?? 0)], + ['risk violations', String(shadowPaperValidationSummary.risk_violation_count ?? 0)], + ['shadow criteria', `${shadowPaperValidationSummary.passed_criteria_count ?? 0}/${shadowPaperValidationSummary.required_criteria_count ?? 0}`], + ['next shadow gate', shadowPaperValidationSummary.next_validation_gate || 'n/a'], + ['sample collection next actions', formatList(shadowPaperValidationSummary.sample_collection_plan?.next_actions || [])], + ['real live after shadow', shadowPaperValidationSummary.execution_boundary || 'real_live_still_blocked'], + ['shadow validation summary', shadowPaperValidationSummary.operator_summary || 'n/a'], + ['shadow daily coverage', formatList((shadowPaperValidationSummary.daily_coverage || []).slice(-5).map((item) => `${item.date}: o${item.shadow_observation_count}/c${item.candidate_count}/p${item.paper_execution_count}/f${item.operator_feedback_count}`))], + ]) + ); + } if (assistantSections.length) { decisionAssistantPanel.appendChild( buildActionBar( @@ -1726,6 +1924,25 @@ function renderDecisionAssistant(summary) { } if (digestSurface) { appendSectionTitle(decisionAssistantPanel, 'Digest'); + if (archiveOperatorOverview) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['archive operator overview', archiveOperatorOverview.status || 'idle'], + ['digest trend status', archiveOperatorOverview.digest_trend_status || 'n/a'], + ['workflow trend status', archiveOperatorOverview.workflow_trend_status || 'n/a'], + ['digest snapshots', String(archiveOperatorOverview.digest_snapshot_count ?? 0)], + ['workflow snapshots', String(archiveOperatorOverview.workflow_snapshot_count ?? 0)], + ['digest baseline status', archiveOperatorOverview.digest_baseline_status || 'n/a'], + ['workflow baseline status', archiveOperatorOverview.workflow_baseline_status || 'n/a'], + ['digest baseline delta', archiveOperatorOverview.digest_baseline_delta_status || 'n/a'], + ['workflow baseline delta', archiveOperatorOverview.workflow_baseline_delta_status || 'n/a'], + ['digest baseline delta story', archiveOperatorOverview.digest_baseline_delta_story || 'n/a'], + ['workflow baseline delta story', archiveOperatorOverview.workflow_baseline_delta_story || 'n/a'], + ['overview story', archiveOperatorOverview.operator_overview_story || 'n/a'], + ['overview prompt', archiveOperatorOverview.next_overview_prompt || 'n/a'], + ]) + ); + } decisionAssistantPanel.appendChild( buildInfoGrid([ ['digest status', digestSurface.status || 'idle'], @@ -2017,6 +2234,8 @@ function renderDecisionAssistant(summary) { limit: exportLimit, offset: 0, })}`); + assistantDigestArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); downloadJsonFile(exportPayload.export_name || 'digest-archive-export.json', exportPayload); }, }, @@ -2043,6 +2262,8 @@ function renderDecisionAssistant(summary) { left_id: assistantDigestArchiveState.selected_left_id, right_id: assistantDigestArchiveState.selected_right_id, })}`); + assistantDigestArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); downloadJsonFile(exportPayload.export_name || 'digest-archive-compare-export.json', exportPayload); }, }, @@ -2150,12 +2371,43 @@ function renderDecisionAssistant(summary) { ['archive span days', String(digestArchiveLongRangeSummary?.covered_day_span ?? 0)], ['archive baseline', String(assistantDigestArchiveState.pinned_baseline_id ?? digestArchiveBaselineSummary?.baseline_snapshot_id ?? 'n/a')], ['archive baseline persistence', digestArchiveBaselineSummary?.persistence_status || digestPreferenceProfile?.archive_baselines?.status || 'n/a'], + ['archive baseline delta', digestArchiveBaselineSummary?.baseline_delta_status || 'n/a'], + ['archive baseline delta story', digestArchiveBaselineSummary?.baseline_delta_story || 'n/a'], ['archive baseline prompt', digestArchiveBaselineSummary?.suggested_compare_prompt || 'n/a'], ['archive next offset', String(digestArchiveData?.next_offset ?? assistantDigestArchiveState.next_offset ?? 0)], ['compare left snapshot', String(assistantDigestArchiveState.selected_left_id ?? 'n/a')], ['compare right snapshot', String(assistantDigestArchiveState.selected_right_id ?? 'n/a')], ]) ); + if (digestArchiveRankedAggregation) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['archive top profile', digestArchiveRankedAggregation.top_profile?.value || 'n/a'], + ['archive top kind', digestArchiveRankedAggregation.top_history_kind?.value || 'n/a'], + ['archive aggregation story', digestArchiveRankedAggregation.aggregation_story || 'n/a'], + ]) + ); + const digestRankGrid = createElement('div', 'cards assistant-tight-grid'); + (digestArchiveRankedAggregation.profile_rankings || []).slice(0, 3).forEach((item) => { + digestRankGrid.appendChild( + buildItemCard( + `Profile ${item.value || 'n/a'}`, + String(item.count ?? 0), + [['count', String(item.count ?? 0)]] + ) + ); + }); + (digestArchiveRankedAggregation.history_kind_rankings || []).slice(0, 3).forEach((item) => { + digestRankGrid.appendChild( + buildItemCard( + `Kind ${item.value || 'n/a'}`, + String(item.count ?? 0), + [['count', String(item.count ?? 0)]] + ) + ); + }); + decisionAssistantPanel.appendChild(digestRankGrid); + } decisionAssistantPanel.appendChild(digestArchivePanel); if (digestArchiveCompare?.status === 'active') { const digestCompareLeft = digestArchiveCompare.left || {}; @@ -2171,6 +2423,31 @@ function renderDecisionAssistant(summary) { ['high value delta', String(digestArchiveCompare.count_delta?.high_value_reminder_count ?? 0)], ['left body', digestCompareLeft.summary_body || 'n/a'], ['right body', digestCompareRight.summary_body || 'n/a'], + ]) + ); + } + if (assistantDigestArchiveLastExport?.selection_provenance_summary) { + const exportProvenance = assistantDigestArchiveLastExport.selection_provenance_summary; + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['export provenance', exportProvenance.selection_label || 'n/a'], + ['export scope', exportProvenance.export_scope || 'n/a'], + ['selected snapshots', formatList((exportProvenance.selected_snapshot_ids || []).map((item) => String(item)))], + ['active filters', String(exportProvenance.active_filter_count ?? 0)], + ['baseline snapshot', String(exportProvenance.baseline_snapshot_id ?? 'n/a')], + ['selection fingerprint', exportProvenance.selection_fingerprint || 'n/a'], + ]) + ); + } + if (assistantDigestArchiveLastExport?.bundle_manifest) { + const bundleManifest = assistantDigestArchiveLastExport.bundle_manifest; + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['bundle manifest', bundleManifest.manifest_label || 'n/a'], + ['manifest version', bundleManifest.manifest_version || 'n/a'], + ['manifest sections', formatList(bundleManifest.included_sections || [])], + ['manifest section count', String(bundleManifest.included_section_count ?? 0)], + ['manifest story', bundleManifest.manifest_story || 'n/a'], ]) ); } @@ -2695,8 +2972,23 @@ function renderDecisionAssistant(summary) { ['bridge saved handoff', workflowArchiveBridgePersistenceSummary.handoff_packet_starter?.headline || 'n/a'], ['bridge saved replay', workflowArchiveBridgePersistenceSummary.replay_note_bridge_summary?.note_title || 'n/a'], ['bridge saved bundle', workflowArchiveBridgePersistenceSummary.artifact_bundle_summary?.starter_export_prompt || 'n/a'], + ['bridge saved compare note', workflowArchiveBridgePersistenceSummary.compare_writeback_summary?.note_title || 'n/a'], + ['bridge compare note at', formatTimestamp(workflowArchiveBridgePersistenceSummary.compare_writeback_summary?.created_ts)], ]) ); + if (workflowArchiveBridgePersistenceSummary.compare_writeback_summary?.note_body) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Prefill Saved Compare Note', + onClick: () => prefillOperatorNote( + workflowArchiveBridgePersistenceSummary.compare_writeback_summary?.note_body || '', + workflowArchiveBridgeContext || null, + ), + }, + ]) + ); + } } if (workflowArchiveBridgeContext) { decisionAssistantPanel.appendChild( @@ -2899,6 +3191,8 @@ function renderDecisionAssistant(summary) { limit: exportLimit, offset: 0, })}`); + assistantWorkflowArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); downloadJsonFile(exportPayload.export_name || 'workflow-archive-export.json', exportPayload); }, }, @@ -2926,6 +3220,8 @@ function renderDecisionAssistant(summary) { left_id: assistantWorkflowArchiveState.selected_left_id, right_id: assistantWorkflowArchiveState.selected_right_id, })}`); + assistantWorkflowArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); downloadJsonFile(exportPayload.export_name || 'workflow-archive-compare-export.json', exportPayload); }, }, @@ -3035,12 +3331,66 @@ function renderDecisionAssistant(summary) { ['workflow span days', String(workflowArchiveLongRangeSummary?.covered_day_span ?? 0)], ['workflow baseline', String(assistantWorkflowArchiveState.pinned_baseline_id ?? workflowArchiveBaselineSummary?.baseline_snapshot_id ?? 'n/a')], ['workflow baseline persistence', workflowArchiveBaselineSummary?.persistence_status || digestPreferenceProfile?.archive_baselines?.status || 'n/a'], + ['workflow baseline delta', workflowArchiveBaselineSummary?.baseline_delta_status || 'n/a'], + ['workflow baseline delta story', workflowArchiveBaselineSummary?.baseline_delta_story || 'n/a'], ['workflow baseline prompt', workflowArchiveBaselineSummary?.suggested_compare_prompt || 'n/a'], ['workflow next offset', String(workflowArchiveData?.next_offset ?? assistantWorkflowArchiveState.next_offset ?? 0)], ['workflow compare left', String(assistantWorkflowArchiveState.selected_left_id ?? 'n/a')], ['workflow compare right', String(assistantWorkflowArchiveState.selected_right_id ?? 'n/a')], ]) ); + if (assistantWorkflowArchiveLastExport?.selection_provenance_summary) { + const exportProvenance = assistantWorkflowArchiveLastExport.selection_provenance_summary; + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow export provenance', exportProvenance.selection_label || 'n/a'], + ['workflow export scope', exportProvenance.export_scope || 'n/a'], + ['workflow selected snapshots', formatList((exportProvenance.selected_snapshot_ids || []).map((item) => String(item)))], + ['workflow active filters', String(exportProvenance.active_filter_count ?? 0)], + ['workflow baseline snapshot', String(exportProvenance.baseline_snapshot_id ?? 'n/a')], + ['selection fingerprint', exportProvenance.selection_fingerprint || 'n/a'], + ]) + ); + } + if (assistantWorkflowArchiveLastExport?.bundle_manifest) { + const bundleManifest = assistantWorkflowArchiveLastExport.bundle_manifest; + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow bundle manifest', bundleManifest.manifest_label || 'n/a'], + ['workflow manifest version', bundleManifest.manifest_version || 'n/a'], + ['workflow manifest sections', formatList(bundleManifest.included_sections || [])], + ['workflow manifest section count', String(bundleManifest.included_section_count ?? 0)], + ['workflow manifest story', bundleManifest.manifest_story || 'n/a'], + ]) + ); + } + if (workflowArchiveRankedAggregation) { + decisionAssistantPanel.appendChild( + buildInfoGrid([ + ['workflow top profile', workflowArchiveRankedAggregation.top_profile?.value || 'n/a'], + ['workflow top session', workflowArchiveRankedAggregation.top_session?.value || 'n/a'], + ['workflow top phase', workflowArchiveRankedAggregation.top_phase?.value || 'n/a'], + ['workflow aggregation story', workflowArchiveRankedAggregation.aggregation_story || 'n/a'], + ]) + ); + const workflowRankGrid = createElement('div', 'cards assistant-tight-grid'); + (workflowArchiveRankedAggregation.profile_rankings || []).slice(0, 2).forEach((item) => { + workflowRankGrid.appendChild( + buildItemCard(`Profile ${item.value || 'n/a'}`, String(item.count ?? 0), [['count', String(item.count ?? 0)]]) + ); + }); + (workflowArchiveRankedAggregation.session_rankings || []).slice(0, 2).forEach((item) => { + workflowRankGrid.appendChild( + buildItemCard(`Session ${item.value || 'n/a'}`, String(item.count ?? 0), [['count', String(item.count ?? 0)]]) + ); + }); + (workflowArchiveRankedAggregation.phase_rankings || []).slice(0, 2).forEach((item) => { + workflowRankGrid.appendChild( + buildItemCard(`Phase ${item.value || 'n/a'}`, String(item.count ?? 0), [['count', String(item.count ?? 0)]]) + ); + }); + decisionAssistantPanel.appendChild(workflowRankGrid); + } decisionAssistantPanel.appendChild(workflowArchivePanel); if (workflowArchiveOperationsSummary) { decisionAssistantPanel.appendChild( @@ -3113,6 +3463,31 @@ function renderDecisionAssistant(summary) { ['phase names', formatList(item.phase_names || [])], ['workflow body', item.workflow_body || 'n/a'], ['next prompt', item.next_artifact_prompt || 'n/a'], + ], + [ + { + label: 'Export Day Bundle', + onClick: async () => { + const exportPayload = await fetchJson(`/trading/workflow-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + profile_scope: assistantWorkflowArchiveState.profile_scope || 'selected_profile', + query_preset: assistantWorkflowArchiveState.query_preset || '', + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: workflowArchiveData?.item_count || assistantWorkflowArchiveState.limit || 12, + offset: 0, + group_date: item.group_date || '', + })}`); + assistantWorkflowArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + downloadJsonFile(exportPayload.export_name || `workflow-day-group-${item.group_date || 'export'}.json`, exportPayload); + }, + }, ] ) ); @@ -3134,6 +3509,31 @@ function renderDecisionAssistant(summary) { ['latest next phase', item.latest_next_phase || 'n/a'], ['artifact counts', `${item.artifact_count ?? 0} / ${item.ready_for_handoff_count ?? 0} / ${item.needs_update_count ?? 0} / ${item.pending_capture_count ?? 0}`], ['phase names', formatList(item.phase_names || [])], + ], + [ + { + label: 'Export Session Bundle', + onClick: async () => { + const exportPayload = await fetchJson(`/trading/workflow-archive/export?${queryString({ + instrument_id: Number(instrumentIdInput.value || 1), + profile_key: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + profile_scope: assistantWorkflowArchiveState.profile_scope || 'selected_profile', + query_preset: assistantWorkflowArchiveState.query_preset || '', + current_session: assistantWorkflowArchiveState.current_session || '', + next_phase: assistantWorkflowArchiveState.next_phase || '', + next_artifact_kind: assistantWorkflowArchiveState.next_artifact_kind || '', + query_text: assistantWorkflowArchiveState.query_text || '', + created_after: assistantWorkflowArchiveState.created_after || '', + created_before: assistantWorkflowArchiveState.created_before || '', + limit: workflowArchiveData?.item_count || assistantWorkflowArchiveState.limit || 12, + offset: 0, + session_group: item.session_code || '', + })}`); + assistantWorkflowArchiveLastExport = exportPayload; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + downloadJsonFile(exportPayload.export_name || `workflow-session-group-${item.session_code || 'export'}.json`, exportPayload); + }, + }, ] ) ); @@ -3160,6 +3560,20 @@ function renderDecisionAssistant(summary) { label: 'Prefill Compare Replay Note', onClick: () => prefillOperatorNote(buildWorkflowArchiveCompareReplayNote(workflowArchiveCompare)), }, + { + label: 'Persist Compare Replay Note', + onClick: async () => { + const payload = await persistWorkflowArchiveCompareWritebackServer({ + profileKey: assistantWorkflowArchiveState.profile_key || selectedProfileKey, + compare: workflowArchiveCompare, + }); + if (!payload || payload.status !== 'saved') { + showErrorPanel('workflow_compare_writeback_failed'); + return; + } + await refreshDashboard(); + }, + }, { label: 'Export Handoff Starter', onClick: async () => { @@ -3584,21 +3998,93 @@ function renderDecisionAssistant(summary) { ['provider health states', formatJsonInline(externalContext.provider_health_status_counts || {})], ['provider health next', externalContext.provider_health_summary?.next_health_target || 'n/a'], ['provider health prompt', externalContext.provider_health_summary?.next_health_prompt || 'n/a'], + ['provider health story', externalContext.provider_health_summary?.health_story || 'n/a'], ['capture surfaces', String(externalContext.capture_surface_card_count ?? 0)], ['capture surface states', formatJsonInline(externalContext.capture_surface_status_counts || {})], ['capture next target', externalContext.capture_surface_summary?.next_capture_target || 'n/a'], ['capture next prompt', externalContext.capture_surface_summary?.next_capture_prompt || 'n/a'], + ['capture surface story', externalContext.capture_surface_summary?.capture_story || 'n/a'], ['screenshot readiness', externalContext.screenshot_readiness_summary?.status || 'n/a'], ['screenshot ready', String(externalContext.screenshot_readiness_summary?.screenshot_ready_count ?? 0)], ['autodiscovery needed', String(externalContext.screenshot_readiness_summary?.autodiscovery_needed_count ?? 0)], ['screenshot embed ready', String(externalContext.screenshot_readiness_summary?.embed_ready_count ?? 0)], ['screenshot next target', externalContext.screenshot_readiness_summary?.next_screenshot_target || 'n/a'], ['screenshot next prompt', externalContext.screenshot_readiness_summary?.next_screenshot_prompt || 'n/a'], + ['screenshot story', externalContext.screenshot_readiness_summary?.screenshot_story || 'n/a'], ['visual references', String(externalContext.visual_reference_card_count ?? 0)], ['visual prompt', externalContext.visual_reference_next_prompt || 'n/a'], ['visual evidence bridge', externalContext.visual_evidence_bridge_summary?.status || 'n/a'], ['visual bridge target', externalContext.visual_evidence_bridge_summary?.next_bridge_target || 'n/a'], ['visual bridge prompt', externalContext.visual_evidence_bridge_summary?.next_bridge_prompt || 'n/a'], + ['visual bridge story', externalContext.visual_evidence_bridge_summary?.bridge_story || 'n/a'], + ['surface history', externalContext.surface_selection_history_summary?.status || 'n/a'], + ['surface history count', String(externalContext.surface_selection_history_summary?.entry_count ?? 0)], + ['latest surface', externalContext.surface_selection_history_summary?.latest_title || 'n/a'], + ['latest selection kind', externalContext.surface_selection_history_summary?.latest_selection_kind || 'n/a'], + ['surface history recency', externalContext.surface_selection_history_summary?.recency_status || 'n/a'], + ['surface history cadence', externalContext.surface_selection_history_summary?.cadence_status || 'n/a'], + ['surface cadence story', externalContext.surface_selection_history_summary?.cadence_story || 'n/a'], + ['surface revisit needed', String(Boolean(externalContext.surface_selection_history_summary?.revisit_needed))], + ['revisit queue count', String(externalContext.surface_selection_history_summary?.revisit_queue_count ?? 0)], + ['acknowledged revisits', String(externalContext.surface_selection_history_summary?.acknowledged_count ?? 0)], + ['resolution notes', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_count ?? 0)], + ['resolution note gaps', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_count ?? 0)], + ['note gap queue', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_queue_count ?? 0)], + ['note gap oldest', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_oldest_title || 'n/a'], + ['note gap avg age', typeof externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_average_age_seconds === 'number' ? `${Math.round(externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_average_age_seconds / 60)}m` : 'n/a'], + ['resolution owners', formatJsonInline(externalContext.surface_selection_history_summary?.resolution_filter_summary?.acknowledged_by_counts || {})], + ['owner spotlight', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_spotlight?.owner || 'n/a'], + ['owner spotlight count', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_spotlight?.count ?? 0)], + ['owner cadence', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_cadence_status || 'n/a'], + ['owner cadence avg gap', typeof externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_cadence_average_gap_seconds === 'number' ? `${Math.round(externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_cadence_average_gap_seconds / 60)}m` : 'n/a'], + ['owner cadence story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_cadence_story || 'n/a'], + ['owner trend', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_trend_status || 'n/a'], + ['owner trend story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_trend_story || 'n/a'], + ['owner handoff', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_handoff_status || 'n/a'], + ['owner handoff story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_handoff_story || 'n/a'], + ['owner handoff follow-up', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_handoff_follow_up_status || 'n/a'], + ['owner handoff follow-up story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_handoff_follow_up_story || 'n/a'], + ['resolved owner drift', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_status || 'n/a'], + ['resolved owner drift story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_story || 'n/a'], + ['resolved owner drift review', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_status || 'n/a'], + ['resolved owner drift review story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_story || 'n/a'], + ['drift review queue', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_queue_count ?? 0)], + ['drift review queue story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_queue_story || 'n/a'], + ['drift review queue aging', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_queue_aging_status || 'n/a'], + ['drift review queue aging story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.resolved_owner_drift_review_queue_aging_story || 'n/a'], + ['active resolution filter', assistantSurfaceResolutionFilter || 'all'], + ['cleared queue status', externalContext.surface_selection_history_summary?.cleared_queue_status || 'n/a'], + ['cleared queue story', externalContext.surface_selection_history_summary?.cleared_queue_story || 'n/a'], + ['resolution history count', String(externalContext.surface_selection_history_summary?.resolution_history_count ?? 0)], + ['latest acknowledged by', externalContext.surface_selection_history_summary?.latest_acknowledged_by || 'n/a'], + ['latest acknowledged note', externalContext.surface_selection_history_summary?.latest_acknowledged_note || 'n/a'], + ['surface revisit prompt', externalContext.surface_selection_history_summary?.revisit_prompt || 'n/a'], + ['next revisit prompt', externalContext.surface_selection_history_summary?.next_revisit_prompt || 'n/a'], + ['resolution review prompt', externalContext.surface_selection_history_summary?.next_resolution_review_prompt || 'n/a'], + ['note gap prompt', externalContext.surface_selection_history_summary?.resolution_filter_summary?.next_note_gap_prompt || 'n/a'], + ['owner spotlight prompt', externalContext.surface_selection_history_summary?.resolution_filter_summary?.owner_spotlight_prompt || 'n/a'], + ['priority lane status', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_status || 'n/a'], + ['priority lane count', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_count ?? 0)], + ['priority lane story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_story || 'n/a'], + ['priority lane carry-forward', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_carry_forward_status || 'n/a'], + ['priority lane carry-forward story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_carry_forward_story || 'n/a'], + ['carry-forward aging', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_carry_forward_aging_status || 'n/a'], + ['carry-forward aging story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.note_gap_priority_lane_carry_forward_aging_story || 'n/a'], + ['follow-up lane status', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_status || 'n/a'], + ['follow-up lane count', String(externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_count ?? 0)], + ['follow-up lane story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_story || 'n/a'], + ['follow-up lane rollover', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_rollover_status || 'n/a'], + ['follow-up lane rollover story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_rollover_story || 'n/a'], + ['rollover handoff', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_rollover_handoff_status || 'n/a'], + ['rollover handoff story', externalContext.surface_selection_history_summary?.resolution_filter_summary?.carry_forward_follow_up_lane_rollover_handoff_story || 'n/a'], + ['priority lane prompt', externalContext.surface_selection_history_summary?.resolution_filter_summary?.next_priority_lane_prompt || 'n/a'], + ['rollover prompt', externalContext.surface_selection_history_summary?.resolution_filter_summary?.next_follow_up_lane_rollover_prompt || 'n/a'], + ['rollover handoff prompt', externalContext.surface_selection_history_summary?.resolution_filter_summary?.next_rollover_handoff_prompt || 'n/a'], + ['surface history story', externalContext.surface_selection_history_summary?.provenance_story || 'n/a'], + ['dominant surface', externalContext.surface_selection_history_summary?.dominant_surface_title || 'n/a'], + ['dominant surface count', String(externalContext.surface_selection_history_summary?.dominant_surface_count ?? 0)], + ['dominant source group', externalContext.surface_selection_history_summary?.dominant_source_group || 'n/a'], + ['dominant surface story', externalContext.surface_selection_history_summary?.dominant_surface_story || 'n/a'], ['chart stale evidence', formatList((externalContext.chart_stale_source_evidence || []).map((item) => `${item.source_id}: ${item.evidence}`))], ['exchange status', `${externalContext.exchange_status?.provider_name || 'unknown'} / ${externalContext.exchange_status?.status || 'unknown'}`], ['exchange events', formatList(externalContext.exchange_status_titles || [])], @@ -3615,7 +4101,13 @@ function renderDecisionAssistant(summary) { buildActionBar([ { label: 'Open Visual Bridge', - onClick: () => openExternalContextLink(externalContext.visual_evidence_bridge_summary?.next_bridge_url), + onClick: () => openTrackedExternalContextLink(externalContext.visual_evidence_bridge_summary?.next_bridge_url, { + surface_id: externalContext.visual_evidence_bridge_summary?.next_bridge_target || 'visual_bridge', + title: externalContext.visual_evidence_bridge_summary?.next_bridge_target || 'visual bridge', + source_group: 'visual_evidence_bridge', + selection_kind: 'bridge_target', + prompt: externalContext.visual_evidence_bridge_summary?.next_bridge_prompt || '', + }), }, ]) ); @@ -3625,7 +4117,13 @@ function renderDecisionAssistant(summary) { buildActionBar([ { label: 'Open Capture Surface', - onClick: () => openExternalContextLink(externalContext.capture_surface_summary?.next_capture_url), + onClick: () => openTrackedExternalContextLink(externalContext.capture_surface_summary?.next_capture_url, { + surface_id: externalContext.capture_surface_summary?.next_capture_target || 'capture_surface', + title: externalContext.capture_surface_summary?.next_capture_target || 'capture surface', + source_group: 'capture_surface', + selection_kind: 'capture_target', + prompt: externalContext.capture_surface_summary?.next_capture_prompt || '', + }), }, ]) ); @@ -3635,11 +4133,371 @@ function renderDecisionAssistant(summary) { buildActionBar([ { label: 'Open Screenshot Surface', - onClick: () => openExternalContextLink(externalContext.screenshot_readiness_summary?.next_screenshot_url), + onClick: () => openTrackedExternalContextLink(externalContext.screenshot_readiness_summary?.next_screenshot_url, { + surface_id: externalContext.screenshot_readiness_summary?.next_screenshot_target || 'screenshot_surface', + title: externalContext.screenshot_readiness_summary?.next_screenshot_target || 'screenshot surface', + source_group: 'screenshot_surface', + selection_kind: 'screenshot_target', + prompt: externalContext.screenshot_readiness_summary?.next_screenshot_prompt || '', + }), }, ]) ); } + const surfaceHistory = externalContext.surface_selection_history_summary?.items || []; + if (surfaceHistory.length) { + if (externalContext.surface_selection_history_summary?.revisit_target_url) { + decisionAssistantPanel.appendChild( + buildActionBar([ + { + label: 'Reopen Latest Surface', + onClick: () => openTrackedExternalContextLink( + externalContext.surface_selection_history_summary?.revisit_target_url, + { + surface_id: externalContext.surface_selection_history_summary?.revisit_surface_id || 'external_surface', + title: externalContext.surface_selection_history_summary?.revisit_surface_title || 'external surface', + source_group: 'surface_history_revisit', + selection_kind: 'revisit_target', + prompt: externalContext.surface_selection_history_summary?.revisit_prompt || '', + }, + ), + }, + ]) + ); + } + const revisitQueue = externalContext.surface_selection_history_summary?.revisit_queue || []; + if (revisitQueue.length) { + const revisitGrid = createElement('div', 'cards assistant-tight-grid'); + revisitQueue.forEach((item) => { + revisitGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'revisit surface', + item.recency_status || 'aging', + [ + ['group', item.source_group || 'n/a'], + ['age', typeof item.age_seconds === 'number' ? `${Math.round(item.age_seconds / 60)}m` : 'n/a'], + ['target', item.target_url || 'n/a'], + ['selected at', formatTimestamp(item.selected_ts)], + ], + item.target_url ? [{ + label: 'Reopen Revisit', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'revisit_target', + prompt: externalContext.surface_selection_history_summary?.next_revisit_prompt || '', + }), + }, { + label: 'Acknowledge Revisit', + onClick: async () => { + const payload = await acknowledgeExternalContextSurfaceHistory({ + surface_id: item.surface_id || '', + selected_ts: item.selected_ts || '', + }); + if (!payload || payload.status !== 'saved') { + showErrorPanel('surface_history_acknowledge_failed'); + return; + } + await refreshDashboard(); + }, + }, { + label: 'Acknowledge With Note', + onClick: async () => { + const note = window.prompt('Resolution note', ''); + if (note == null) return; + const payload = await acknowledgeExternalContextSurfaceHistory({ + surface_id: item.surface_id || '', + selected_ts: item.selected_ts || '', + note, + }); + if (!payload || payload.status !== 'saved') { + showErrorPanel('surface_history_acknowledge_failed'); + return; + } + await refreshDashboard(); + }, + }] : [] + ) + ); + }); + decisionAssistantPanel.appendChild(revisitGrid); + } + const historyGrid = createElement('div', 'cards assistant-tight-grid'); + surfaceHistory.forEach((item) => { + historyGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'surface history', + item.selection_kind || 'target_url', + [ + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['selected at', formatTimestamp(item.selected_ts)], + ['prompt', item.prompt || 'n/a'], + ], + item.target_url ? [{ + label: 'Reopen Surface', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: item.selection_kind || 'target_url', + prompt: item.prompt || '', + }), + }] : [] + ) + ); + }); + decisionAssistantPanel.appendChild(historyGrid); + } + const resolutionHistory = externalContext.surface_selection_history_summary?.resolution_history || []; + if (resolutionHistory.length) { + const resolutionFilterSummary = externalContext.surface_selection_history_summary?.resolution_filter_summary || {}; + const resolutionFilterOptions = (resolutionFilterSummary.filter_options && resolutionFilterSummary.filter_options.length) + ? resolutionFilterSummary.filter_options + : getDefaultSurfaceResolutionFilterOptions(externalContext.surface_selection_history_summary || {}); + if (resolutionFilterOptions.length) { + decisionAssistantPanel.appendChild( + buildActionBar( + resolutionFilterOptions.map((item) => ({ + label: item.label || item.filter_id || 'resolved', + onClick: () => { + assistantSurfaceResolutionFilter = item.filter_id || 'all'; + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + })) + ) + ); + } + const noteGapQueue = resolutionFilterSummary.note_gap_queue || []; + if (noteGapQueue.length) { + const noteGapGrid = createElement('div', 'cards assistant-tight-grid'); + noteGapQueue.forEach((item) => { + noteGapGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'note gap surface', + 'note_gap', + [ + ['acknowledged by', item.revisit_acknowledged_by || 'n/a'], + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['acknowledged at', formatTimestamp(item.revisit_acknowledged_ts)], + ], + [ + item.target_url && { + label: 'Reopen Note Gap', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'note_gap_target', + prompt: resolutionFilterSummary.next_note_gap_prompt || '', + }), + }, + { + label: 'Add Resolution Note', + onClick: async () => { + const note = window.prompt('Resolution note', item.revisit_ack_note || ''); + if (note == null) return; + const payload = await acknowledgeExternalContextSurfaceHistory({ + surface_id: item.surface_id || '', + selected_ts: item.selected_ts || '', + note, + }); + if (!payload || payload.status !== 'saved') { + showErrorPanel('surface_history_acknowledge_failed'); + return; + } + await refreshDashboard(); + }, + }, + ].filter(Boolean) + ) + ); + }); + decisionAssistantPanel.appendChild(noteGapGrid); + } + const priorityLane = resolutionFilterSummary.note_gap_priority_lane_items || []; + if (priorityLane.length) { + const priorityGrid = createElement('div', 'cards assistant-tight-grid'); + priorityLane.forEach((item) => { + priorityGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'priority lane', + typeof item.age_seconds === 'number' ? `${Math.round(item.age_seconds / 60)}m` : 'aging', + [ + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['selected at', formatTimestamp(item.selected_ts)], + ], + [ + item.target_url && { + label: 'Open Priority Surface', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'priority_lane_target', + prompt: resolutionFilterSummary.next_priority_lane_prompt || '', + }), + }, + ].filter(Boolean) + ) + ); + }); + decisionAssistantPanel.appendChild(priorityGrid); + } + const followUpLane = resolutionFilterSummary.carry_forward_follow_up_lane_items || []; + if (followUpLane.length) { + const followUpGrid = createElement('div', 'cards assistant-tight-grid'); + followUpLane.forEach((item) => { + followUpGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'follow-up lane', + typeof item.age_seconds === 'number' ? `${Math.round(item.age_seconds / 60)}m` : 'follow_up', + [ + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['selected at', formatTimestamp(item.selected_ts)], + ], + [ + item.target_url && { + label: 'Open Follow-Up Lane', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'follow_up_lane_target', + prompt: resolutionFilterSummary.next_follow_up_lane_prompt || '', + }), + }, + item.target_url && { + label: 'Open Rollover Surface', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'follow_up_lane_rollover_target', + prompt: resolutionFilterSummary.next_follow_up_lane_rollover_prompt || '', + }), + }, + ].filter(Boolean) + ) + ); + }); + decisionAssistantPanel.appendChild(followUpGrid); + } + const driftReviewQueue = resolutionFilterSummary.resolved_owner_drift_review_queue_items || []; + if (driftReviewQueue.length) { + const driftReviewGrid = createElement('div', 'cards assistant-tight-grid'); + driftReviewQueue.forEach((item) => { + driftReviewGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'drift review', + 'drift_review', + [ + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['selected at', formatTimestamp(item.selected_ts)], + ], + [ + item.target_url && { + label: 'Open Drift Review', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'drift_review_target', + prompt: resolutionFilterSummary.next_follow_up_lane_rollover_prompt || '', + }), + }, + item.target_url && { + label: 'Open Rollover Handoff', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'rollover_handoff_target', + prompt: resolutionFilterSummary.next_rollover_handoff_prompt || '', + }), + }, + ].filter(Boolean) + ) + ); + }); + decisionAssistantPanel.appendChild(driftReviewGrid); + } + const filteredResolutionHistory = resolutionHistory.filter((item) => matchesSurfaceResolutionFilter(item, assistantSurfaceResolutionFilter)); + const resolutionGrid = createElement('div', 'cards assistant-tight-grid'); + filteredResolutionHistory.forEach((item) => { + resolutionGrid.appendChild( + buildItemCard( + item.title || item.surface_id || 'resolved revisit', + item.revisit_acknowledged_by || 'resolved', + [ + ['acknowledged at', formatTimestamp(item.revisit_acknowledged_ts)], + ['group', item.source_group || 'n/a'], + ['target', item.target_url || 'n/a'], + ['note', item.revisit_ack_note || 'n/a'], + ], + [ + item.target_url && { + label: 'Reopen Cleared Surface', + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || 'external_surface', + title: item.title || item.surface_id || 'external surface', + source_group: item.source_group || 'external_context', + selection_kind: 'resolved_target', + prompt: externalContext.surface_selection_history_summary?.next_resolution_review_prompt || '', + }), + }, + { + label: 'Add Resolution Note', + onClick: async () => { + const note = window.prompt('Resolution note', item.revisit_ack_note || ''); + if (note == null) return; + const payload = await acknowledgeExternalContextSurfaceHistory({ + surface_id: item.surface_id || '', + selected_ts: item.selected_ts || '', + note, + }); + if (!payload || payload.status !== 'saved') { + showErrorPanel('surface_history_acknowledge_failed'); + return; + } + await refreshDashboard(); + }, + }, + ].filter(Boolean) + ) + ); + }); + decisionAssistantPanel.appendChild(resolutionGrid); + } + const surfaceRankings = externalContext.surface_selection_history_summary?.surface_rankings || []; + const sourceGroupRankings = externalContext.surface_selection_history_summary?.source_group_rankings || []; + if (surfaceRankings.length || sourceGroupRankings.length) { + const groupingGrid = createElement('div', 'cards assistant-tight-grid'); + surfaceRankings.slice(0, 3).forEach((item) => { + groupingGrid.appendChild( + buildItemCard( + `Surface ${item.value || 'n/a'}`, + String(item.count ?? 0), + [['count', String(item.count ?? 0)]] + ) + ); + }); + sourceGroupRankings.slice(0, 3).forEach((item) => { + groupingGrid.appendChild( + buildItemCard( + `Group ${item.value || 'n/a'}`, + String(item.count ?? 0), + [['count', String(item.count ?? 0)]] + ) + ); + }); + decisionAssistantPanel.appendChild(groupingGrid); + } const sourceCards = externalContext.source_cards || []; if (sourceCards.length) { const sourceGrid = createElement('div', 'cards'); @@ -3682,13 +4540,25 @@ function renderDecisionAssistant(summary) { if (item.target_url) { actions.push({ label: 'Open Target', - onClick: () => openExternalContextLink(item.target_url), + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.health_id || item.title || item.provider_name || 'provider_health', + title: item.title || item.provider_name || 'provider health', + source_group: 'provider_health', + selection_kind: 'target_url', + prompt: item.next_visual_prompt || item.health_reason || '', + }), }); } if (item.embed_url) { actions.push({ label: 'Open Embed', - onClick: () => openExternalContextLink(item.embed_url), + onClick: () => openTrackedExternalContextLink(item.embed_url, { + surface_id: item.health_id || item.title || item.provider_name || 'provider_health_embed', + title: item.title || item.provider_name || 'provider health embed', + source_group: 'provider_health', + selection_kind: 'embed_url', + prompt: item.next_visual_prompt || item.health_reason || '', + }), }); } healthGrid.appendChild( @@ -3719,13 +4589,25 @@ function renderDecisionAssistant(summary) { if (item.target_url) { actions.push({ label: 'Open Capture Target', - onClick: () => openExternalContextLink(item.target_url), + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.surface_id || item.title || item.provider_name || 'capture_surface', + title: item.title || item.provider_name || 'capture surface', + source_group: 'capture_surface_card', + selection_kind: 'target_url', + prompt: item.capture_surface_prompt || '', + }), }); } if (item.embed_url) { actions.push({ label: 'Open Capture Embed', - onClick: () => openExternalContextLink(item.embed_url), + onClick: () => openTrackedExternalContextLink(item.embed_url, { + surface_id: item.surface_id || item.title || item.provider_name || 'capture_surface_embed', + title: item.title || item.provider_name || 'capture surface embed', + source_group: 'capture_surface_card', + selection_kind: 'embed_url', + prompt: item.capture_surface_prompt || '', + }), }); } captureGrid.appendChild( @@ -3842,13 +4724,25 @@ function renderDecisionAssistant(summary) { if (item.target_url) { actions.push({ label: 'Open Target', - onClick: () => openExternalContextLink(item.target_url), + onClick: () => openTrackedExternalContextLink(item.target_url, { + surface_id: item.visual_id || item.title || item.provider_name || 'visual_reference', + title: item.title || item.provider_name || 'visual reference', + source_group: 'visual_reference_card', + selection_kind: 'target_url', + prompt: item.visual_prompt || '', + }), }); } if (item.embed_url) { actions.push({ label: 'Open Embed', - onClick: () => openExternalContextLink(item.embed_url), + onClick: () => openTrackedExternalContextLink(item.embed_url, { + surface_id: item.visual_id || item.title || item.provider_name || 'visual_reference_embed', + title: item.title || item.provider_name || 'visual reference embed', + source_group: 'visual_reference_card', + selection_kind: 'embed_url', + prompt: item.visual_prompt || '', + }), }); } visualGrid.appendChild( @@ -4408,6 +5302,7 @@ function renderTrustScore(payload) { function renderLiveReadiness(payload) { clearNode(liveReadinessPanel); + const okxSandboxPreflight = payload?.okx_sandbox_preflight_summary || null; liveReadinessPanel.appendChild( buildInfoGrid([ ['ready for real live', String(Boolean(payload?.ready_for_real_live))], @@ -4417,6 +5312,24 @@ function renderLiveReadiness(payload) { ['latest ticket', payload?.latest_ticket?.id || 'n/a'], ]) ); + if (okxSandboxPreflight) { + liveReadinessPanel.appendChild( + buildInfoGrid([ + ['okx sandbox preflight', okxSandboxPreflight.status || 'n/a'], + ['sandbox next gate', okxSandboxPreflight.next_gate || 'n/a'], + ['simulated trading header', formatJsonInline(okxSandboxPreflight.sandbox_header || {})], + ['sandbox credentials', String(Boolean(okxSandboxPreflight.sandbox_credentials_present))], + ['submit dry-run', String(Boolean(okxSandboxPreflight.submit_dry_run_ready))], + ['cancel dry-run', String(Boolean(okxSandboxPreflight.cancel_dry_run_ready))], + ['query order dry-run', String(Boolean(okxSandboxPreflight.query_order_dry_run_ready))], + ['query fills dry-run', String(Boolean(okxSandboxPreflight.query_fills_dry_run_ready))], + ['idempotency key', okxSandboxPreflight.idempotency_key_preview || 'n/a'], + ['error normalization', formatList(Object.keys(okxSandboxPreflight.error_normalization || {}))], + ['real live after sandbox', okxSandboxPreflight.execution_boundary || 'real_live_still_blocked'], + ['sandbox summary', okxSandboxPreflight.operator_summary || 'n/a'], + ]) + ); + } const grid = createElement('div', 'cards'); (payload?.checklist || []).forEach((item) => { grid.appendChild( @@ -4437,8 +5350,11 @@ function renderLiveReadiness(payload) { function renderLaunchReadiness(payload) { clearNode(launchReadinessPanel); + const liveAnalysisAssistantSummary = payload?.live_analysis_assistant_summary || null; + const readonlyLiveHealthSummary = payload?.readonly_live_health_summary || liveAnalysisAssistantSummary?.health_summary || null; launchReadinessPanel.appendChild( buildInfoGrid([ + ['live analysis assistant ready', String(Boolean(payload?.live_analysis_assistant_ready))], ['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))], @@ -4454,6 +5370,98 @@ function renderLaunchReadiness(payload) { ['next actions', formatList(payload?.next_actions || [])], ]) ); + if (liveAnalysisAssistantSummary) { + launchReadinessPanel.appendChild( + buildInfoGrid([ + ['Read-only Live Analysis', liveAnalysisAssistantSummary.status || 'n/a'], + ['ready_for_readonly_live_analysis', String(Boolean(liveAnalysisAssistantSummary.ready_for_readonly_live_analysis))], + ['must_not_trade', String(Boolean(liveAnalysisAssistantSummary.must_not_trade))], + ['No order submission', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.no_order_submission))], + ['Not a buy/sell instruction', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.not_buy_sell_instruction))], + ['Execution remains disabled', String(Boolean(liveAnalysisAssistantSummary.launch_boundary?.execution_remains_disabled))], + ['api health', readonlyLiveHealthSummary?.api_health || 'n/a'], + ['runtime health', readonlyLiveHealthSummary?.runtime_health || 'n/a'], + ['market freshness', readonlyLiveHealthSummary?.market_freshness || 'n/a'], + ['market stream', readonlyLiveHealthSummary?.market_stream || 'n/a'], + ['broker/account context', readonlyLiveHealthSummary?.broker_account_context || 'n/a'], + ['latest decision packet timestamp', formatTimestamp(readonlyLiveHealthSummary?.latest_decision_packet_timestamp)], + ['latest review packet timestamp', formatTimestamp(readonlyLiveHealthSummary?.latest_review_packet_timestamp)], + ['current candidate count', String(readonlyLiveHealthSummary?.current_candidate_count ?? 0)], + ['current blocker', readonlyLiveHealthSummary?.current_blocker || 'none'], + ['read-only recommendation', readonlyLiveHealthSummary?.recommendation || 'n/a'], + ['allowed actions', formatList(liveAnalysisAssistantSummary.allowed_actions || [])], + ['blocked actions', formatList(liveAnalysisAssistantSummary.blocked_actions || [])], + ]) + ); + } + if (payload?.decision_support_boundary_summary) { + const boundary = payload.decision_support_boundary_summary; + launchReadinessPanel.appendChild( + buildInfoGrid([ + ['operator boundary', boundary.label || 'n/a'], + ['boundary summary', boundary.operator_summary || 'n/a'], + ['current model boundary', boundary.current_model_boundary || 'n/a'], + ['current model reason', boundary.current_model_reason || 'n/a'], + ['live execution boundary', boundary.live_execution_boundary || 'n/a'], + ['live execution reason', boundary.live_execution_reason || 'n/a'], + ['allowed operator actions', formatList(boundary.allowed_operator_actions || [])], + ['blocked operator actions', formatList(boundary.blocked_operator_actions || [])], + ['boundary next gate', boundary.next_boundary_gate || 'n/a'], + ['boundary gate reason', boundary.next_boundary_gate_reason || 'n/a'], + ]) + ); + } + if (payload?.decision_support_promotion_summary) { + const promotion = payload.decision_support_promotion_summary; + launchReadinessPanel.appendChild( + buildInfoGrid([ + ['decision support promotion', promotion.status || 'n/a'], + ['strict decision support ready', String(Boolean(promotion.strict_decision_support_ready))], + ['current decision support ready', String(Boolean(promotion.current_decision_support_ready))], + ['execution after promotion', promotion.execution_boundary_after_promotion || 'n/a'], + ['real live after promotion', promotion.real_live_boundary_after_promotion || 'n/a'], + ['promotion model', promotion.candidate_model_code || 'n/a'], + ['promotion classification', promotion.candidate_classification || 'n/a'], + ['operator feedback samples', String(promotion.operator_review_sample_count ?? 0)], + ['promotion criteria', `${promotion.passed_criteria_count ?? 0}/${promotion.required_criteria_count ?? 0}`], + ['failed promotion criteria', formatList(promotion.failed_criteria || [])], + ['next promotion gate', promotion.next_promotion_gate || 'n/a'], + ['promotion summary', promotion.operator_summary || 'n/a'], + ]) + ); + } + if (payload?.shadow_paper_validation_summary) { + const shadowPaper = payload.shadow_paper_validation_summary; + launchReadinessPanel.appendChild( + buildActionBar([ + { label: 'Record Shadow Observation', onClick: () => recordShadowPaperObservation('pending') }, + { label: 'Agree Shadow Observation', onClick: () => recordShadowPaperObservation('agree') }, + { label: 'Disagree Shadow Observation', onClick: () => recordShadowPaperObservation('disagree') }, + ]) + ); + launchReadinessPanel.appendChild( + buildInfoGrid([ + ['shadow paper validation', shadowPaper.status || 'n/a'], + ['shadow validation status', shadowPaper.status || 'n/a'], + ['shadow observed days', `${shadowPaper.observed_shadow_day_count ?? 0}/${shadowPaper.window_days ?? 0}`], + ['shadow observation samples', String(shadowPaper.shadow_observation_count ?? 0)], + ['shadow observation days', String(shadowPaper.shadow_observation_day_count ?? 0)], + ['shadow candidates', String(shadowPaper.candidate_count ?? 0)], + ['paper ticket samples', String(shadowPaper.paper_ticket_count ?? 0)], + ['paper execution samples', String(shadowPaper.paper_execution_count ?? 0)], + ['operator feedback samples', String(shadowPaper.operator_feedback_count ?? 0)], + ['operator observation feedback', String(shadowPaper.operator_observation_feedback_count ?? 0)], + ['journal samples', String(shadowPaper.journal_entry_count ?? 0)], + ['shadow criteria', `${shadowPaper.passed_criteria_count ?? 0}/${shadowPaper.required_criteria_count ?? 0}`], + ['failed shadow criteria', formatList(shadowPaper.failed_criteria || [])], + ['next shadow gate', shadowPaper.next_validation_gate || 'n/a'], + ['sample collection next actions', formatList(shadowPaper.sample_collection_plan?.next_actions || [])], + ['real live after shadow', shadowPaper.execution_boundary || 'real_live_still_blocked'], + ['shadow validation summary', shadowPaper.operator_summary || 'n/a'], + ['shadow daily coverage', formatList((shadowPaper.daily_coverage || []).slice(-5).map((item) => `${item.date}: o${item.shadow_observation_count}/c${item.candidate_count}/p${item.paper_execution_count}/f${item.operator_feedback_count}`))], + ]) + ); + } const grid = createElement('div', 'cards'); (payload?.checklist || []).forEach((item) => { grid.appendChild( @@ -4979,7 +5987,39 @@ function renderCandidates(items) { filtered.forEach((candidate) => { const trustModel = trustModels.get(candidate.model_code) || {}; const executionAllowed = trustModel.execution_allowed !== false && candidate.executable; + const canBuildShadowPaper = !executionAllowed && candidate.executable; const detail = `quality ${formatNumber(candidate.quality_score)} | ${candidate.ai_explanation || 'no explanation'}`; + const ticketActions = []; + if (executionAllowed) { + ticketActions.push( + { + label: 'Build Paper Ticket', + onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, { + execution_mode: 'paper', + account_equity: 10000, + risk_pct: 0.01, + }), + }, + { + label: 'Build Semi-Auto Ticket', + onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, { + execution_mode: 'semi_auto', + account_equity: 10000, + risk_pct: 0.01, + }), + } + ); + } else if (canBuildShadowPaper) { + ticketActions.push({ + label: 'Build Shadow Paper', + onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, { + execution_mode: 'paper', + account_equity: 10000, + risk_pct: 0.01, + allow_shadow_paper: true, + }), + }); + } grid.appendChild( buildItemCard( `Candidate #${candidate.id}`, @@ -4999,24 +6039,7 @@ function renderCandidates(items) { ['management rules', formatJsonInline(candidate.management_rules)], ['evidence links', formatJsonInline(candidate.evidence)], ], - executionAllowed ? [ - { - label: 'Build Paper Ticket', - onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, { - execution_mode: 'paper', - account_equity: 10000, - risk_pct: 0.01, - }), - }, - { - label: 'Build Semi-Auto Ticket', - onClick: () => runAction(`/trading/candidates/${candidate.id}/build-ticket`, { - execution_mode: 'semi_auto', - account_equity: 10000, - risk_pct: 0.01, - }), - }, - ] : [], + ticketActions, detail ) ); diff --git a/tests/unit/test_alert_archive_helpers.py b/tests/unit/test_alert_archive_helpers.py index 1d6893b..a1498fc 100644 --- a/tests/unit/test_alert_archive_helpers.py +++ b/tests/unit/test_alert_archive_helpers.py @@ -4,7 +4,11 @@ import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.api.alerts import ( + _build_archive_bundle_manifest, + _build_archive_selection_provenance_summary, + _build_external_context_surface_history_summary, _build_workflow_archive_artifact_bundle_summary, + _build_workflow_archive_compare_writeback_summary, _build_workflow_archive_handoff_bundle_summary, _build_workflow_archive_handoff_packet_starter, _build_workflow_archive_replay_note_bridge_summary, @@ -62,6 +66,133 @@ class AlertArchiveHelperTests(unittest.TestCase): self.assertEqual(bundle["status"], "active") self.assertEqual(bundle["day_group_count"], 1) + def test_workflow_compare_writeback_summary_persists_note_context(self) -> None: + result = _build_workflow_archive_compare_writeback_summary( + note={ + "id": 21, + "note_scope": "workflow_compare", + "author": "local_operator", + "tags": ["workflow-compare", "session:LONDON"], + "created_ts": "2026-05-16T02:00:00+00:00", + }, + replay_note_bridge_summary={ + "note_title": "Workflow replay compare #9 vs #8", + "note_body": "compare body", + "compare_left_snapshot_id": 9, + "compare_right_snapshot_id": 8, + "changed_phase_count": 2, + }, + compare={"summary": "compare summary"}, + query_context={"profile_scope": "selected_profile", "left_id": 9, "right_id": 8}, + ) + + self.assertEqual(result["status"], "persisted") + self.assertEqual(result["note_id"], 21) + self.assertEqual(result["note_title"], "Workflow replay compare #9 vs #8") + self.assertEqual(result["compare_left_snapshot_id"], 9) + self.assertEqual(result["changed_phase_count"], 2) + + def test_archive_selection_provenance_summary_tracks_scope_filters_and_compare(self) -> None: + result = _build_archive_selection_provenance_summary( + archive={ + "status": "active", + "profile_scope": "all_profiles", + "query_preset": "recent_7d", + "filters": {"query_text": "candidate", "created_after": "", "digest_mode": "high_value_only"}, + "archive_baseline_state": {"digest_snapshot_id": 9}, + "item_count": 5, + }, + compare={"left": {"snapshot_id": 4}, "right": {"snapshot_id": 3}}, + export_scope="selected_compare", + selection_kind="digest_archive", + ) + + self.assertEqual(result["status"], "active") + self.assertEqual(result["export_scope"], "selected_compare") + self.assertEqual(result["active_filter_count"], 2) + self.assertEqual(result["selected_snapshot_ids"], [4, 3]) + self.assertEqual(result["baseline_snapshot_id"], 9) + + def test_archive_bundle_manifest_normalizes_export_sections(self) -> None: + manifest = _build_archive_bundle_manifest( + archive={ + "status": "active", + "profile_scope": "all_profiles", + "query_preset": "recent_7d", + "item_count": 5, + }, + compare={"status": "active"}, + generated_ts="2026-05-17T03:00:00+00:00", + export_kind="digest_archive_bundle", + export_scope="selected_compare", + selection_provenance_summary={ + "active_filter_count": 2, + "selected_snapshot_ids": [4, 3], + "baseline_snapshot_id": 9, + }, + structured_export_summary={"status": "active"}, + ) + + self.assertEqual(manifest["normalization_status"], "normalized") + self.assertEqual(manifest["manifest_version"], "decision_assistant_archive_bundle_manifest_v1") + self.assertEqual(manifest["status"], "active") + self.assertIn("compare", manifest["included_sections"]) + self.assertEqual(manifest["baseline_snapshot_id"], 9) + + def test_external_context_surface_history_summary_includes_e30_resolution_follow_up_fields(self) -> None: + result = _build_external_context_surface_history_summary( + instrument_id=1, + profile_key="local_operator", + history=[ + { + "surface_id": "okx_help_announcements_page", + "title": "okx_help_announcements_page", + "source_group": "exchange_context_surface", + "selection_kind": "target_url", + "target_url": "https://www.okx.com/help", + "selected_ts": "2026-05-16T00:00:00+00:00", + "revisit_acknowledged": True, + "revisit_acknowledged_by": "alice", + "revisit_acknowledged_ts": "2026-05-16T08:00:00+00:00", + }, + { + "surface_id": "tradingview_public_chart_reference", + "title": "tradingview_public_chart_reference", + "source_group": "visual_reference_card", + "selection_kind": "target_url", + "target_url": "https://www.tradingview.com/", + "selected_ts": "2026-05-15T00:00:00+00:00", + "revisit_acknowledged": True, + "revisit_acknowledged_by": "bob", + "revisit_acknowledged_ts": "2026-05-16T07:30:00+00:00", + "revisit_ack_note": "checked", + }, + { + "surface_id": "binance_public_chart_reference", + "title": "binance_public_chart_reference", + "source_group": "visual_reference_card", + "selection_kind": "target_url", + "target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot", + "selected_ts": "2026-05-14T00:00:00+00:00", + "revisit_acknowledged": True, + "revisit_acknowledged_by": "bob", + "revisit_acknowledged_ts": "2026-05-16T07:00:00+00:00", + "revisit_ack_note": "done", + }, + ], + ) + + resolution = result["resolution_filter_summary"] + self.assertIn("resolved_owner_drift_status", resolution) + self.assertIn("carry_forward_follow_up_lane_status", resolution) + self.assertEqual(resolution["resolved_owner_drift_status"], "drift_detected") + self.assertEqual(resolution["carry_forward_follow_up_lane_status"], "active") + self.assertEqual(resolution["resolved_owner_drift_review_status"], "review_required") + self.assertEqual(resolution["resolved_owner_drift_review_queue_status"], "active") + self.assertEqual(resolution["resolved_owner_drift_review_queue_aging_status"], "stale") + self.assertEqual(resolution["carry_forward_follow_up_lane_rollover_status"], "rollover_required") + self.assertEqual(resolution["carry_forward_follow_up_lane_rollover_handoff_status"], "handoff_required") + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 5fac261..7e1ff95 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -199,6 +199,70 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(post_status, 200) self.assertEqual(post_payload["workflow_snapshot_id"], 34) + def test_routes_external_context_surface_history(self) -> None: + get_status, get_payload = route_request( + "/trading/external-context-surface-history", + {"instrument_id": ["1"], "profile_key": ["local_operator"]}, + dependencies={ + "trading_external_context_surface_history": lambda **kwargs: { + "status": "active", + "instrument_id": kwargs["instrument_id"], + "profile_key": kwargs["profile_key"], + "entry_count": 2, + } + }, + ) + post_status, post_payload = route_request( + "/trading/external-context-surface-history", + {}, + method="POST", + body={ + "instrument_id": 1, + "profile_key": "local_operator", + "surface_id": "binance_public_chart_reference", + "title": "binance_public_chart_reference", + "source_group": "visual_reference_card", + "selection_kind": "target_url", + "target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot", + }, + dependencies={ + "trading_external_context_surface_history_save": lambda **kwargs: { + "status": "saved", + "instrument_id": kwargs["instrument_id"], + "profile_key": kwargs["profile_key"], + "latest_selection": {"surface_id": kwargs["surface_id"]}, + } + }, + ) + ack_status, ack_payload = route_request( + "/trading/external-context-surface-history/acknowledge", + {}, + method="POST", + body={ + "instrument_id": 1, + "profile_key": "local_operator", + "surface_id": "binance_public_chart_reference", + "selected_ts": "2026-05-12T02:05:30+00:00", + "note": "checked and cleared", + }, + dependencies={ + "trading_external_context_surface_history_ack": lambda **kwargs: { + "status": "saved", + "instrument_id": kwargs["instrument_id"], + "profile_key": kwargs["profile_key"], + "latest_acknowledgment": {"surface_id": kwargs["surface_id"], "revisit_ack_note": kwargs["note"]}, + } + }, + ) + + self.assertEqual(get_status, 200) + self.assertEqual(get_payload["entry_count"], 2) + self.assertEqual(post_status, 200) + self.assertEqual(post_payload["latest_selection"]["surface_id"], "binance_public_chart_reference") + self.assertEqual(ack_status, 200) + self.assertEqual(ack_payload["latest_acknowledgment"]["surface_id"], "binance_public_chart_reference") + self.assertEqual(ack_payload["latest_acknowledgment"]["revisit_ack_note"], "checked and cleared") + def test_routes_decision_assistant_archive_and_focus(self) -> None: profiles_status, profiles_payload = route_request( "/trading/alerts/digest-preferences/profiles", @@ -224,6 +288,7 @@ class ApiServerRoutingTests(unittest.TestCase): "filters": {"preferred_history_kind": kwargs["preferred_history_kind"], "query_text": kwargs["query_text"]}, "long_range_trend_summary": {"status": "active"}, "baseline_pin_summary": {"status": "active"}, + "ranked_aggregation_summary": {"status": "active"}, } }, ) @@ -234,6 +299,7 @@ class ApiServerRoutingTests(unittest.TestCase): "trading_alert_operator_digest_history_export": lambda **kwargs: { "status": "active", "export_kind": "digest_archive_bundle", + "export_schema_version": "decision_assistant_archive_export_v4", "profile_scope": kwargs["profile_scope"], "query_preset": kwargs["query_preset"], "query_text": kwargs["query_text"], @@ -241,7 +307,10 @@ class ApiServerRoutingTests(unittest.TestCase): "right_id": kwargs["right_id"], "long_range_trend_summary": {"status": "active"}, "baseline_pin_summary": {"status": "active"}, + "ranked_aggregation_summary": {"status": "active"}, + "selection_provenance_summary": {"status": "active", "export_scope": "selected_compare"}, "structured_export_summary": {"status": "active"}, + "bundle_manifest": {"status": "active", "manifest_version": "decision_assistant_archive_bundle_manifest_v1"}, } }, ) @@ -271,28 +340,36 @@ class ApiServerRoutingTests(unittest.TestCase): "filters": {"current_session": kwargs["current_session"], "next_phase": kwargs["next_phase"], "query_text": kwargs["query_text"]}, "long_range_trend_summary": {"status": "active"}, "baseline_pin_summary": {"status": "active"}, + "ranked_aggregation_summary": {"status": "active"}, } }, ) workflow_export_status, workflow_export_payload = route_request( "/trading/workflow-archive/export", - {"instrument_id": ["1"], "profile_scope": ["all_profiles"], "query_preset": ["intraday_recent"], "query_text": ["handoff"], "left_id": ["9"], "right_id": ["8"]}, + {"instrument_id": ["1"], "profile_scope": ["all_profiles"], "query_preset": ["intraday_recent"], "query_text": ["handoff"], "left_id": ["9"], "right_id": ["8"], "group_date": ["2026-05-16"], "session_group": ["LONDON"]}, dependencies={ "trading_workflow_archive_export": lambda **kwargs: { "status": "active", "export_kind": "workflow_archive_bundle", + "export_schema_version": "decision_assistant_archive_export_v4", "profile_scope": kwargs["profile_scope"], "query_preset": kwargs["query_preset"], "query_text": kwargs["query_text"], "left_id": kwargs["left_id"], "right_id": kwargs["right_id"], + "group_date": kwargs["group_date"], + "session_group": kwargs["session_group"], "long_range_trend_summary": {"status": "active"}, "baseline_pin_summary": {"status": "active"}, + "ranked_aggregation_summary": {"status": "active"}, "handoff_bundle_summary": {"status": "active"}, "handoff_packet_starter": {"status": "active"}, "replay_note_bridge_summary": {"status": "active"}, "artifact_bundle_summary": {"status": "active"}, + "group_export_summary": {"status": "active"}, + "selection_provenance_summary": {"status": "active", "export_scope": "selected_compare"}, "structured_export_summary": {"status": "active"}, + "bundle_manifest": {"status": "active", "manifest_version": "decision_assistant_archive_bundle_manifest_v1"}, } }, ) @@ -310,6 +387,21 @@ class ApiServerRoutingTests(unittest.TestCase): } }, ) + workflow_writeback_status, workflow_writeback_payload = route_request( + "/trading/workflow-archive/compare-writeback", + {}, + method="POST", + body={"instrument_id": 1, "profile_key": "desk_a", "left_id": 9, "right_id": 8, "query_text": "handoff"}, + dependencies={ + "trading_workflow_archive_compare_writeback": lambda **kwargs: { + "status": "saved", + "profile_key": kwargs["profile_key"], + "left_id": kwargs["left_id"], + "right_id": kwargs["right_id"], + "compare_writeback_summary": {"status": "persisted", "note_title": "Workflow replay compare #9 vs #8"}, + } + }, + ) focus_get_status, focus_get_payload = route_request( "/trading/assistant-focus", {"instrument_id": ["1"], "profile_key": ["desk_a"]}, @@ -346,13 +438,18 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(digest_archive_payload["filters"]["preferred_history_kind"], "reminder") self.assertEqual(digest_archive_payload["filters"]["query_text"], "candidate") self.assertEqual(digest_archive_payload["long_range_trend_summary"]["status"], "active") + self.assertEqual(digest_archive_payload["ranked_aggregation_summary"]["status"], "active") self.assertEqual(digest_export_status, 200) self.assertEqual(digest_export_payload["export_kind"], "digest_archive_bundle") + self.assertEqual(digest_export_payload["export_schema_version"], "decision_assistant_archive_export_v4") self.assertEqual(digest_export_payload["profile_scope"], "all_profiles") self.assertEqual(digest_export_payload["query_preset"], "recent_7d") self.assertEqual(digest_export_payload["left_id"], 4) self.assertEqual(digest_export_payload["baseline_pin_summary"]["status"], "active") + self.assertEqual(digest_export_payload["ranked_aggregation_summary"]["status"], "active") + self.assertEqual(digest_export_payload["selection_provenance_summary"]["status"], "active") self.assertEqual(digest_export_payload["structured_export_summary"]["status"], "active") + self.assertEqual(digest_export_payload["bundle_manifest"]["manifest_version"], "decision_assistant_archive_bundle_manifest_v1") self.assertEqual(digest_compare_status, 200) self.assertEqual(digest_compare_payload["left_id"], 4) self.assertEqual(digest_compare_payload["profile_scope"], "all_profiles") @@ -364,21 +461,32 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(workflow_archive_payload["filters"]["next_phase"], "intraday") self.assertEqual(workflow_archive_payload["filters"]["query_text"], "handoff") self.assertEqual(workflow_archive_payload["baseline_pin_summary"]["status"], "active") + self.assertEqual(workflow_archive_payload["ranked_aggregation_summary"]["status"], "active") self.assertEqual(workflow_export_status, 200) self.assertEqual(workflow_export_payload["export_kind"], "workflow_archive_bundle") + self.assertEqual(workflow_export_payload["export_schema_version"], "decision_assistant_archive_export_v4") self.assertEqual(workflow_export_payload["profile_scope"], "all_profiles") self.assertEqual(workflow_export_payload["query_preset"], "intraday_recent") + self.assertEqual(workflow_export_payload["group_date"], "2026-05-16") + self.assertEqual(workflow_export_payload["session_group"], "LONDON") self.assertEqual(workflow_export_payload["long_range_trend_summary"]["status"], "active") + self.assertEqual(workflow_export_payload["ranked_aggregation_summary"]["status"], "active") self.assertEqual(workflow_export_payload["handoff_bundle_summary"]["status"], "active") self.assertEqual(workflow_export_payload["handoff_packet_starter"]["status"], "active") self.assertEqual(workflow_export_payload["replay_note_bridge_summary"]["status"], "active") self.assertEqual(workflow_export_payload["artifact_bundle_summary"]["status"], "active") + self.assertEqual(workflow_export_payload["group_export_summary"]["status"], "active") + self.assertEqual(workflow_export_payload["selection_provenance_summary"]["status"], "active") self.assertEqual(workflow_export_payload["structured_export_summary"]["status"], "active") + self.assertEqual(workflow_export_payload["bundle_manifest"]["manifest_version"], "decision_assistant_archive_bundle_manifest_v1") self.assertEqual(workflow_compare_status, 200) self.assertEqual(workflow_compare_payload["profile_scope"], "all_profiles") self.assertEqual(workflow_compare_payload["query_preset"], "intraday_recent") self.assertEqual(workflow_compare_payload["right_id"], 8) self.assertEqual(workflow_compare_payload["query_text"], "handoff") + self.assertEqual(workflow_writeback_status, 200) + self.assertEqual(workflow_writeback_payload["profile_key"], "desk_a") + self.assertEqual(workflow_writeback_payload["compare_writeback_summary"]["status"], "persisted") self.assertEqual(focus_get_status, 200) self.assertEqual(focus_get_payload["focus_candidate_id"], 18) self.assertEqual(focus_post_status, 200) @@ -634,6 +742,24 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertFalse(payload["ready_for_real_live"]) self.assertEqual(payload["timeframe"], "5m") + def test_routes_trading_okx_sandbox_preflight(self) -> None: + status, payload = route_request( + "/trading/okx-sandbox-preflight", + {"instrument_id": ["1"], "timeframe": ["5m"]}, + dependencies={ + "trading_okx_sandbox_preflight": lambda **kwargs: { + "status": "sandbox_preflight_ready", + "timeframe": kwargs["timeframe"], + "next_gate": "sandbox_submit_cancel_reconcile_smoke", + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["status"], "sandbox_preflight_ready") + self.assertEqual(payload["timeframe"], "5m") + self.assertEqual(payload["next_gate"], "sandbox_submit_cancel_reconcile_smoke") + def test_routes_trading_launch_readiness(self) -> None: status, payload = route_request( "/trading/launch-readiness", @@ -647,6 +773,56 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertFalse(payload["decision_support_ready"]) self.assertEqual(payload["timeframe"], "5m") + def test_routes_trading_shadow_paper_validation(self) -> None: + status, payload = route_request( + "/trading/shadow-paper-validation", + {"instrument_id": ["1"], "timeframe": ["5m"]}, + dependencies={"trading_shadow_paper_validation": lambda **kwargs: {"status": "collecting_paper_samples", "timeframe": kwargs["timeframe"]}}, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["status"], "collecting_paper_samples") + self.assertEqual(payload["timeframe"], "5m") + + def test_routes_trading_shadow_paper_observation_record(self) -> None: + status, payload = route_request( + "/trading/shadow-paper/observe", + {}, + method="POST", + body={ + "instrument_id": 1, + "timeframe": "5m", + "session_code": "NY_AM", + "candidate_id": 7, + "paper_ticket_id": 11, + "paper_execution_id": 21, + "operator_agreement": "agree", + "operator_note": "useful paper sample", + "created_by": "alice", + }, + dependencies={ + "trading_shadow_paper_observation_record": lambda **kwargs: { + "status": "recorded", + "instrument_id": kwargs["instrument_id"], + "timeframe": kwargs["timeframe"], + "candidate_id": kwargs["candidate_id"], + "paper_ticket_id": kwargs["paper_ticket_id"], + "paper_execution_id": kwargs["paper_execution_id"], + "operator_agreement": kwargs["operator_agreement"], + "created_by": kwargs["created_by"], + } + }, + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["status"], "recorded") + self.assertEqual(payload["timeframe"], "5m") + self.assertEqual(payload["candidate_id"], 7) + self.assertEqual(payload["paper_ticket_id"], 11) + self.assertEqual(payload["paper_execution_id"], 21) + self.assertEqual(payload["operator_agreement"], "agree") + self.assertEqual(payload["created_by"], "alice") + def test_routes_trading_broker_state(self) -> None: status, payload = route_request( "/trading/broker-state", @@ -694,7 +870,7 @@ class ApiServerRoutingTests(unittest.TestCase): "/trading/candidates/4/build-ticket", {}, method="POST", - body={"execution_mode": "semi_auto"}, + body={"execution_mode": "semi_auto", "allow_shadow_paper": False}, dependencies={"trading_candidate_build_ticket": lambda **kwargs: {"ticket_id": 11, **kwargs}}, ) status_get, payload_get = route_request( @@ -719,6 +895,7 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(status_build, 200) self.assertEqual(payload_build["ticket_id"], 11) + self.assertFalse(payload_build["allow_shadow_paper"]) self.assertEqual(status_get, 200) self.assertEqual(payload_get["status"], "awaiting_confirmation") self.assertEqual(status_risk, 200) diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 64bcad1..704f4b1 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -155,6 +155,86 @@ class ApiStaticServerTests(unittest.TestCase): "Handoff Bridge", "Clear Workflow Bridge", "provider health cards", + "provider health story", + "visual bridge story", + "surface history", + "surface history count", + "surface history recency", + "surface history cadence", + "surface cadence story", + "revisit queue count", + "cleared queue status", + "cleared queue story", + "resolution history count", + "latest acknowledged by", + "latest acknowledged note", + "surface revisit prompt", + "next revisit prompt", + "resolution review prompt", + "surface history story", + "Reopen Surface", + "Reopen Latest Surface", + "Reopen Revisit", + "acknowledged revisits", + "Acknowledge Revisit", + "Acknowledge With Note", + "Reopen Cleared Surface", + "resolution notes", + "resolution note gaps", + "note gap queue", + "note gap oldest", + "note gap avg age", + "resolution owners", + "owner spotlight", + "owner spotlight count", + "owner cadence", + "owner cadence avg gap", + "owner cadence story", + "owner trend", + "owner trend story", + "owner handoff", + "owner handoff story", + "owner handoff follow-up", + "owner handoff follow-up story", + "resolved owner drift", + "resolved owner drift story", + "resolved owner drift review", + "resolved owner drift review story", + "drift review queue", + "drift review queue story", + "drift review queue aging", + "drift review queue aging story", + "active resolution filter", + "All Resolved", + "With Resolution Note", + "Without Resolution Note", + "Add Resolution Note", + "Reopen Note Gap", + "owner spotlight prompt", + "priority lane status", + "priority lane count", + "priority lane story", + "priority lane carry-forward", + "priority lane carry-forward story", + "carry-forward aging", + "carry-forward aging story", + "follow-up lane status", + "follow-up lane count", + "follow-up lane story", + "follow-up lane rollover", + "follow-up lane rollover story", + "rollover handoff", + "rollover handoff story", + "priority lane prompt", + "rollover prompt", + "rollover handoff prompt", + "Open Priority Surface", + "Open Follow-Up Lane", + "Open Rollover Surface", + "Open Drift Review", + "Open Rollover Handoff", + "dominant surface", + "dominant surface story", "Open Visual Bridge", "Open Capture Surface", "Open Screenshot Surface", @@ -162,13 +242,85 @@ class ApiStaticServerTests(unittest.TestCase): "Open Embed", "Open Capture Target", "Open Capture Embed", + "capture surface story", "screenshot readiness", + "screenshot story", "screenshot status", "browser capture", + "export provenance", + "workflow export provenance", + "bundle manifest", + "manifest sections", + "workflow bundle manifest", + "workflow manifest sections", + "selection fingerprint", + "Read-only Live Analysis", + "read-only live analysis", + "ready_for_readonly_live_analysis", + "must_not_trade", + "No order submission", + "Not a buy/sell instruction", + "Execution remains disabled", + "live analysis assistant ready", + "readonly live health", + "broker/account context", + "latest decision packet timestamp", + "latest review packet timestamp", + "operator boundary", + "boundary summary", + "current model boundary", + "current model reason", + "live execution boundary", + "live execution reason", + "allowed operator actions", + "blocked operator actions", + "boundary next gate", + "boundary gate reason", + "decision support promotion", + "strict decision support ready", + "current decision support ready", + "promotion model", + "promotion classification", + "operator feedback samples", + "promotion criteria", + "failed promotion criteria", + "next promotion gate", + "promotion summary", + "shadow paper validation", + "shadow validation status", + "shadow observed days", + "shadow observation samples", + "shadow observation days", + "paper ticket samples", + "paper execution samples", + "operator observation feedback", + "Record Shadow Observation", + "Agree Shadow Observation", + "Disagree Shadow Observation", + "Build Shadow Paper", + "allow_shadow_paper", + "failed shadow criteria", + "next shadow gate", + "sample collection next actions", + "real live after shadow", + "shadow validation summary", + "shadow daily coverage", + "okx sandbox preflight", + "sandbox next gate", + "simulated trading header", + "submit dry-run", + "cancel dry-run", + "query order dry-run", + "query fills dry-run", + "idempotency key", + "error normalization", + "real live after sandbox", "archive trend", "archive trend story", "archive baseline", "archive baseline persistence", + "archive baseline delta", + "archive baseline delta story", "Pin Baseline", "Compare To Baseline", "Save Baseline", @@ -181,13 +333,29 @@ class ApiStaticServerTests(unittest.TestCase): "workflow baseline", "workflow trend story", "workflow baseline persistence", + "archive operator overview", + "overview story", + "archive top profile", + "archive top kind", + "workflow top profile", + "workflow top session", + "workflow top phase", + "workflow baseline delta", + "workflow baseline delta story", + "digest baseline delta", + "digest baseline delta story", "Profile Scope", "Query Preset", "Prefill Compare Replay Note", + "Persist Compare Replay Note", "Export Handoff Starter", "Export Artifact Bundle", + "Export Day Bundle", + "Export Session Bundle", "Persist Bridge State", + "Prefill Saved Compare Note", "bridge persistence", + "bridge saved compare note", ): self.assertIn(expected, app_js) diff --git a/tests/unit/test_archive_long_range_projection.py b/tests/unit/test_archive_long_range_projection.py index 1e65968..236fbbc 100644 --- a/tests/unit/test_archive_long_range_projection.py +++ b/tests/unit/test_archive_long_range_projection.py @@ -43,6 +43,8 @@ class ArchiveLongRangeProjectionTests(unittest.TestCase): self.assertEqual(baseline["latest_snapshot_id"], 5) self.assertEqual(baseline["baseline_snapshot_id"], 1) self.assertTrue(baseline["compare_ready"]) + self.assertEqual(baseline["baseline_delta_status"], "changed") + self.assertIn("high-value delta", baseline["baseline_delta_story"]) def test_workflow_long_range_and_baseline_summary(self) -> None: items = [ @@ -80,6 +82,8 @@ class ArchiveLongRangeProjectionTests(unittest.TestCase): self.assertEqual(baseline["latest_snapshot_id"], 9) self.assertEqual(baseline["baseline_snapshot_id"], 3) self.assertTrue(baseline["suggested_compare_prompt"]) + self.assertEqual(baseline["baseline_delta_status"], "changed") + self.assertIn("ready delta", baseline["baseline_delta_story"]) def test_empty_items_return_idle(self) -> None: self.assertEqual(build_digest_archive_long_range_summary([])["status"], "idle") diff --git a/tests/unit/test_archive_retrieval_projection.py b/tests/unit/test_archive_retrieval_projection.py new file mode 100644 index 0000000..1c3dcd8 --- /dev/null +++ b/tests/unit/test_archive_retrieval_projection.py @@ -0,0 +1,38 @@ +import unittest + +from src.services.operator.archive_retrieval_projection import ( + build_digest_archive_ranked_aggregation, + build_workflow_archive_ranked_aggregation, +) + + +class ArchiveRetrievalProjectionTests(unittest.TestCase): + def test_digest_ranked_aggregation(self) -> None: + items = [ + {"profile_key": "desk_b", "preferred_history_kind": "note"}, + {"profile_key": "desk_a", "preferred_history_kind": "reminder"}, + {"profile_key": "desk_a", "preferred_history_kind": "reminder"}, + ] + result = build_digest_archive_ranked_aggregation(items) + + self.assertEqual(result["status"], "active") + self.assertEqual(result["top_profile"]["value"], "desk_a") + self.assertEqual(result["top_history_kind"]["value"], "reminder") + + def test_workflow_ranked_aggregation(self) -> None: + items = [ + {"profile_key": "desk_a", "current_session": "LONDON", "next_phase": "intraday", "next_artifact_kind": "intraday_digest"}, + {"profile_key": "desk_a", "current_session": "LONDON", "next_phase": "intraday", "next_artifact_kind": "intraday_digest"}, + {"profile_key": "desk_b", "current_session": "NY_AM", "next_phase": "post_trade", "next_artifact_kind": "post_trade_recap"}, + ] + result = build_workflow_archive_ranked_aggregation(items) + + self.assertEqual(result["status"], "active") + self.assertEqual(result["top_profile"]["value"], "desk_a") + self.assertEqual(result["top_session"]["value"], "LONDON") + self.assertEqual(result["top_phase"]["value"], "intraday") + self.assertEqual(result["top_artifact"]["value"], "intraday_digest") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index 392f9d7..9144ed9 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -18,7 +18,9 @@ OLD_TRAINING_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md NONEXISTENT_VISUAL_PLAN_NAME = "AI_ICT_" + "视觉盯盘与主动告警增强方案_v1.md" ACTIVE_TOP_LEVEL_DOCS = { + "AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md", "AI_ICT_分析助手主线增强开发计划_2026-05-14_v1.md", + "AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md", "AI_ICT_全自动开发执行流程_v1.md", "AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md", "AI_ICT_实盘交易分析系统开发计划_v1.md", diff --git a/tests/unit/test_launch_readiness_service.py b/tests/unit/test_launch_readiness_service.py index 54374fc..45584f1 100644 --- a/tests/unit/test_launch_readiness_service.py +++ b/tests/unit/test_launch_readiness_service.py @@ -15,6 +15,7 @@ class LaunchReadinessServiceTests(unittest.TestCase): weekly_review_service=_WeeklyReviewService(), trust_service=_TrustService(), live_readiness_service=_LiveReadinessService(), + shadow_paper_validation_service=_ShadowPaperValidationService(), ) with ( @@ -24,6 +25,11 @@ class LaunchReadinessServiceTests(unittest.TestCase): payload = service.build_summary(instrument_id=1, timeframe="1m") self.assertTrue(payload["analysis_only_ready"]) + self.assertTrue(payload["live_analysis_assistant_ready"]) + self.assertEqual(payload["live_analysis_assistant_summary"]["status"], "ready_for_readonly_live_analysis") + self.assertTrue(payload["live_analysis_assistant_summary"]["must_not_trade"]) + self.assertFalse(payload["live_analysis_assistant_summary"]["execution_allowed"]) + self.assertEqual(payload["readonly_live_health_summary"]["broker_account_context"], "available") self.assertTrue(payload["assisted_trading_ready"]) self.assertTrue(payload["operator_soft_rollout_ready"]) self.assertEqual(payload["operator_soft_rollout_mode"], "execution_assist") @@ -34,6 +40,20 @@ class LaunchReadinessServiceTests(unittest.TestCase): 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"]) + self.assertEqual(payload["decision_support_boundary_summary"]["status"], "execution_assist_available") + self.assertIn("review_market_context", payload["decision_support_boundary_summary"]["allowed_operator_actions"]) + self.assertIn("submit_live_orders", payload["decision_support_boundary_summary"]["blocked_operator_actions"]) + self.assertEqual( + payload["decision_support_promotion_summary"]["status"], + "ready_for_decision_support_non_execution", + ) + self.assertTrue(payload["decision_support_promotion_summary"]["strict_decision_support_ready"]) + self.assertEqual( + payload["decision_support_promotion_summary"]["execution_boundary_after_promotion"], + "execution_still_blocked", + ) + self.assertEqual(payload["shadow_paper_validation_summary"]["status"], "collecting_paper_samples") + self.assertEqual(payload["shadow_paper_validation_summary"]["next_validation_gate"], "paper_ticket_sample_gate") def test_build_summary_defers_real_trade_sample_gate_for_analysis_decision_phase(self) -> None: service = LaunchReadinessService( @@ -42,6 +62,7 @@ class LaunchReadinessServiceTests(unittest.TestCase): weekly_review_service=_WeeklyReviewService(), trust_service=_TrustService(), live_readiness_service=_LiveReadinessService(), + shadow_paper_validation_service=_ShadowPaperValidationService(), ) with ( @@ -57,6 +78,36 @@ class LaunchReadinessServiceTests(unittest.TestCase): 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"]) + self.assertEqual(payload["decision_support_boundary_summary"]["live_execution_boundary"], "live_submit_blocked") + self.assertIn("decision_support_promotion_summary", payload) + self.assertIn("shadow_paper_validation_summary", payload) + + def test_live_analysis_assistant_ready_when_account_context_unavailable(self) -> None: + service = LaunchReadinessService( + market_state_service=_MarketStateService(), + broker_read_model_service=_BrokerReadService(status="broker_state_unavailable"), + weekly_review_service=_WeeklyReviewService(), + trust_service=_TrustService(), + live_readiness_service=_LiveReadinessService(), + shadow_paper_validation_service=_ShadowPaperValidationService(), + ) + + with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()): + payload = service.build_summary(instrument_id=1, timeframe="1m") + + self.assertFalse(payload["analysis_only_ready"]) + self.assertTrue(payload["live_analysis_assistant_ready"]) + self.assertEqual(payload["product_operating_mode"], "readonly_live_analysis") + self.assertEqual(payload["readonly_account_context_status"], "unavailable") + self.assertIn("broker_state_ready", payload["blocking_reasons"]) + self.assertIn("account_context_unavailable", payload["live_analysis_assistant_summary"]["warnings"]) + self.assertEqual(payload["live_analysis_assistant_summary"]["blockers"], []) + self.assertEqual(payload["decision_support_boundary_summary"]["status"], "readonly_live_analysis_available") + self.assertIn("review_market_context", payload["decision_support_boundary_summary"]["allowed_operator_actions"]) + self.assertIn("submit_live_orders", payload["live_analysis_assistant_summary"]["blocked_actions"]) + self.assertFalse(payload["decision_support_ready"]) + self.assertFalse(payload["execution_assist_ready"]) + self.assertFalse(payload["real_live_ready"]) def test_build_summary_exposes_restricted_soft_rollout_when_only_blacklist_models_exist(self) -> None: service = LaunchReadinessService( @@ -65,6 +116,7 @@ class LaunchReadinessServiceTests(unittest.TestCase): weekly_review_service=_WeeklyReviewService(), trust_service=_BlacklistOnlyTrustService(), live_readiness_service=_LiveReadinessService(), + shadow_paper_validation_service=_ShadowPaperValidationService(), ) with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()): @@ -78,6 +130,17 @@ class LaunchReadinessServiceTests(unittest.TestCase): 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"]) + self.assertEqual(payload["decision_support_boundary_summary"]["status"], "restricted_assisted_analysis_available") + self.assertEqual(payload["decision_support_boundary_summary"]["current_model_boundary"], "observe_only_current_model") + self.assertEqual( + payload["decision_support_boundary_summary"]["next_boundary_gate"], + "promote_model_out_of_observe_only_or_blacklist", + ) + self.assertEqual(payload["decision_support_promotion_summary"]["status"], "needs_trust_promotion") + self.assertIn( + "decision_support_trust_candidate", + payload["decision_support_promotion_summary"]["failed_criteria"], + ) class _MarketState: @@ -100,25 +163,46 @@ class _MarketStateService: class _BrokerState: - status = "broker_state_ok" + def __init__(self, status: str = "broker_state_ok") -> None: + self.status = status def to_dict(self): - return {"status": "broker_state_ok"} + return {"status": self.status} class _BrokerReadService: + def __init__(self, status: str = "broker_state_ok") -> None: + self.status = status + def fetch_snapshot(self, **kwargs): - return _BrokerState() + return _BrokerState(self.status) class _WeeklyReviewService: def build_summary(self, **kwargs): - return {"real_trade_count": 0} + return { + "real_trade_count": 0, + "entry_count": 5, + "framework_execution_rate": 0.8, + "risk_violation_count": 0, + } class _TrustService: def build_summary(self, **kwargs): return { + "models": [ + { + "model_code": "OSOK", + "classification": "whitelist", + "sample_size": 6, + "quality_gate_passed": True, + "expectancy_r": 0.4, + "framework_execution_rate": 0.8, + "operator_mode": "trade_inside_framework", + "reasons": ["backtest_quality_gate_passed"], + } + ], "whitelist": ["OSOK"], "observe_list": [], "blacklist": [], @@ -132,6 +216,18 @@ class _TrustService: class _BlacklistOnlyTrustService: def build_summary(self, **kwargs): return { + "models": [ + { + "model_code": "SWEEP_MSS_FVG", + "classification": "blacklist", + "sample_size": 1, + "quality_gate_passed": False, + "expectancy_r": 0.0, + "framework_execution_rate": 0.2, + "operator_mode": "observe_only", + "reasons": ["backtest_quality_gate_failed"], + } + ], "whitelist": [], "observe_list": [], "blacklist": ["SWEEP_MSS_FVG"], @@ -152,5 +248,19 @@ class _RuntimeService: return {"scheduler_state": "running", "current_blockers": []} +class _ShadowPaperValidationService: + def build_summary(self, **kwargs): + return { + "status": "collecting_paper_samples", + "next_validation_gate": "paper_ticket_sample_gate", + "execution_boundary": "real_live_still_blocked", + "candidate_count": 3, + "recent_samples": { + "candidates": [{"created_ts": "2026-05-18T01:00:00+00:00"}], + "shadow_observations": [{"observed_ts": "2026-05-18T01:05:00+00:00"}], + }, + } + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_live_readiness_service.py b/tests/unit/test_live_readiness_service.py index 25247c3..30e3a55 100644 --- a/tests/unit/test_live_readiness_service.py +++ b/tests/unit/test_live_readiness_service.py @@ -15,6 +15,7 @@ class LiveReadinessServiceTests(unittest.TestCase): execution_repository=_ExecutionRepo(), decision_packet_service=_DecisionPacketService(), trust_service=_TrustService(), + okx_sandbox_preflight_service=_OKXSandboxPreflightService(), ) with patch("src.services.broker.live_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()): @@ -22,6 +23,8 @@ class LiveReadinessServiceTests(unittest.TestCase): self.assertFalse(payload["ready_for_real_live"]) self.assertIn("adapter_real_live_not_ready", payload["blocking_reasons"]) + self.assertIn("okx_sandbox_preflight_summary", payload) + self.assertEqual(payload["okx_sandbox_preflight_summary"]["status"], "sandbox_preflight_ready") self.assertEqual(payload["operator_confirmation_protocol"]["protocol_version"], "live-confirmation-v1") self.assertEqual(payload["live_order_schema"]["schema_version"], "live-order-v1") @@ -79,5 +82,15 @@ class _RuntimeService: return {"scheduler_state": "running", "current_blockers": []} +class _OKXSandboxPreflightService: + def build_summary(self, **kwargs): + return { + "status": "sandbox_preflight_ready", + "dry_run_ready": True, + "next_gate": "sandbox_submit_cancel_reconcile_smoke", + "execution_boundary": "real_live_still_blocked", + } + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_market_truth_runtime.py b/tests/unit/test_market_truth_runtime.py index adb8bff..9ef43c6 100644 --- a/tests/unit/test_market_truth_runtime.py +++ b/tests/unit/test_market_truth_runtime.py @@ -96,6 +96,42 @@ class MarketDataStreamServiceTests(unittest.TestCase): self.assertTrue(gap_state["is_gap"]) self.assertEqual(gap_state["code"], DATA_GAP_CODE_DETECTED) + def test_stream_service_falls_back_to_direct_when_proxy_attempt_never_connects(self) -> None: + calls = [] + service = None + + class _FakeWebSocket: + def __init__(self, *args, **kwargs): + pass + + def run_forever(self, **kwargs): + calls.append(dict(kwargs)) + if len(calls) >= 2: + service._stop_event.set() + + def close(self): + return None + + service = MarketDataStreamService( + url="wss://example.test", + proxy_url="http://127.0.0.1:1080", + reconnect_backoff_seconds=1, + ws_factory=_FakeWebSocket, + ) + service.subscribe( + instrument_id=1, + symbol="BTC-USDT", + inst_id="BTC-USDT-SWAP", + timeframe="1m", + ) + + service._run_loop() + + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0]["http_proxy_host"], "127.0.0.1") + self.assertEqual(calls[0]["http_proxy_port"], 1080) + self.assertNotIn("http_proxy_host", calls[1]) + class EconomicCalendarProviderTests(unittest.TestCase): def test_provider_marks_active_high_impact_event(self) -> None: diff --git a/tests/unit/test_okx_sandbox_preflight_service.py b/tests/unit/test_okx_sandbox_preflight_service.py new file mode 100644 index 0000000..a5eab96 --- /dev/null +++ b/tests/unit/test_okx_sandbox_preflight_service.py @@ -0,0 +1,219 @@ +import os +import unittest +from io import BytesIO +from datetime import datetime, timezone +from urllib.error import HTTPError +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.config.settings import settings +from src.services.broker.okx_sandbox_preflight_service import ( + OKXSandboxPreflightService, + build_okx_client_order_id, + normalize_okx_order_error, +) + + +class OKXSandboxPreflightServiceTests(unittest.TestCase): + def test_build_summary_waits_for_sandbox_credentials_without_live_submit(self) -> None: + service = OKXSandboxPreflightService(api_key="", api_secret="", api_passphrase="") + + with patch.object(settings, "live_trading_enabled", False): + payload = service.build_summary( + instrument_id=1, + timeframe="1m", + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["status"], "waiting_for_sandbox_credentials") + self.assertFalse(payload["dry_run_ready"]) + self.assertFalse(payload["sandbox_credentials_present"]) + self.assertEqual(payload["sandbox_header"], {"x-simulated-trading": "1"}) + self.assertEqual(payload["next_gate"], "okx_sandbox_credentials") + self.assertEqual(payload["execution_boundary"], "real_live_still_blocked") + self.assertIn("okx_sandbox_credentials_present", payload["failed_criteria"]) + self.assertEqual(payload["signed_request_previews"][0]["operation"], "submit_order") + self.assertFalse(payload["signed_request_previews"][0]["signed"]) + + def test_build_summary_builds_signed_dry_run_request_previews(self) -> None: + service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass") + + with patch.object(settings, "live_trading_enabled", False): + payload = service.build_summary( + instrument_id=2, + timeframe="5m", + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["status"], "sandbox_preflight_ready") + self.assertTrue(payload["dry_run_ready"]) + self.assertTrue(payload["submit_dry_run_ready"]) + self.assertTrue(payload["cancel_dry_run_ready"]) + self.assertTrue(payload["query_order_dry_run_ready"]) + self.assertTrue(payload["query_fills_dry_run_ready"]) + self.assertEqual(payload["okx_inst_id"], "ETH-USDT-SWAP") + self.assertEqual(payload["next_gate"], "sandbox_submit_cancel_reconcile_smoke") + operations = {item["operation"]: item for item in payload["signed_request_previews"]} + self.assertIn("OK-ACCESS-SIGN", operations["submit_order"]["header_names"]) + self.assertIn("x-simulated-trading", operations["cancel_order"]["header_names"]) + self.assertTrue(operations["query_order"]["request_path"].startswith("/api/v5/trade/order?")) + self.assertEqual(payload["bracket_semantics"]["schema_version"], "bracket-v1") + self.assertIn("insufficient_balance", payload["error_normalization"]) + + def test_live_switch_enabled_keeps_preflight_in_operator_review(self) -> None: + service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass") + + with patch.object(settings, "live_trading_enabled", True): + payload = service.build_summary( + instrument_id=1, + timeframe="1m", + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["status"], "live_switch_enabled_requires_operator_review") + self.assertIn("okx_live_submit_still_disabled", payload["failed_criteria"]) + self.assertEqual(payload["execution_boundary"], "real_live_still_blocked") + + def test_client_order_id_is_bounded_and_deterministic(self) -> None: + value = build_okx_client_order_id( + instrument_id=12, + timeframe="15m", + observed_ts=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + ) + + self.assertEqual(value, "AI1215M20260517010203") + self.assertLessEqual(len(value), 32) + + def test_normalizes_okx_order_errors(self) -> None: + self.assertEqual( + normalize_okx_order_error(transport_error="connection refused")["category"], + "network_error", + ) + self.assertEqual( + normalize_okx_order_error(payload={"code": "51008", "msg": "Insufficient balance"})["category"], + "insufficient_balance", + ) + self.assertEqual( + normalize_okx_order_error(payload={"code": "51001", "msg": "Parameter error"})["category"], + "parameter_error", + ) + self.assertEqual( + normalize_okx_order_error(payload={"code": "50120", "msg": "This API key doesn't have permission to use this function"})["category"], + "permission_error", + ) + self.assertEqual( + normalize_okx_order_error(payload={"code": "51100", "msg": "Rejected"})["category"], + "exchange_rejected", + ) + + def test_isolated_smoke_requires_explicit_execute_flag(self) -> None: + service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass") + calls = [] + + def opener(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("opener should not be called when execute is false") + + with patch.object(settings, "live_trading_enabled", False): + payload = service.run_isolated_smoke( + instrument_id=1, + timeframe="1m", + execute=False, + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + opener=opener, + ) + + self.assertEqual(payload["status"], "blocked") + self.assertEqual(payload["blockers"], ["okx_sandbox_smoke_execute_not_enabled"]) + self.assertEqual(payload["next_gate"], "enable_explicit_sandbox_smoke_execute") + self.assertEqual(payload["steps"], []) + self.assertEqual(calls, []) + + def test_isolated_smoke_passes_submit_query_cancel_fills_sequence(self) -> None: + service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass") + seen_operations = [] + + def opener(request, timeout_seconds): + seen_operations.append((request.get_method(), request.selector, timeout_seconds)) + if request.selector.startswith("/api/v5/trade/order?"): + return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "ordId": "123", "state": "live"}]}) + if request.selector == "/api/v5/trade/order": + return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]}) + if request.selector == "/api/v5/trade/cancel-order": + return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]}) + if request.selector.startswith("/api/v5/trade/fills?"): + return _FakeHTTPResponse({"code": "0", "msg": "", "data": []}) + raise AssertionError(f"unexpected request {request.get_method()} {request.selector}") + + with patch.object(settings, "live_trading_enabled", False): + payload = service.run_isolated_smoke( + instrument_id=1, + timeframe="1m", + execute=True, + order_price="1", + order_size="1", + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + timeout_seconds=7, + opener=opener, + ) + + self.assertEqual(payload["status"], "passed") + self.assertEqual([item["operation"] for item in payload["steps"]], [ + "submit_order", + "query_order_after_submit", + "cancel_order", + "query_order_after_cancel", + "query_fills", + ]) + self.assertEqual([item[2] for item in seen_operations], [7, 7, 7, 7, 7]) + self.assertTrue(all(item["simulated_trading_header"] == "1" for item in payload["steps"])) + self.assertEqual(payload["execution_boundary"], "real_live_still_blocked") + self.assertEqual(payload["reconcile_check"]["status"], "ready") + + def test_isolated_smoke_normalizes_http_error(self) -> None: + service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass") + + def opener(request, timeout_seconds): + raise HTTPError( + url=request.full_url, + code=400, + msg="Bad Request", + hdrs=None, + fp=BytesIO(b'{"code":"51001","msg":"Parameter error"}'), + ) + + with patch.object(settings, "live_trading_enabled", False): + payload = service.run_isolated_smoke( + instrument_id=1, + timeframe="1m", + execute=True, + now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc), + opener=opener, + ) + + self.assertEqual(payload["status"], "blocked") + self.assertEqual(payload["blockers"], ["okx_sandbox_submit_failed"]) + self.assertEqual(payload["steps"][0]["http_status"], 400) + self.assertEqual(payload["steps"][0]["normalized_error"]["category"], "parameter_error") + + +class _FakeHTTPResponse: + def __init__(self, payload: dict, status: int = 200) -> None: + self.payload = payload + self.status = status + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def read(self) -> bytes: + import json + + return json.dumps(self.payload).encode("utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_okx_sandbox_smoke_script.py b/tests/unit/test_okx_sandbox_smoke_script.py new file mode 100644 index 0000000..4da8820 --- /dev/null +++ b/tests/unit/test_okx_sandbox_smoke_script.py @@ -0,0 +1,86 @@ +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from scripts import run_okx_sandbox_smoke + + +class OKXSandboxSmokeScriptTests(unittest.TestCase): + def test_main_writes_passed_json_artifact(self) -> None: + payload = { + "status": "passed", + "execute_requested": True, + "blockers": [], + "steps": [{"operation": "submit_order", "ok": True}], + } + service = Mock() + service.run_isolated_smoke.return_value = payload + output = io.StringIO() + + with tempfile.TemporaryDirectory() as tmpdir: + artifact = Path(tmpdir) / "smoke.json" + with patch("scripts.run_okx_sandbox_smoke.OKXSandboxPreflightService", return_value=service): + with contextlib.redirect_stdout(output): + exit_code = run_okx_sandbox_smoke.main( + env={ + "OKX_SANDBOX_SMOKE_OUTPUT_JSON": "1", + "OKX_SANDBOX_SMOKE_EXECUTE": "1", + "OKX_SANDBOX_SMOKE_INSTRUMENT_ID": "2", + "OKX_SANDBOX_SMOKE_TIMEFRAME": "5m", + "OKX_SANDBOX_SMOKE_PRICE": "1.2", + "OKX_SANDBOX_SMOKE_SIZE": "3", + "OKX_SANDBOX_SMOKE_SIDE": "sell", + "OKX_SANDBOX_SMOKE_ORDER_TYPE": "limit", + "OKX_SANDBOX_SMOKE_TD_MODE": "cross", + "OKX_SANDBOX_SMOKE_TIMEOUT_SECONDS": "4.5", + "OKX_SANDBOX_SMOKE_ARTIFACT_PATH": str(artifact), + } + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(output.getvalue())["status"], "passed") + self.assertEqual(json.loads(artifact.read_text(encoding="utf-8"))["status"], "passed") + service.run_isolated_smoke.assert_called_once_with( + instrument_id=2, + timeframe="5m", + execute=True, + order_price="1.2", + order_size="3", + order_side="sell", + order_type="limit", + td_mode="cross", + timeout_seconds=4.5, + ) + + def test_main_returns_nonzero_for_blocked_smoke(self) -> None: + payload = { + "status": "blocked", + "execute_requested": False, + "blockers": ["okx_sandbox_smoke_execute_not_enabled"], + "steps": [], + } + service = Mock() + service.run_isolated_smoke.return_value = payload + output = io.StringIO() + + with tempfile.TemporaryDirectory() as tmpdir: + artifact = Path(tmpdir) / "smoke.json" + with patch("scripts.run_okx_sandbox_smoke.OKXSandboxPreflightService", return_value=service): + with contextlib.redirect_stdout(output): + exit_code = run_okx_sandbox_smoke.main( + env={ + "OKX_SANDBOX_SMOKE_ARTIFACT_PATH": str(artifact), + } + ) + + self.assertEqual(exit_code, 2) + self.assertIn("okx_sandbox_smoke_execute_not_enabled", output.getvalue()) + self.assertEqual(json.loads(artifact.read_text(encoding="utf-8"))["status"], "blocked") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 0d96e04..e5b83e2 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -203,11 +203,18 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): ) self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_bridge_summary"]["status"], "active") self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_bridge_persistence_summary"]["status"], "persisted") + self.assertEqual( + summary["workflow_artifact_summary"]["workflow_archive_bridge_persistence_summary"]["compare_writeback_summary"]["note_title"], + "Saved compare note", + ) self.assertIn("long_range_trend_summary", summary["workflow_artifact_summary"]["workflow_history_archive"]) self.assertIn("baseline_pin_summary", summary["workflow_artifact_summary"]["workflow_history_archive"]) + self.assertIn("ranked_aggregation_summary", summary["workflow_artifact_summary"]["workflow_history_archive"]) self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["long_range_trend_summary"]["status"], "active") self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history") self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]["persisted_baseline_snapshot_id"], 10) + self.assertIn("baseline_delta_story", summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]) + self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["ranked_aggregation_summary"]["status"], "active") self.assertIn("daily_groups", summary["workflow_artifact_summary"]["workflow_history_archive"]) self.assertIn("session_groups", summary["workflow_artifact_summary"]["workflow_history_archive"]) self.assertIn("bridge_summary", summary["workflow_artifact_summary"]["workflow_history_archive"]) @@ -396,6 +403,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("digest_history_archive", summary["digest_surface"]) self.assertIn("digest_history_archive_count", summary["digest_surface"]) self.assertIn("digest_archive_compare", summary["digest_surface"]) + self.assertIn("archive_operator_overview", summary) + self.assertEqual(summary["archive_operator_overview"]["status"], "active") self.assertIn("focus_related_reminders", summary["digest_surface"]) self.assertGreaterEqual(summary["digest_surface"]["focus_related_reminder_count"], 1) self.assertIsNotNone(summary["digest_surface"]["next_focus_related_reminder"]) @@ -403,9 +412,14 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("next_digest_history_archive_review", summary["digest_surface"]) self.assertIn("long_range_trend_summary", summary["digest_surface"]["digest_history_archive"]) self.assertIn("baseline_pin_summary", summary["digest_surface"]["digest_history_archive"]) + self.assertIn("ranked_aggregation_summary", summary["digest_surface"]["digest_history_archive"]) self.assertEqual(summary["digest_surface"]["digest_history_archive"]["long_range_trend_summary"]["status"], "active") self.assertEqual(summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history") self.assertEqual(summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]["persisted_baseline_snapshot_id"], 9) + self.assertIn("baseline_delta_story", summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]) + self.assertEqual(summary["digest_surface"]["digest_history_archive"]["ranked_aggregation_summary"]["status"], "active") + self.assertIn("digest_baseline_delta_story", summary["archive_operator_overview"]) + self.assertIn("workflow_baseline_delta_story", summary["archive_operator_overview"]) self.assertIn("next_high_value_change", summary["digest_surface"]) self.assertIn("digest_review_back_actions", summary["digest_surface"]) self.assertIn("digest_review_back_action_count", summary["digest_surface"]) @@ -653,14 +667,67 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertGreaterEqual(summary["external_context_summary"]["provider_health_card_count"], 3) self.assertIn("provider_health_summary", summary["external_context_summary"]) self.assertEqual(summary["external_context_summary"]["provider_health_summary"]["status"], "active") + self.assertIn("health_story", summary["external_context_summary"]["provider_health_summary"]) self.assertIn("visual_evidence_bridge_summary", summary["external_context_summary"]) self.assertEqual(summary["external_context_summary"]["visual_evidence_bridge_summary"]["status"], "active") + self.assertIn("bridge_story", summary["external_context_summary"]["visual_evidence_bridge_summary"]) self.assertIn("capture_surface_cards", summary["external_context_summary"]) self.assertGreaterEqual(summary["external_context_summary"]["capture_surface_card_count"], 3) self.assertIn("capture_surface_summary", summary["external_context_summary"]) self.assertEqual(summary["external_context_summary"]["capture_surface_summary"]["status"], "active") + self.assertIn("capture_story", summary["external_context_summary"]["capture_surface_summary"]) + self.assertIn("decision_support_boundary_summary", summary) + self.assertEqual(summary["decision_support_boundary_summary"]["label"], "Decision support available") + self.assertIn("operator_summary", summary["decision_support_boundary_summary"]) + self.assertIn("live_analysis_assistant_summary", summary) + self.assertEqual(summary["live_analysis_assistant_summary"]["status"], "ready_for_readonly_live_analysis") + self.assertTrue(summary["evidence_summary"]["live_analysis_assistant_ready"]) + self.assertTrue(summary["evidence_summary"]["must_not_trade"]) + self.assertEqual(summary["readonly_live_health_summary"]["status"], "usable") + self.assertIn("decision_support_promotion_summary", summary) + self.assertEqual( + summary["decision_support_promotion_summary"]["status"], + "ready_for_decision_support_non_execution", + ) + self.assertEqual(summary["evidence_summary"]["promotion_status"], "ready_for_decision_support_non_execution") + self.assertIn("shadow_paper_validation_summary", summary) + self.assertEqual(summary["shadow_paper_validation_summary"]["status"], "collecting_paper_samples") + self.assertEqual(summary["evidence_summary"]["shadow_paper_status"], "collecting_paper_samples") + self.assertEqual(summary["evidence_summary"]["next_shadow_paper_gate"], "paper_ticket_sample_gate") self.assertIn("screenshot_readiness_summary", summary["external_context_summary"]) self.assertEqual(summary["external_context_summary"]["screenshot_readiness_summary"]["status"], "active") + self.assertIn("screenshot_story", summary["external_context_summary"]["screenshot_readiness_summary"]) + self.assertIn("surface_selection_history_summary", summary["external_context_summary"]) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["status"], "active") + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["entry_count"], 1) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["dominant_surface_title"], "binance_public_chart_reference") + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["dominant_source_group"], "visual_reference_card") + self.assertIn("recency_status", summary["external_context_summary"]["surface_selection_history_summary"]) + self.assertIn("revisit_prompt", summary["external_context_summary"]["surface_selection_history_summary"]) + self.assertIn("cadence_status", summary["external_context_summary"]["surface_selection_history_summary"]) + self.assertIn("revisit_queue_count", summary["external_context_summary"]["surface_selection_history_summary"]) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["acknowledged_count"], 1) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_history_count"], 1) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["cleared_queue_status"], "active") + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]["note_count"], 1) + self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]["acknowledged_by_counts"]["local_operator"], 1) + self.assertIn("note_gap_queue_count", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("owner_spotlight", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("owner_cadence_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("note_gap_priority_lane_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("owner_trend_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("note_gap_priority_lane_story", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("owner_handoff_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("note_gap_priority_lane_carry_forward_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("owner_handoff_follow_up_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("note_gap_priority_lane_carry_forward_aging_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("resolved_owner_drift_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("carry_forward_follow_up_lane_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("resolved_owner_drift_review_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("resolved_owner_drift_review_queue_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("resolved_owner_drift_review_queue_aging_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("carry_forward_follow_up_lane_rollover_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) + self.assertIn("carry_forward_follow_up_lane_rollover_handoff_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]) self.assertIn("visual_reference_cards", summary["external_context_summary"]) self.assertGreaterEqual(summary["external_context_summary"]["visual_reference_card_count"], 3) self.assertIsNotNone(summary["external_context_summary"]["visual_reference_next_prompt"]) @@ -775,11 +842,92 @@ class _LaunchReadinessService: "instrument_id": instrument_id, "timeframe": timeframe, "analysis_only_ready": True, + "live_analysis_assistant_ready": True, "decision_support_ready": True, "blocking_reasons": [], "warning_reasons": ["trust_observe_mode"], "next_actions": ["review_priority_setup"], "product_operating_mode": "decision_support_only", + "readonly_account_context_status": "available", + "readonly_live_health_summary": { + "status": "usable", + "api_health": "available", + "runtime_health": "available", + "market_freshness": "market_data_fresh", + "market_stream": "market_stream_connected", + "broker_account_context": "available", + "latest_decision_packet_timestamp": "2026-05-18T01:05:00+00:00", + "latest_review_packet_timestamp": "2026-05-18T01:05:00+00:00", + "current_candidate_count": 3, + "current_blocker": "", + "recommendation": "can_use_for_live_market_analysis", + }, + "live_analysis_assistant_summary": { + "status": "ready_for_readonly_live_analysis", + "ready_for_readonly_live_analysis": True, + "must_not_trade": True, + "execution_allowed": False, + "launch_boundary": { + "mode_label": "Read-only Live Analysis", + "not_buy_sell_instruction": True, + "no_order_submission": True, + "execution_remains_disabled": True, + }, + "health_summary": { + "status": "usable", + "market_freshness": "market_data_fresh", + "broker_account_context": "available", + }, + "allowed_actions": ["review_market_context", "record_shadow_observation_or_operator_note"], + "blocked_actions": ["submit_live_orders", "live_execute"], + }, + "decision_support_boundary_summary": { + "status": "decision_support_available", + "label": "Decision support available", + "operator_summary": "Decision support is available for human review, but execution assist and live submit remain gated.", + "current_model_boundary": "decision_support_candidate_available", + "current_model_reason": "decision_support_list_present", + "live_execution_boundary": "live_submit_blocked", + "live_execution_reason": "adapter_real_live_not_ready", + "allowed_operator_actions": ["review_market_context", "use_decision_support_for_manual_trade_review"], + "blocked_operator_actions": ["submit_live_orders", "enable_default_auto_trading"], + "next_boundary_gate": "execution_assist_trust_gate", + "next_boundary_gate_reason": "execution_candidate_list_missing", + }, + "decision_support_promotion_summary": { + "status": "ready_for_decision_support_non_execution", + "strict_decision_support_ready": True, + "current_decision_support_ready": True, + "execution_assist_ready": False, + "execution_boundary_after_promotion": "execution_still_blocked", + "real_live_boundary_after_promotion": "real_live_still_blocked", + "candidate_model_code": "OSOK", + "candidate_classification": "observe", + "operator_review_sample_count": 5, + "passed_criteria_count": 8, + "required_criteria_count": 8, + "failed_criteria": [], + "next_promotion_gate": "decision_support_ready_can_be_true_without_execution", + "operator_summary": "Decision support can be marked ready for human review; execution assist and live submit remain blocked.", + }, + "shadow_paper_validation_summary": { + "status": "collecting_paper_samples", + "observed_shadow_day_count": 3, + "window_days": 14, + "candidate_count": 6, + "paper_ticket_count": 1, + "paper_execution_count": 1, + "operator_feedback_count": 2, + "journal_entry_count": 1, + "risk_violation_count": 0, + "passed_criteria_count": 4, + "required_criteria_count": 8, + "failed_criteria": ["paper_ticket_sample_gate"], + "next_validation_gate": "paper_ticket_sample_gate", + "execution_boundary": "real_live_still_blocked", + "operator_summary": "Shadow/paper validation is collecting paper samples.", + "daily_coverage": [], + }, "market_state": { "session": {"session_code": "LONDON", "status": "active"}, "freshness": {"code": "market_data_fresh"}, @@ -985,9 +1133,24 @@ class _DecisionAssistantRepo: "handoff_packet_starter": {"headline": "Saved handoff starter"}, "replay_note_bridge_summary": {"note_title": "Saved replay note"}, "artifact_bundle_summary": {"starter_export_prompt": "Saved artifact bundle"}, + "compare_writeback_summary": {"note_title": "Saved compare note", "status": "persisted"}, "query_context": {"profile_scope": "selected_profile", "query_preset": "intraday_recent"}, "updated_ts": "2026-05-12T02:04:30+00:00", }, + "external_context_surface_history": [ + { + "surface_id": "binance_public_chart_reference", + "title": "binance_public_chart_reference", + "source_group": "visual_reference_card", + "selection_kind": "target_url", + "target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot", + "selected_ts": "2026-05-12T02:05:30+00:00", + "revisit_acknowledged": True, + "revisit_acknowledged_ts": "2026-05-12T02:15:30+00:00", + "revisit_acknowledged_by": "local_operator", + "revisit_ack_note": "checked and cleared", + } + ], }, "updated_ts": "2026-05-12T02:05:00+00:00", } diff --git a/tests/unit/test_readonly_live_analysis_smoke_script.py b/tests/unit/test_readonly_live_analysis_smoke_script.py new file mode 100644 index 0000000..bd2c4b7 --- /dev/null +++ b/tests/unit/test_readonly_live_analysis_smoke_script.py @@ -0,0 +1,146 @@ +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from scripts import run_readonly_live_analysis_smoke + + +class ReadonlyLiveAnalysisSmokeScriptTests(unittest.TestCase): + def test_main_writes_passed_json_artifact_without_trade_operations(self) -> None: + output = io.StringIO() + with tempfile.TemporaryDirectory() as tmpdir: + artifact = Path(tmpdir) / "readonly-smoke.json" + opener = _FakeOpener( + { + "/health": {"app": "ai-ict", "database_configured": True}, + "/trading/runtime-status": {"scheduler_state": "running", "current_blockers": []}, + "/trading/launch-readiness?instrument_id=1&timeframe=1m": { + "live_analysis_assistant_ready": True, + "execution_assist_ready": False, + "real_live_ready": False, + "live_analysis_assistant_summary": { + "ready_for_readonly_live_analysis": True, + "must_not_trade": True, + }, + }, + "/trading/review-packet?instrument_id=1": {"status": "blocked", "action_recommendation": "observe_only"}, + "/trading/shadow-paper-validation?instrument_id=1&timeframe=1m": { + "status": "collecting_paper_samples", + "execution_boundary": "real_live_still_blocked", + }, + "/trading/research/weekly-review?instrument_id=1": { + "entry_count": 0, + "next_week_actions": ["prepare_shadow_paper_weekly_review"], + }, + "/trading/candidates?instrument_id=1&limit=5": {"items": []}, + "/static/app.js": ( + "Read-only Live Analysis No order submission Not a buy/sell instruction " + "Execution remains disabled ready_for_readonly_live_analysis must_not_trade" + ), + } + ) + + original_urlopen = run_readonly_live_analysis_smoke._urlopen_no_proxy + run_readonly_live_analysis_smoke._urlopen_no_proxy = opener + try: + with contextlib.redirect_stdout(output): + exit_code = run_readonly_live_analysis_smoke.main( + argv=[ + "--base-url", + "http://example.test", + "--output-json", + "--artifact-path", + str(artifact), + ], + env={}, + ) + finally: + run_readonly_live_analysis_smoke._urlopen_no_proxy = original_urlopen + artifact_payload = json.loads(artifact.read_text(encoding="utf-8")) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["status"], "passed") + self.assertTrue(payload["ready_for_readonly_live_analysis"]) + self.assertTrue(payload["must_not_trade"]) + self.assertFalse(payload["submit_cancel_or_live_execution_called"]) + self.assertFalse(payload["trade_permission_required"]) + self.assertEqual(artifact_payload["status"], "passed") + + def test_run_reports_blocker_when_launch_boundary_allows_execution(self) -> None: + payload = run_readonly_live_analysis_smoke.run_readonly_live_analysis_smoke( + base_url="http://example.test", + attempts=1, + sleep_seconds=0, + opener=_FakeOpener( + { + "/health": {"app": "ai-ict"}, + "/trading/runtime-status": {"scheduler_state": "running"}, + "/trading/launch-readiness?instrument_id=1&timeframe=1m": { + "live_analysis_assistant_ready": True, + "execution_assist_ready": True, + "real_live_ready": False, + "live_analysis_assistant_summary": { + "ready_for_readonly_live_analysis": True, + "must_not_trade": True, + }, + }, + "/trading/review-packet?instrument_id=1": {"status": "blocked"}, + "/trading/shadow-paper-validation?instrument_id=1&timeframe=1m": { + "status": "collecting_paper_samples", + "execution_boundary": "real_live_still_blocked", + }, + "/trading/research/weekly-review?instrument_id=1": { + "entry_count": 0, + "next_week_actions": ["prepare_shadow_paper_weekly_review"], + }, + "/trading/candidates?instrument_id=1&limit=5": {"items": []}, + "/static/app.js": ( + "Read-only Live Analysis No order submission Not a buy/sell instruction " + "Execution remains disabled ready_for_readonly_live_analysis must_not_trade" + ), + } + ), + ) + + self.assertEqual(payload["status"], "blocked") + self.assertIn("launch_readiness", payload["blockers"]) + + +class _FakeOpener: + def __init__(self, payloads: dict) -> None: + self.payloads = payloads + + def __call__(self, request, timeout_seconds: float): + path = request.full_url.replace("http://example.test", "") + if path not in self.payloads: + raise RuntimeError(f"missing fake response for {path}") + payload = self.payloads[path] + if isinstance(payload, str): + raw = payload.encode("utf-8") + else: + raw = json.dumps(payload).encode("utf-8") + return _FakeResponse(raw) + + +class _FakeResponse: + status = 200 + + def __init__(self, raw: bytes) -> None: + self.raw = raw + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self): + return self.raw + + +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 index 8ce878c..62ed185 100644 --- a/tests/unit/test_research_council_runtime_integration.py +++ b/tests/unit/test_research_council_runtime_integration.py @@ -9,6 +9,7 @@ from tests.unit.test_trading_runtime_service import ( FakeMarketStateService, FakeReconciliationService, FakeResearchCouncilService, + FakeShadowPaperSampleService, FakeSetupWatcherService, FakeTradingPlanService, ) @@ -25,6 +26,7 @@ class ResearchCouncilRuntimeIntegrationTests(unittest.TestCase): decision_packet_service=FakeDecisionPacketService(), alert_dispatcher_service=FakeAlertDispatcherService(), research_council_service=research_council_service, + shadow_paper_sample_service=FakeShadowPaperSampleService(), ) def test_council_task_runs_after_watch_before_decision_packet(self) -> None: diff --git a/tests/unit/test_run_shadow_paper_observation_backfill.py b/tests/unit/test_run_shadow_paper_observation_backfill.py new file mode 100644 index 0000000..7e308a2 --- /dev/null +++ b/tests/unit/test_run_shadow_paper_observation_backfill.py @@ -0,0 +1,75 @@ +import contextlib +import io +import json +import unittest +from unittest.mock import Mock, patch + +from scripts import run_shadow_paper_observation_backfill + + +class RunShadowPaperObservationBackfillTests(unittest.TestCase): + def test_main_outputs_json_and_defaults_to_dry_run(self) -> None: + service = Mock() + service.build_backfill_audit.return_value = { + "status": "dry_run", + "eligible_backfill_dates": ["2026-05-15"], + "created_observation_count": 0, + "projected_observed_shadow_day_count": 4, + "blocked_backfill_items": ["paper_ticket_backfill_not_allowed_without_executable_candidate"], + } + output = io.StringIO() + + with patch("scripts.run_shadow_paper_observation_backfill.ShadowPaperObservationBackfillService", return_value=service): + with contextlib.redirect_stdout(output): + exit_code = run_shadow_paper_observation_backfill.main( + env={ + "SHADOW_PAPER_BACKFILL_OUTPUT_JSON": "1", + "SHADOW_PAPER_BACKFILL_INSTRUMENT_ID": "2", + "SHADOW_PAPER_BACKFILL_TIMEFRAME": "5m", + "SHADOW_PAPER_BACKFILL_WINDOW_DAYS": "7", + } + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(output.getvalue())["status"], "dry_run") + service.build_backfill_audit.assert_called_once_with( + instrument_id=2, + timeframe="5m", + window_days=7, + apply=False, + created_by="shadow_paper_backfill_audit", + ) + + def test_main_honors_apply_flag(self) -> None: + service = Mock() + service.build_backfill_audit.return_value = { + "status": "applied", + "eligible_backfill_dates": ["2026-05-15"], + "created_observation_count": 1, + "projected_observed_shadow_day_count": 5, + "blocked_backfill_items": [], + } + output = io.StringIO() + + with patch("scripts.run_shadow_paper_observation_backfill.ShadowPaperObservationBackfillService", return_value=service): + with contextlib.redirect_stdout(output): + exit_code = run_shadow_paper_observation_backfill.main( + env={ + "SHADOW_PAPER_BACKFILL_APPLY": "1", + "SHADOW_PAPER_BACKFILL_CREATED_BY": "operator", + } + ) + + self.assertEqual(exit_code, 0) + self.assertIn("applied", output.getvalue()) + service.build_backfill_audit.assert_called_once_with( + instrument_id=1, + timeframe="1m", + window_days=14, + apply=True, + created_by="operator", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shadow_paper_observation_backfill_service.py b/tests/unit/test_shadow_paper_observation_backfill_service.py new file mode 100644 index 0000000..dfe59ec --- /dev/null +++ b/tests/unit/test_shadow_paper_observation_backfill_service.py @@ -0,0 +1,123 @@ +import unittest +from datetime import date, datetime, timezone + +from src.services.readiness.shadow_paper_observation_backfill_service import ShadowPaperObservationBackfillService + + +class ShadowPaperObservationBackfillServiceTests(unittest.TestCase): + def test_dry_run_finds_candidate_days_without_writing(self) -> None: + repository = _BackfillRepository() + service = ShadowPaperObservationBackfillService(repository=repository) + + payload = service.build_backfill_audit( + instrument_id=1, + timeframe="1m", + now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc), + apply=False, + ) + + self.assertEqual(payload["status"], "dry_run") + self.assertEqual(payload["eligible_backfill_dates"], ["2026-05-15", "2026-05-16"]) + self.assertEqual(payload["created_observation_count"], 0) + self.assertEqual(repository.insert_calls, []) + self.assertFalse(payload["paper_backfill_allowed"]) + self.assertFalse(payload["operator_feedback_backfill_allowed"]) + self.assertIn("paper_ticket_backfill_not_allowed_without_executable_candidate", payload["blocked_backfill_items"]) + + def test_apply_creates_only_shadow_observations(self) -> None: + repository = _BackfillRepository() + service = ShadowPaperObservationBackfillService(repository=repository) + + payload = service.build_backfill_audit( + instrument_id=1, + timeframe="1m", + now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc), + apply=True, + created_by="test_backfill", + ) + + self.assertEqual(payload["status"], "applied") + self.assertEqual(payload["created_observation_count"], 2) + self.assertEqual([call["observation_date"].isoformat() for call in repository.insert_calls], ["2026-05-15", "2026-05-16"]) + self.assertTrue(all(call["candidate"]["status"] == "blocked" for call in repository.insert_calls)) + self.assertTrue(all(call["created_by"] == "test_backfill" for call in repository.insert_calls)) + self.assertTrue(all(row["paper_ticket_id"] is None for row in payload["created_observations"])) + self.assertTrue(all(row["paper_execution_id"] is None for row in payload["created_observations"])) + self.assertTrue(all(row["operator_agreement"] == "pending" for row in payload["created_observations"])) + + def test_no_candidate_days_has_no_eligible_backfill(self) -> None: + repository = _BackfillRepository(candidate_rows=[]) + service = ShadowPaperObservationBackfillService(repository=repository) + + payload = service.build_backfill_audit( + instrument_id=1, + timeframe="1m", + now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc), + apply=True, + ) + + self.assertEqual(payload["eligible_backfill_dates"], []) + self.assertEqual(payload["created_observation_count"], 0) + self.assertEqual(repository.insert_calls, []) + + +class _BackfillRepository: + def __init__(self, *, candidate_rows=None) -> None: + self.candidate_rows = candidate_rows + self.insert_calls = [] + + def fetch_candidate_day_summaries(self, **kwargs): + if self.candidate_rows is not None: + return self.candidate_rows + return [ + {"day": date(2026, 5, 15), "status": "blocked", "count": 3}, + {"day": date(2026, 5, 16), "status": "blocked", "count": 4}, + {"day": date(2026, 5, 17), "status": "blocked", "count": 5}, + ] + + def fetch_observation_day_summaries(self, **kwargs): + return [{"day": date(2026, 5, 17), "count": 1, "feedback_count": 1}] + + def fetch_latest_candidate_for_day(self, **kwargs): + return { + "id": int(kwargs["day_start"].strftime("%d")), + "instrument_id": kwargs["instrument_id"], + "model_code": "SWEEP_MSS_FVG", + "status": "blocked", + "session_code": "ALL", + "created_ts": kwargs["day_start"], + "quality_score": 0.5, + } + + def fetch_latest_decision_packet_for_day(self, **kwargs): + return { + "id": int(kwargs["day_start"].strftime("%d")) + 100, + "instrument_id": kwargs["instrument_id"], + "candidate_id": kwargs["candidate_id"], + "status": "blocked", + "action_recommendation": "stand_down", + "packet_ts": kwargs["day_start"], + } + + def insert_shadow_observation(self, **kwargs): + self.insert_calls.append(kwargs) + return { + "id": len(self.insert_calls), + "instrument_id": kwargs["instrument_id"], + "observation_date": kwargs["observation_date"], + "timeframe": kwargs["timeframe"], + "session_code": kwargs["session_code"], + "candidate_id": kwargs["candidate"]["id"], + "decision_packet_id": kwargs["decision_packet"]["id"], + "paper_ticket_id": None, + "paper_execution_id": None, + "observation_status": "candidate_observed", + "system_recommendation": "stand_down_blocked_candidate", + "operator_agreement": "pending", + "reason_codes": ["shadow_observation_backfill"], + "observed_ts": kwargs["candidate"]["created_ts"], + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shadow_paper_sample_collection_service.py b/tests/unit/test_shadow_paper_sample_collection_service.py new file mode 100644 index 0000000..a226661 --- /dev/null +++ b/tests/unit/test_shadow_paper_sample_collection_service.py @@ -0,0 +1,106 @@ +import unittest +from datetime import datetime, timezone + +from src.services.readiness.shadow_paper_sample_collection_service import ShadowPaperSampleCollectionService + + +class ShadowPaperSampleCollectionServiceTests(unittest.TestCase): + def test_records_daily_observation_from_latest_candidate_and_packet(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + repository = _ObservationRepository() + service = ShadowPaperSampleCollectionService(repository=repository) + + payload = service.record_daily_observation( + instrument_id=1, + timeframe="1m", + operator_agreement="agree", + operator_note="aligned with operator review", + created_by="alice", + runtime_status={"current_blockers": []}, + now=now, + ) + + self.assertEqual(payload["status"], "recorded") + self.assertEqual(payload["observation_status"], "operator_feedback_recorded") + self.assertEqual(payload["system_recommendation"], "wait_for_operator_confirmation") + self.assertEqual(payload["operator_agreement"], "agree") + self.assertEqual(payload["candidate_id"], 7) + self.assertEqual(payload["decision_packet_id"], 9) + self.assertIn("keep_daily_shadow_observation_running", payload["next_collection_actions"]) + self.assertIn("build_shadow_paper_ticket_for_latest_candidate", payload["next_collection_actions"]) + self.assertEqual(repository.upsert_call["created_by"], "alice") + self.assertEqual(repository.upsert_call["reason_codes"][0], "operator_feedback_recorded") + + def test_runtime_blocker_records_blocked_observation_without_sample_credit_action(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperSampleCollectionService(repository=_ObservationRepository()) + + payload = service.record_daily_observation( + instrument_id=1, + runtime_status={"current_blockers": ["broker_sync_degraded:BTC-USDT"]}, + now=now, + ) + + self.assertEqual(payload["observation_status"], "runtime_blocked") + self.assertEqual(payload["system_recommendation"], "pause_runtime_blocked") + self.assertEqual(payload["next_collection_actions"], ["clear_runtime_blockers_before_counting_shadow_day"]) + + def test_blocked_candidate_waits_before_paper_ticket_action(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperSampleCollectionService(repository=_ObservationRepository(candidate_status="blocked")) + + payload = service.record_daily_observation( + instrument_id=1, + runtime_status={"current_blockers": []}, + now=now, + ) + + self.assertIn("wait_for_executable_candidate_before_paper_ticket", payload["next_collection_actions"]) + self.assertNotIn("build_shadow_paper_ticket_for_latest_candidate", payload["next_collection_actions"]) + + +class _ObservationRepository: + def __init__(self, *, candidate_status: str = "executable") -> None: + self.upsert_call = None + self.candidate_status = candidate_status + + def fetch_latest_candidate(self, **kwargs): + return { + "id": 7, + "model_code": "SWEEP_MSS_FVG", + "status": self.candidate_status, + "session_code": "NY_AM", + "confirmation_state": {"reason_codes": ["mss_confirmed"]}, + "created_ts": kwargs["since"], + } + + def fetch_latest_decision_packet(self, **kwargs): + return { + "id": 9, + "status": "actionable", + "action_recommendation": "confirm_ticket", + "payload": {"decision": {"reason_codes": ["candidate_actionable"]}}, + "packet_ts": kwargs["since"], + } + + def upsert_daily_observation(self, **kwargs): + self.upsert_call = kwargs + return { + "id": 12, + "observation_date": kwargs["observation_date"], + "timeframe": kwargs["timeframe"], + "session_code": kwargs["session_code"], + "observation_status": kwargs["observation_status"], + "system_recommendation": kwargs["system_recommendation"], + "operator_agreement": kwargs["operator_agreement"], + "candidate_id": kwargs["candidate_id"], + "decision_packet_id": kwargs["decision_packet_id"], + "paper_ticket_id": kwargs["paper_ticket_id"], + "paper_execution_id": kwargs["paper_execution_id"], + "reason_codes": kwargs["reason_codes"], + "observed_ts": kwargs["observed_ts"], + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shadow_paper_validation_service.py b/tests/unit/test_shadow_paper_validation_service.py new file mode 100644 index 0000000..1cacfe7 --- /dev/null +++ b/tests/unit/test_shadow_paper_validation_service.py @@ -0,0 +1,146 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from src.services.readiness.shadow_paper_validation_service import ShadowPaperValidationService + + +class ShadowPaperValidationServiceTests(unittest.TestCase): + def test_build_summary_collects_shadow_and_paper_validation_gates(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperValidationService(repository=_SparseRepository(now)) + + summary = service.build_summary( + instrument_id=1, + timeframe="1m", + runtime_status={"current_blockers": []}, + weekly_review={"entry_count": 1, "risk_violation_count": 0, "framework_execution_rate": 0.8}, + now=now, + ) + + self.assertEqual(summary["status"], "collecting_shadow_candidates") + self.assertEqual(summary["candidate_count"], 3) + self.assertEqual(summary["paper_ticket_count"], 1) + self.assertEqual(summary["paper_execution_count"], 1) + self.assertEqual(summary["operator_feedback_count"], 1) + self.assertIn("shadow_candidate_day_coverage", summary["failed_criteria"]) + self.assertEqual(summary["next_validation_gate"], "shadow_candidate_day_coverage") + self.assertEqual(summary["execution_boundary"], "real_live_still_blocked") + self.assertEqual(len(summary["daily_coverage"]), 14) + + def test_build_summary_marks_ready_when_two_week_gates_are_met(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperValidationService(repository=_ReadyRepository(now)) + + summary = service.build_summary( + instrument_id=1, + runtime_status={"current_blockers": []}, + weekly_review={"entry_count": 5, "risk_violation_count": 0, "framework_execution_rate": 0.9}, + now=now, + ) + + self.assertEqual(summary["status"], "ready_for_shadow_paper_review") + self.assertEqual(summary["failed_criteria"], []) + self.assertEqual(summary["next_validation_gate"], "shadow_paper_review_ready") + self.assertGreaterEqual(summary["observed_shadow_day_count"], 10) + self.assertGreaterEqual(summary["paper_execution_count"], 5) + + def test_runtime_blocker_prevents_shadow_validation(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperValidationService(repository=_ReadyRepository(now)) + + summary = service.build_summary( + instrument_id=1, + runtime_status={"current_blockers": ["market_stream_degraded"]}, + weekly_review={"entry_count": 5, "risk_violation_count": 0}, + now=now, + ) + + self.assertEqual(summary["status"], "blocked_by_runtime") + self.assertEqual(summary["next_validation_gate"], "runtime_clear_for_shadow") + + def test_daily_observations_count_toward_shadow_coverage_and_operator_feedback(self) -> None: + now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) + service = ShadowPaperValidationService(repository=_ObservationOnlyRepository(now)) + + summary = service.build_summary( + instrument_id=1, + runtime_status={"current_blockers": []}, + weekly_review={"entry_count": 1, "risk_violation_count": 0}, + now=now, + ) + + self.assertEqual(summary["shadow_observation_count"], 4) + self.assertEqual(summary["shadow_observation_day_count"], 4) + self.assertEqual(summary["observed_shadow_day_count"], 4) + self.assertEqual(summary["operator_observation_feedback_count"], 1) + self.assertEqual(summary["operator_feedback_count"], 1) + self.assertEqual(summary["daily_coverage"][-1]["shadow_observation_count"], 1) + self.assertEqual(summary["daily_coverage"][-1]["operator_feedback_count"], 1) + + +class _SparseRepository: + def __init__(self, now: datetime) -> None: + self.now = now + + def fetch_recent_candidates(self, **kwargs): + return [_row(index=i, created_ts=self.now - timedelta(days=i)) for i in range(3)] + + def fetch_recent_tickets(self, **kwargs): + return [_row(id=10, execution_mode="paper", created_ts=self.now - timedelta(days=1))] + + def fetch_recent_executions(self, **kwargs): + return [_row(id=20, execution_mode="paper", opened_ts=self.now - timedelta(days=1))] + + def fetch_recent_journal_entries(self, **kwargs): + return [_row(id=30, journal_ts=self.now - timedelta(days=1), journal_date=(self.now - timedelta(days=1)).date())] + + def fetch_recent_operator_feedback(self, **kwargs): + return [_row(id=40, subject_type="alert", label="useful", created_ts=self.now - timedelta(days=1))] + + +class _ReadyRepository(_SparseRepository): + def fetch_recent_candidates(self, **kwargs): + return [_row(id=i, created_ts=self.now - timedelta(days=i % 10)) for i in range(12)] + + def fetch_recent_tickets(self, **kwargs): + return [_row(id=100 + i, execution_mode="paper", created_ts=self.now - timedelta(days=i)) for i in range(5)] + + def fetch_recent_executions(self, **kwargs): + return [_row(id=200 + i, execution_mode="paper", opened_ts=self.now - timedelta(days=i)) for i in range(5)] + + def fetch_recent_operator_feedback(self, **kwargs): + return [_row(id=400 + i, subject_type="alert", label="useful", created_ts=self.now - timedelta(days=i)) for i in range(5)] + + +class _ObservationOnlyRepository(_SparseRepository): + def fetch_recent_candidates(self, **kwargs): + return [] + + def fetch_recent_tickets(self, **kwargs): + return [] + + def fetch_recent_executions(self, **kwargs): + return [] + + def fetch_recent_operator_feedback(self, **kwargs): + return [] + + def fetch_recent_observations(self, **kwargs): + return [ + _row( + id=i, + observation_date=(self.now - timedelta(days=i)).date(), + observed_ts=self.now - timedelta(days=i), + observation_status="candidate_observed", + operator_agreement="agree" if i == 0 else "pending", + ) + for i in range(4) + ] + + +def _row(**kwargs): + return dict(kwargs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_trade_plan_candidate_service.py b/tests/unit/test_trade_plan_candidate_service.py index 4f0e86a..a6ca36d 100644 --- a/tests/unit/test_trade_plan_candidate_service.py +++ b/tests/unit/test_trade_plan_candidate_service.py @@ -203,10 +203,60 @@ class TradePlanCandidateApiTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "candidate_model_not_enabled_for_execution"): build_ticket_from_candidate(candidate_id=77, execution_mode="paper") + def test_build_ticket_from_candidate_allows_explicit_shadow_paper_sample(self) -> None: + candidate_row = { + "id": 77, + "instrument_id": 1, + "trading_plan_id": 10, + "model_code": "SWEEP_MSS_FVG", + "status": "executable", + "entry": {"entry_type": "limit", "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, "tp1": 101.0, "tp2": 102.0}, + "confirmation_state": {}, + "quality_score": 50.0, + "ai_explanation": "observe_only", + } + with unittest.mock.patch("src.api.trading_runtime.TradePlanCandidateRepository.fetch_candidate_by_id", return_value=candidate_row), unittest.mock.patch( + "src.api.trading_runtime.normalize_trade_plan_candidate_row", + return_value={"id": 77, "instrument_id": 1, "model_code": "SWEEP_MSS_FVG", "executable": True}, + ), unittest.mock.patch( + "src.api.trading_runtime.ModelTrustService.build_summary", + return_value={"models": [{"model_code": "SWEEP_MSS_FVG", "execution_allowed": False}]}, + ), unittest.mock.patch( + "src.api.trading_runtime.ensure_trade_signal_for_candidate", + return_value=88, + ), unittest.mock.patch( + "src.api.trading_runtime.build_execution_service", + return_value=_ShadowPaperExecutionService(), + ): + payload = build_ticket_from_candidate(candidate_id=77, execution_mode="paper", allow_shadow_paper=True) + + self.assertEqual(payload["ticket_id"], 501) + self.assertEqual(payload["execution_mode"], "paper") + self.assertEqual(payload["shadow_paper_validation"]["status"], "paper_only_shadow_allowed") + self.assertFalse(payload["shadow_paper_validation"]["trust_execution_allowed"]) + _DEFAULT = object() +class _ShadowPaperExecutionDecision: + def to_dict(self): + return { + "ticket_id": 501, + "status": "awaiting_confirmation", + "execution_mode": "paper", + "signal_id": 88, + "execution_id": None, + "reasons": [], + "ticket_snapshot": {}, + } + + +class _ShadowPaperExecutionService: + def create_execution_ticket(self, **kwargs): + return _ShadowPaperExecutionDecision() + + class _SetupWatcherStubRepository: def __init__( self, diff --git a/tests/unit/test_trading_runtime_service.py b/tests/unit/test_trading_runtime_service.py index bcc55c4..5f068b2 100644 --- a/tests/unit/test_trading_runtime_service.py +++ b/tests/unit/test_trading_runtime_service.py @@ -194,6 +194,23 @@ class FakeResearchCouncilService: } +class FakeShadowPaperSampleService: + def __init__(self): + self.calls = [] + + def record_daily_observation(self, **kwargs): + self.calls.append(kwargs) + return { + "status": "recorded", + "observation_id": 701, + "observation_date": kwargs["now"].date().isoformat(), + "observation_status": "candidate_observed", + "system_recommendation": "continue_shadow_tracking", + "operator_agreement": "pending", + "next_collection_actions": ["keep_daily_shadow_observation_running"], + } + + class TradingRuntimeSchedulerTests(unittest.TestCase): def test_run_cycle_records_successful_task_state(self) -> None: scheduler = TradingRuntimeScheduler( @@ -223,6 +240,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): ), visual_capture_retention_service=FakeVisualCaptureRetentionService(), research_council_service=FakeResearchCouncilService(), + shadow_paper_sample_service=FakeShadowPaperSampleService(), ) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): payload = scheduler.run_cycle() @@ -239,6 +257,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): self.assertTrue(payload["tasks"]["decision_packet"]["details"]["visual_watch_reserved"]) self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_trigger_policy_statuses"], ["triggered"]) self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_triggered_alert_count"], 1) + self.assertEqual(payload["tasks"]["shadow_paper_observation"]["details"]["observation_status"], "candidate_observed") 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["tasks"]["visual_watch"]["details"]["trigger_type_counts"]["candidate_actionable_watch"], 1) @@ -264,6 +283,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): visual_review_service=FakeVisualReviewService(), visual_capture_retention_service=FakeVisualCaptureRetentionService(), research_council_service=FakeResearchCouncilService(), + shadow_paper_sample_service=FakeShadowPaperSampleService(), ) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): payload = scheduler.run_cycle() @@ -290,6 +310,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): visual_review_service=FakeVisualReviewService(), visual_capture_retention_service=FakeVisualCaptureRetentionService(), research_council_service=FakeResearchCouncilService(), + shadow_paper_sample_service=FakeShadowPaperSampleService(), ) supervisor = TradingRuntimeSupervisor(scheduler=scheduler) with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): @@ -310,6 +331,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): visual_review_service=FakeVisualReviewService(), visual_capture_retention_service=FakeVisualCaptureRetentionService(), research_council_service=FakeResearchCouncilService(), + shadow_paper_sample_service=FakeShadowPaperSampleService(), runtime_task_watchdog_service=RuntimeTaskWatchdogService(stale_multiplier=1, min_stuck_seconds=1), ) stale_ts = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()