diff --git a/SESSION.md b/SESSION.md index 1aab6e5..77f01ae 100644 --- a/SESSION.md +++ b/SESSION.md @@ -840,11 +840,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - S11 real intraday use loop is implemented, deployed, and remote-verified: remote `/trader-mode` showed the S10 six-card conclusion clearly (`FVG retrace is not confirmed yet; no trade`), so the operator recorded a real `No Trade` instead of tuning more copy. This created observation `9237` with `operator_agreement=no_trade` for Candidate `4610`; `operator_feedback_count` increased from `3` to `4`, while `paper_ticket_count=0`, `paper_execution_count=0`, and `weekly_review_entry_count=0` stayed unchanged. A feedback mapping bug was fixed so Trader Cockpit buttons now write `no_trade` and `need_more_evidence` explicitly instead of collapsing both into `unclear`. Validation passed focused tests, `node --check`, deploy smoke over 11 endpoints, remote ops smoke, readonly live analysis smoke, field verification, and gstack browser verification with no console errors. Evidence screenshot: `output/playwright/s11-trader-mode-no-trade-feedback-1366x768.png`. - S12 real intraday use loop was attempted and the anti-duplication guard was confirmed: remote `/trader-mode` still showed a clear Chinese `Observe Only / no trade` readout, the UI wrote `POST /trading/shadow-paper/observe -> 201`, and screenshot evidence was saved at `output/playwright/s12-trader-mode-fifth-feedback-1366x768.png`. The R2 count did not increase from `4/5` to `5/5` because `shadow_paper_observations` upserts by `(instrument_id, observation_date, timeframe, session_code)`, so another same-day same-session LONDON `No Trade` updates observation `9237` instead of creating a duplicate sample. Current live state: `observed_shadow_day_count=14`, `shadow_observation_count=17`, `shadow_observation_day_count=13`, `operator_feedback_count=4/5`, `operator_observation_feedback_count=4`, `operator_feedback_day_count=3`, `paper_ticket_count=0`, `paper_execution_count=0`, `weekly_review_entry_count=0`, and `runtime_clear_for_shadow` is currently blocked by `broker_sync_degraded:BTC-USDT:okx_read_credentials_missing`. - S13 read-only runtime blocker recovery and fifth real feedback sample are complete: remote `.env` had empty `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`, so the existing local read-only OKX values were restored into the remote runtime env only; `LIVE_TRADING_ENABLED` and all execution/readiness gates stayed unchanged, and temporary credential fragments/backups were removed. Remote runtime now has `current_blockers=[]`, `readonly_account_context_status=available`, `live_analysis_assistant_ready=true`, `decision_support_ready=false`, `execution_assist_ready=false`, `real_live_ready=false`, and `must_not_trade=true`. Then the existing shadow-observation feedback path recorded a distinct `ALL` / `no_candidate_monitor` observation `7949` as `operator_agreement=agree`, moving `operator_feedback_count` from `4/5` to `5/5` without creating paper tickets, paper executions, or weekly review entries. Current R2 state: `next_validation_gate=paper_ticket_sample_gate`, `operator_feedback_count=5/5`, `operator_observation_feedback_count=5`, `operator_feedback_day_count=3`, `paper_ticket_count=0`, `paper_execution_count=0`, and `weekly_review_entry_count=0`. Validation passed documentation consistency tests, `git diff --check`, remote ops smoke, readonly live analysis smoke, direct shadow-paper field smoke, and gstack console check. +- S14 R2 readonly fallback candidate-refresh closure is complete: R2 shadow-paper validation and sample collection now use the same read-only runtime blocker policy as Maintenance / Unattended Harness, so `market_stream_degraded` with fresh REST candles and clear data gaps is surfaced as a suppressed warning instead of blocking `runtime_clear_for_shadow`. Runtime `watch` also now continues read-only candidate scanning when REST market data is fresh and gap-clear even if the stream is still connecting, and passes `latest_market_state_by_symbol` into shadow-paper observation so fallback blockers can be suppressed consistently. Local validation passed focused runtime/readiness/maintenance/unattended tests, full unit discovery (`811` tests), script static checks (`78` scripts), py_compile, documentation consistency, and `git diff --check` with only LF/CRLF warnings. Remote deploy passed 11 endpoint deploy smoke, `run_remote_ops_smoke.py`, and `run_readonly_live_analysis_smoke.py`; field smoke showed `watch_market_input_mode=rest_fallback`, no `watch_skipped_market_degraded`, and new Candidate `4612` generated. Candidate `4612` is still correctly `blocked` / `executable=false` because `fvg_retrace_confirmed` is missing and `fvg_direction_mismatch` is rejected, so no paper ticket or paper execution was created. - Current assisted-analysis next gate: stop collecting duplicate operator feedback; wait for a real executable candidate before creating shadow paper ticket / paper execution samples, then let real execution-derived Journal / weekly review evidence appear. Do not fabricate paper samples from blocked candidates or no-candidate observations. Do not add new product features, do not restart external-context/archive/digest long-tail work, do not physically delete S1 deprecated surfaces yet, and keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Exact Next Step -Use `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md`, `docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md`, `docs/AI_ICT_Cockpit链路句与反馈错误分类_S5S6_2026-05-21_v1.md`, `docs/AI_ICT_只读实盘可靠性与交易员SOP_S7S8_2026-05-21_v1.md`, `docs/AI_ICT_TraderCockpit状态话术压缩_S9_2026-05-21_v1.md`, and `docs/AI_ICT_TraderCockpit真实反馈验证_S10_2026-05-21_v1.md` as the active anchors. Continue with the R2 paper gate only: wait for a real executable candidate, then create paper ticket / paper execution evidence through the existing shadow-paper path and weekly review flow. Do not keep clicking feedback now that `operator_feedback_count=5/5`; do not restart external-context/archive/digest long-tail work, do not create order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. +Use `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md`, `docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md`, `docs/AI_ICT_Cockpit链路句与反馈错误分类_S5S6_2026-05-21_v1.md`, `docs/AI_ICT_只读实盘可靠性与交易员SOP_S7S8_2026-05-21_v1.md`, `docs/AI_ICT_TraderCockpit状态话术压缩_S9_2026-05-21_v1.md`, and `docs/AI_ICT_TraderCockpit真实反馈验证_S10_2026-05-21_v1.md` as the active anchors. Continue with the R2 paper gate only: watch for the next real executable candidate after the S14 REST-fallback candidate-refresh fix, then create paper ticket / paper execution evidence through the existing shadow-paper path and weekly review flow. Do not keep clicking feedback now that `operator_feedback_count=5/5`; do not restart external-context/archive/digest long-tail work, do not create order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples from blocked candidates such as Candidate `4612`. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 5586b12..bfec98b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -944,6 +944,7 @@ - [x] S11 real intraday use loop: `/trader-mode` six-card wording was clear enough to record a real `No Trade`; fixed existing feedback mapping so `No Trade` writes `operator_agreement=no_trade` and `Need More Evidence` writes `need_more_evidence`; remote observation `9237` increased `operator_feedback_count` from `3` to `4` while paper ticket / execution / weekly review stayed at `0` - [x] S12 real feedback sampling guard verified: a same-day same-session `/trader-mode` `No Trade` POST succeeded but did not increase `operator_feedback_count` because `shadow_paper_observations` intentionally upserts by `(instrument_id, observation_date, timeframe, session_code)` - [x] S13 read-only runtime blocker and fifth feedback sample closure: restored blank remote OKX read-only credential keys from local runtime values without changing live trading flags, confirmed `runtime_status.current_blockers=[]`, recorded distinct `ALL/no_candidate_monitor` observation `7949` as `operator_agreement=agree`, and moved `operator_feedback_count` from `4/5` to `5/5` +- [x] S14 R2 readonly fallback candidate-refresh closure: shadow-paper validation/sample collection now share the read-only blocker policy, runtime watch continues scanning on fresh REST candles with clear gaps while stream is connecting, remote smoke passed, and new Candidate `4612` was generated but remains blocked (`fvg_retrace_confirmed` missing, `fvg_direction_mismatch` rejected) - [ ] R2 paper-ticket sample gate is waiting on a real executable candidate; do not fabricate paper tickets from blocked candidates - [x] R2 operator feedback sample gate satisfied with distinct real samples (`operator_feedback_count=5/5`); do not keep clicking feedback to pad counts - [ ] R2 weekly review visible gate still needs a real execution-derived journal/weekly review entry (`weekly_review_entry_count=0`) diff --git a/src/services/maintenance/maintenance_summary_service.py b/src/services/maintenance/maintenance_summary_service.py index d5e3df4..68f27e0 100644 --- a/src/services/maintenance/maintenance_summary_service.py +++ b/src/services/maintenance/maintenance_summary_service.py @@ -5,6 +5,11 @@ from typing import Callable, Optional from src.config.settings import settings from src.services.alerts import AlertQualitySummaryService +from src.services.readiness.runtime_blocker_policy import ( + effective_runtime_blockers_for_readonly, + is_readonly_broker_context_blocker, + is_readonly_stream_fallback_blocker, +) from src.services.runtime.runtime_incident_journal_service import RuntimeIncidentJournalService from src.services.visual_watch import VisualCaptureRetentionService @@ -252,8 +257,8 @@ def build_readonly_live_reliability( runtime_health = dict(runtime_status.get("runtime_health") or {}) runtime_level = str(runtime_health.get("level") or runtime_status.get("scheduler_state") or "unknown") raw_blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item] - rest_fallback_active = any(_is_readonly_stream_fallback_blocker(item, runtime_status) for item in raw_blockers) - broker_context_warning = any(_is_readonly_broker_context_blocker(item) for item in raw_blockers) + rest_fallback_active = any(is_readonly_stream_fallback_blocker(item, runtime_status) for item in raw_blockers) + broker_context_warning = any(is_readonly_broker_context_blocker(item) for item in raw_blockers) proxy_status = "configured" if (settings.market_truth_proxy_url or settings.broker_read_proxy_url) else "not_configured" market_ready = freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" runtime_ready = not runtime_blockers and runtime_level in {"ok", "running", "available", ""} @@ -303,32 +308,6 @@ def build_readonly_live_reliability( } -def effective_runtime_blockers_for_readonly(runtime_status: dict) -> list[str]: - blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item] - return [ - blocker - for blocker in blockers - if not _is_readonly_stream_fallback_blocker(blocker, runtime_status) - and not _is_readonly_broker_context_blocker(blocker) - ] - - -def _is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) -> bool: - if not blocker.startswith("market_stream_degraded:"): - return False - parts = blocker.split(":") - symbol = parts[1] if len(parts) > 1 else "" - states = dict(runtime_status.get("latest_market_state_by_symbol") or {}) - market_state = dict(states.get(symbol) or {}) - freshness_code = str(((market_state.get("freshness") or {}).get("code")) or "") - gap_code = str(((market_state.get("data_gap_state") or {}).get("code")) or "") - return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" - - -def _is_readonly_broker_context_blocker(blocker: str) -> bool: - return blocker.startswith("broker_sync_degraded:") - - def _first_market_state(runtime_status: dict) -> dict: states = dict(runtime_status.get("latest_market_state_by_symbol") or {}) for value in states.values(): diff --git a/src/services/readiness/runtime_blocker_policy.py b/src/services/readiness/runtime_blocker_policy.py new file mode 100644 index 0000000..681d19c --- /dev/null +++ b/src/services/readiness/runtime_blocker_policy.py @@ -0,0 +1,27 @@ +from __future__ import annotations + + +def effective_runtime_blockers_for_readonly(runtime_status: dict) -> list[str]: + blockers = [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + return [ + blocker + for blocker in blockers + if not is_readonly_stream_fallback_blocker(blocker, runtime_status) + and not is_readonly_broker_context_blocker(blocker) + ] + + +def is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) -> bool: + if not blocker.startswith("market_stream_degraded:"): + return False + parts = blocker.split(":") + symbol = parts[1] if len(parts) > 1 else "" + states = dict((runtime_status or {}).get("latest_market_state_by_symbol") or {}) + market_state = dict(states.get(symbol) or {}) + freshness_code = str(((market_state.get("freshness") or {}).get("code")) or "") + gap_code = str(((market_state.get("data_gap_state") or {}).get("code")) or "") + return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" + + +def is_readonly_broker_context_blocker(blocker: str) -> bool: + return blocker.startswith("broker_sync_degraded:") diff --git a/src/services/readiness/shadow_paper_sample_collection_service.py b/src/services/readiness/shadow_paper_sample_collection_service.py index 91c41f4..f542189 100644 --- a/src/services/readiness/shadow_paper_sample_collection_service.py +++ b/src/services/readiness/shadow_paper_sample_collection_service.py @@ -4,6 +4,7 @@ from datetime import datetime, time, timezone from typing import Optional from src.repositories.shadow_paper_observation_repository import ShadowPaperObservationRepository +from src.services.readiness.runtime_blocker_policy import effective_runtime_blockers_for_readonly OPERATOR_AGREEMENT_PENDING = "pending" @@ -40,7 +41,12 @@ class ShadowPaperSampleCollectionService: 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] + runtime_status = runtime_status or {} + raw_runtime_blockers = _raw_runtime_blockers(runtime_status) + runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status) + suppressed_runtime_blockers = [ + blocker for blocker in raw_runtime_blockers if blocker not in runtime_blockers + ] 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) @@ -67,7 +73,9 @@ class ShadowPaperSampleCollectionService: ) payload = { "mode": "shadow_paper_sample_collection", + "raw_runtime_blockers": raw_runtime_blockers, "runtime_blockers": runtime_blockers, + "suppressed_runtime_blockers": suppressed_runtime_blockers, "latest_candidate": _candidate_payload(latest_candidate), "latest_decision_packet": _packet_payload(latest_packet), "paper_ticket_id": paper_ticket_id, @@ -136,6 +144,10 @@ def _normalize_operator_agreement(value: str) -> str: return normalized +def _raw_runtime_blockers(runtime_status: dict) -> list[str]: + return [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + + def _resolve_observation_status( *, runtime_blockers: list[str], diff --git a/src/services/readiness/shadow_paper_validation_service.py b/src/services/readiness/shadow_paper_validation_service.py index fb49576..8167ac9 100644 --- a/src/services/readiness/shadow_paper_validation_service.py +++ b/src/services/readiness/shadow_paper_validation_service.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timedelta, timezone from typing import Optional from src.repositories.shadow_paper_validation_repository import ShadowPaperValidationRepository +from src.services.readiness.runtime_blocker_policy import effective_runtime_blockers_for_readonly DEFAULT_WINDOW_DAYS = 14 @@ -52,7 +53,12 @@ class ShadowPaperValidationService: error=str(exc), ) - runtime_blockers = [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + runtime_status = runtime_status or {} + raw_runtime_blockers = _raw_runtime_blockers(runtime_status) + runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status) + suppressed_runtime_blockers = [ + blocker for blocker in raw_runtime_blockers if blocker not in runtime_blockers + ] 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"))] @@ -109,6 +115,7 @@ class ShadowPaperValidationService: "window_end_date": now.date().isoformat(), "runtime_blocker_count": len(runtime_blockers), "runtime_blockers": runtime_blockers, + "suppressed_runtime_blockers": suppressed_runtime_blockers, "candidate_count": len(candidates), "observed_shadow_day_count": observed_shadow_day_count, "shadow_observation_count": len(observations), @@ -213,6 +220,10 @@ def _build_validation_criteria( ] +def _raw_runtime_blockers(runtime_status: dict) -> list[str]: + return [str(item) for item in list((runtime_status or {}).get("current_blockers") or []) if item] + + def _resolve_validation_status(failed_criteria: list[str]) -> str: failed = set(failed_criteria) if "runtime_clear_for_shadow" in failed: diff --git a/src/services/runtime/trading_runtime_service.py b/src/services/runtime/trading_runtime_service.py index 8946239..71b65e2 100644 --- a/src/services/runtime/trading_runtime_service.py +++ b/src/services/runtime/trading_runtime_service.py @@ -351,13 +351,17 @@ class TradingRuntimeScheduler: freshness_code = ((market_payload or {}).get("freshness") or {}).get("code") stream_code = ((market_payload or {}).get("stream_state") or {}).get("code") gap_code = ((market_payload or {}).get("data_gap_state") or {}).get("code") - if freshness_code != "market_data_fresh" or stream_code != "market_stream_connected" or gap_code != "market_data_gap_clear": + readonly_market_usable = freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear" + stream_fallback_active = readonly_market_usable and stream_code != "market_stream_connected" + if not readonly_market_usable: task.details = {"symbol": symbol, "status": "skipped_market_degraded"} _task_success(task, now) code = f"watch_skipped_market_degraded:{symbol}" noisy_incidents.append(code) self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="noisy_incident", observed_ts=now) return + if stream_fallback_active: + warnings.append(f"watch_using_rest_fallback:{symbol}:{stream_code}") try: candidate = self.setup_watcher_service.build_candidate( instrument_id=instrument_id, @@ -371,6 +375,7 @@ class TradingRuntimeScheduler: "instrument_id": instrument_id, "candidate_id": candidate_id, "candidate_generated": bool(candidate), + "market_input_mode": "rest_fallback" if stream_fallback_active else "stream", } _task_success(task, now) except Exception as exc: @@ -488,7 +493,10 @@ class TradingRuntimeScheduler: instrument_id=instrument_id, timeframe=self.trigger_tf, created_by="runtime_shadow_collector", - runtime_status={"current_blockers": list(blockers)}, + runtime_status={ + "current_blockers": list(blockers), + "latest_market_state_by_symbol": dict(self.latest_market_state_by_symbol), + }, now=now, ) task.details = { diff --git a/tests/unit/test_shadow_paper_sample_collection_service.py b/tests/unit/test_shadow_paper_sample_collection_service.py index ef4bc7e..9210846 100644 --- a/tests/unit/test_shadow_paper_sample_collection_service.py +++ b/tests/unit/test_shadow_paper_sample_collection_service.py @@ -37,7 +37,7 @@ class ShadowPaperSampleCollectionServiceTests(unittest.TestCase): payload = service.record_daily_observation( instrument_id=1, - runtime_status={"current_blockers": ["broker_sync_degraded:BTC-USDT"]}, + runtime_status={"current_blockers": ["runtime_task_stalled:market_state"]}, now=now, ) @@ -45,6 +45,34 @@ class ShadowPaperSampleCollectionServiceTests(unittest.TestCase): self.assertEqual(payload["system_recommendation"], "pause_runtime_blocked") self.assertEqual(payload["next_collection_actions"], ["clear_runtime_blockers_before_counting_shadow_day"]) + def test_readonly_rest_fallback_blocker_still_counts_observation(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, + runtime_status={ + "current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + "latest_market_state_by_symbol": { + "BTC-USDT": { + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + }, + }, + now=now, + ) + + self.assertEqual(payload["observation_status"], "candidate_observed") + self.assertEqual(payload["system_recommendation"], "wait_for_operator_confirmation") + self.assertIn("build_shadow_paper_ticket_for_latest_candidate", payload["next_collection_actions"]) + self.assertEqual(repository.upsert_call["payload"]["runtime_blockers"], []) + self.assertEqual( + repository.upsert_call["payload"]["suppressed_runtime_blockers"], + ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + ) + 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")) diff --git a/tests/unit/test_shadow_paper_validation_service.py b/tests/unit/test_shadow_paper_validation_service.py index 1cacfe7..86f9bda 100644 --- a/tests/unit/test_shadow_paper_validation_service.py +++ b/tests/unit/test_shadow_paper_validation_service.py @@ -58,6 +58,33 @@ class ShadowPaperValidationServiceTests(unittest.TestCase): self.assertEqual(summary["status"], "blocked_by_runtime") self.assertEqual(summary["next_validation_gate"], "runtime_clear_for_shadow") + def test_readonly_rest_fallback_suppresses_market_stream_blocker(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:BTC-USDT:market_stream_connecting"], + "latest_market_state_by_symbol": { + "BTC-USDT": { + "freshness": {"code": "market_data_fresh"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + }, + }, + weekly_review={"entry_count": 5, "risk_violation_count": 0}, + now=now, + ) + + self.assertEqual(summary["status"], "ready_for_shadow_paper_review") + self.assertEqual(summary["runtime_blockers"], []) + self.assertEqual( + summary["suppressed_runtime_blockers"], + ["market_stream_degraded:BTC-USDT:market_stream_connecting"], + ) + self.assertNotIn("runtime_clear_for_shadow", summary["failed_criteria"]) + 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)) diff --git a/tests/unit/test_trading_runtime_service.py b/tests/unit/test_trading_runtime_service.py index 5f068b2..357a1fc 100644 --- a/tests/unit/test_trading_runtime_service.py +++ b/tests/unit/test_trading_runtime_service.py @@ -274,7 +274,9 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): def test_run_cycle_enters_degraded_mode_when_market_truth_is_bad(self) -> None: scheduler = TradingRuntimeScheduler( - market_state_service=FakeMarketStateService(FakeMarketState(stream="market_stream_disconnected")), + market_state_service=FakeMarketStateService( + FakeMarketState(stream="market_stream_disconnected", gap="market_data_gap_detected") + ), trading_plan_service=FakeTradingPlanService(), setup_watcher_service=FakeSetupWatcherService(), broker_read_model_service=FakeBrokerReadModelService(), @@ -298,6 +300,33 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): self.assertGreaterEqual(payload["incident_summary"]["severity_counts"]["blocker"], 1) self.assertIn("check_market_truth_stream_proxy_and_backfill", payload["incident_summary"]["recommended_actions"]) + def test_run_cycle_uses_readonly_rest_fallback_for_watch_when_stream_is_degraded(self) -> None: + shadow_service = FakeShadowPaperSampleService() + scheduler = TradingRuntimeScheduler( + market_state_service=FakeMarketStateService(FakeMarketState(stream="market_stream_disconnected")), + trading_plan_service=FakeTradingPlanService(), + setup_watcher_service=FakeSetupWatcherService(), + broker_read_model_service=FakeBrokerReadModelService(), + reconciliation_service=FakeReconciliationService(), + decision_packet_service=FakeDecisionPacketService(), + visual_review_service=FakeVisualReviewService(), + visual_capture_retention_service=FakeVisualCaptureRetentionService(), + research_council_service=FakeResearchCouncilService(), + shadow_paper_sample_service=shadow_service, + ) + with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): + payload = scheduler.run_cycle() + + self.assertIn("market_stream_degraded:BTC-USDT:market_stream_disconnected", payload["current_blockers"]) + self.assertIn("watch_using_rest_fallback:BTC-USDT:market_stream_disconnected", payload["current_warnings"]) + self.assertNotIn("watch_skipped_market_degraded:BTC-USDT", payload["current_noisy_incidents"]) + self.assertEqual(payload["tasks"]["watch"]["details"]["market_input_mode"], "rest_fallback") + self.assertTrue(payload["tasks"]["watch"]["details"]["candidate_generated"]) + self.assertEqual( + shadow_service.calls[0]["runtime_status"]["latest_market_state_by_symbol"]["BTC-USDT"]["freshness"]["code"], + "market_data_fresh", + ) + def test_supervisor_run_once_exposes_supervisor_state(self) -> None: scheduler = TradingRuntimeScheduler( market_state_service=FakeMarketStateService(),