Close R2 readonly fallback candidate refresh

This commit is contained in:
Codex
2026-05-21 17:13:55 +08:00
parent c5dcc60192
commit a6029c5dfa
10 changed files with 158 additions and 35 deletions
+2 -1
View File
@@ -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`. - 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`. - 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. - 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. - 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 ## 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 ## Validation Commands Used Recently
+1
View File
@@ -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] 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] 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] 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 - [ ] 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 - [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`) - [ ] R2 weekly review visible gate still needs a real execution-derived journal/weekly review entry (`weekly_review_entry_count=0`)
@@ -5,6 +5,11 @@ from typing import Callable, Optional
from src.config.settings import settings from src.config.settings import settings
from src.services.alerts import AlertQualitySummaryService 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.runtime.runtime_incident_journal_service import RuntimeIncidentJournalService
from src.services.visual_watch import VisualCaptureRetentionService 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_health = dict(runtime_status.get("runtime_health") or {})
runtime_level = str(runtime_health.get("level") or runtime_status.get("scheduler_state") or "unknown") 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] 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) 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) 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" 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" 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", ""} 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: def _first_market_state(runtime_status: dict) -> dict:
states = dict(runtime_status.get("latest_market_state_by_symbol") or {}) states = dict(runtime_status.get("latest_market_state_by_symbol") or {})
for value in states.values(): for value in states.values():
@@ -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:")
@@ -4,6 +4,7 @@ from datetime import datetime, time, timezone
from typing import Optional from typing import Optional
from src.repositories.shadow_paper_observation_repository import ShadowPaperObservationRepository 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" OPERATOR_AGREEMENT_PENDING = "pending"
@@ -40,7 +41,12 @@ class ShadowPaperSampleCollectionService:
now = _coerce_utc(now) now = _coerce_utc(now)
observation_date = now.date() observation_date = now.date()
day_start = datetime.combine(observation_date, time.min, tzinfo=timezone.utc) 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) normalized_agreement = _normalize_operator_agreement(operator_agreement)
latest_candidate = self.repository.fetch_latest_candidate(instrument_id=instrument_id, since=day_start) 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) latest_packet = self.repository.fetch_latest_decision_packet(instrument_id=instrument_id, since=day_start)
@@ -67,7 +73,9 @@ class ShadowPaperSampleCollectionService:
) )
payload = { payload = {
"mode": "shadow_paper_sample_collection", "mode": "shadow_paper_sample_collection",
"raw_runtime_blockers": raw_runtime_blockers,
"runtime_blockers": runtime_blockers, "runtime_blockers": runtime_blockers,
"suppressed_runtime_blockers": suppressed_runtime_blockers,
"latest_candidate": _candidate_payload(latest_candidate), "latest_candidate": _candidate_payload(latest_candidate),
"latest_decision_packet": _packet_payload(latest_packet), "latest_decision_packet": _packet_payload(latest_packet),
"paper_ticket_id": paper_ticket_id, "paper_ticket_id": paper_ticket_id,
@@ -136,6 +144,10 @@ def _normalize_operator_agreement(value: str) -> str:
return normalized 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( def _resolve_observation_status(
*, *,
runtime_blockers: list[str], runtime_blockers: list[str],
@@ -5,6 +5,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Optional from typing import Optional
from src.repositories.shadow_paper_validation_repository import ShadowPaperValidationRepository 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 DEFAULT_WINDOW_DAYS = 14
@@ -52,7 +53,12 @@ class ShadowPaperValidationService:
error=str(exc), 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 {}) weekly_review = dict(weekly_review or {})
paper_tickets = [row for row in tickets if _is_paper_mode(row.get("execution_mode"))] 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"))] 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(), "window_end_date": now.date().isoformat(),
"runtime_blocker_count": len(runtime_blockers), "runtime_blocker_count": len(runtime_blockers),
"runtime_blockers": runtime_blockers, "runtime_blockers": runtime_blockers,
"suppressed_runtime_blockers": suppressed_runtime_blockers,
"candidate_count": len(candidates), "candidate_count": len(candidates),
"observed_shadow_day_count": observed_shadow_day_count, "observed_shadow_day_count": observed_shadow_day_count,
"shadow_observation_count": len(observations), "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: def _resolve_validation_status(failed_criteria: list[str]) -> str:
failed = set(failed_criteria) failed = set(failed_criteria)
if "runtime_clear_for_shadow" in failed: if "runtime_clear_for_shadow" in failed:
@@ -351,13 +351,17 @@ class TradingRuntimeScheduler:
freshness_code = ((market_payload or {}).get("freshness") or {}).get("code") freshness_code = ((market_payload or {}).get("freshness") or {}).get("code")
stream_code = ((market_payload or {}).get("stream_state") 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") 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.details = {"symbol": symbol, "status": "skipped_market_degraded"}
_task_success(task, now) _task_success(task, now)
code = f"watch_skipped_market_degraded:{symbol}" code = f"watch_skipped_market_degraded:{symbol}"
noisy_incidents.append(code) noisy_incidents.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="noisy_incident", observed_ts=now) self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="noisy_incident", observed_ts=now)
return return
if stream_fallback_active:
warnings.append(f"watch_using_rest_fallback:{symbol}:{stream_code}")
try: try:
candidate = self.setup_watcher_service.build_candidate( candidate = self.setup_watcher_service.build_candidate(
instrument_id=instrument_id, instrument_id=instrument_id,
@@ -371,6 +375,7 @@ class TradingRuntimeScheduler:
"instrument_id": instrument_id, "instrument_id": instrument_id,
"candidate_id": candidate_id, "candidate_id": candidate_id,
"candidate_generated": bool(candidate), "candidate_generated": bool(candidate),
"market_input_mode": "rest_fallback" if stream_fallback_active else "stream",
} }
_task_success(task, now) _task_success(task, now)
except Exception as exc: except Exception as exc:
@@ -488,7 +493,10 @@ class TradingRuntimeScheduler:
instrument_id=instrument_id, instrument_id=instrument_id,
timeframe=self.trigger_tf, timeframe=self.trigger_tf,
created_by="runtime_shadow_collector", 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, now=now,
) )
task.details = { task.details = {
@@ -37,7 +37,7 @@ class ShadowPaperSampleCollectionServiceTests(unittest.TestCase):
payload = service.record_daily_observation( payload = service.record_daily_observation(
instrument_id=1, instrument_id=1,
runtime_status={"current_blockers": ["broker_sync_degraded:BTC-USDT"]}, runtime_status={"current_blockers": ["runtime_task_stalled:market_state"]},
now=now, now=now,
) )
@@ -45,6 +45,34 @@ class ShadowPaperSampleCollectionServiceTests(unittest.TestCase):
self.assertEqual(payload["system_recommendation"], "pause_runtime_blocked") self.assertEqual(payload["system_recommendation"], "pause_runtime_blocked")
self.assertEqual(payload["next_collection_actions"], ["clear_runtime_blockers_before_counting_shadow_day"]) 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: def test_blocked_candidate_waits_before_paper_ticket_action(self) -> None:
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
service = ShadowPaperSampleCollectionService(repository=_ObservationRepository(candidate_status="blocked")) service = ShadowPaperSampleCollectionService(repository=_ObservationRepository(candidate_status="blocked"))
@@ -58,6 +58,33 @@ class ShadowPaperValidationServiceTests(unittest.TestCase):
self.assertEqual(summary["status"], "blocked_by_runtime") self.assertEqual(summary["status"], "blocked_by_runtime")
self.assertEqual(summary["next_validation_gate"], "runtime_clear_for_shadow") 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: def test_daily_observations_count_toward_shadow_coverage_and_operator_feedback(self) -> None:
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
service = ShadowPaperValidationService(repository=_ObservationOnlyRepository(now)) service = ShadowPaperValidationService(repository=_ObservationOnlyRepository(now))
+30 -1
View File
@@ -274,7 +274,9 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
def test_run_cycle_enters_degraded_mode_when_market_truth_is_bad(self) -> None: def test_run_cycle_enters_degraded_mode_when_market_truth_is_bad(self) -> None:
scheduler = TradingRuntimeScheduler( 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(), trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(), setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(), broker_read_model_service=FakeBrokerReadModelService(),
@@ -298,6 +300,33 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
self.assertGreaterEqual(payload["incident_summary"]["severity_counts"]["blocker"], 1) self.assertGreaterEqual(payload["incident_summary"]["severity_counts"]["blocker"], 1)
self.assertIn("check_market_truth_stream_proxy_and_backfill", payload["incident_summary"]["recommended_actions"]) 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: def test_supervisor_run_once_exposes_supervisor_state(self) -> None:
scheduler = TradingRuntimeScheduler( scheduler = TradingRuntimeScheduler(
market_state_service=FakeMarketStateService(), market_state_service=FakeMarketStateService(),