Files
ai-exchange/src/services/runtime/trading_runtime_service.py
T
2026-05-21 17:13:55 +08:00

832 lines
39 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import threading
import time
from typing import Optional
from src.config.settings import settings
from src.services.alerts import AlertDispatcherService
from src.services.broker import BROKER_STATE_OK, BrokerReadModelService
from src.services.decision import DecisionPacketService
from src.services.market_state import MarketStateService, get_market_truth_supervisor
from src.services.reconciliation import BrokerExecutionReconciliationService
from src.services.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
from src.services.trade_plan_candidates import SetupWatcherService
from src.services.trading_plans import TradingPlanService
from src.services.visual_watch import VisualCaptureRetentionService, VisualReviewService
@dataclass
class RuntimeTaskState:
name: str
interval_seconds: int
last_started_ts: Optional[str] = None
last_completed_ts: Optional[str] = None
last_success_ts: Optional[str] = None
next_run_ts: Optional[str] = None
last_status: str = "idle"
last_error: Optional[str] = None
run_count: int = 0
success_count: int = 0
failure_count: int = 0
details: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"name": self.name,
"interval_seconds": self.interval_seconds,
"last_started_ts": self.last_started_ts,
"last_completed_ts": self.last_completed_ts,
"last_success_ts": self.last_success_ts,
"next_run_ts": self.next_run_ts,
"last_status": self.last_status,
"last_error": self.last_error,
"run_count": self.run_count,
"success_count": self.success_count,
"failure_count": self.failure_count,
"details": dict(self.details or {}),
}
class TradingRuntimeScheduler:
def __init__(
self,
*,
market_state_service: Optional[MarketStateService] = None,
trading_plan_service: Optional[TradingPlanService] = None,
setup_watcher_service: Optional[SetupWatcherService] = None,
broker_read_model_service: Optional[BrokerReadModelService] = None,
reconciliation_service: Optional[BrokerExecutionReconciliationService] = None,
decision_packet_service: Optional[DecisionPacketService] = None,
alert_dispatcher_service: Optional[AlertDispatcherService] = None,
research_council_service: Optional[ResearchCouncilService] = None,
visual_review_service: Optional[VisualReviewService] = None,
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:
self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor())
self.trading_plan_service = trading_plan_service or TradingPlanService()
self.setup_watcher_service = setup_watcher_service or SetupWatcherService()
self.broker_read_model_service = broker_read_model_service or BrokerReadModelService()
self.reconciliation_service = reconciliation_service or BrokerExecutionReconciliationService(
broker_read_model_service=self.broker_read_model_service,
)
self.decision_packet_service = decision_packet_service or DecisionPacketService()
self.alert_dispatcher_service = alert_dispatcher_service or AlertDispatcherService()
self.research_council_service = research_council_service or ResearchCouncilService()
self.visual_review_service = visual_review_service or VisualReviewService()
self.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,
)
self.runtime_incident_journal_service = runtime_incident_journal_service or RuntimeIncidentJournalService()
self.symbols = [item.strip() for item in settings.trading_runtime_symbols.split(",") if item.strip()]
self.bias_tf = settings.trading_runtime_bias_timeframe
self.setup_tf = settings.trading_runtime_setup_timeframe
self.trigger_tf = settings.trading_runtime_trigger_timeframe
self.tasks = {
"market_state": RuntimeTaskState("market_state", settings.trading_runtime_market_state_interval_seconds),
"broker_sync": RuntimeTaskState("broker_sync", settings.trading_runtime_broker_sync_interval_seconds),
"reconciliation": RuntimeTaskState("reconciliation", settings.trading_runtime_reconciliation_interval_seconds),
"watch": RuntimeTaskState("watch", settings.trading_runtime_watch_interval_seconds),
"research_council": RuntimeTaskState("research_council", settings.trading_runtime_research_council_interval_seconds),
"decision_packet": RuntimeTaskState("decision_packet", settings.trading_runtime_decision_packet_interval_seconds),
"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),
"plan": RuntimeTaskState("plan", settings.trading_runtime_plan_interval_seconds),
}
self.current_blockers: list[str] = []
self.current_warnings: list[str] = []
self.current_noisy_incidents: list[str] = []
self.degraded_mode_state: dict = {}
self.incidents: list[dict] = []
self.latest_market_state_by_symbol: dict[str, dict] = {}
self.latest_broker_state_by_symbol: dict[str, dict] = {}
self.runtime_health: dict = {}
self.runtime_watchdog: dict = {}
def run_cycle(self) -> dict:
if not settings.trading_runtime_enabled:
self.current_blockers = ["trading_runtime_disabled"]
self.current_warnings = []
self.current_noisy_incidents = []
snapshot = self.status_snapshot()
self.runtime_health = dict(snapshot.get("runtime_health") or {})
self.degraded_mode_state = dict(snapshot.get("degraded_mode_state") or {})
self.runtime_watchdog = dict(snapshot.get("runtime_watchdog") or {})
return snapshot
blockers: list[str] = []
warnings: list[str] = []
noisy_incidents: list[str] = []
now = datetime.now(timezone.utc)
for symbol in self.symbols:
instrument = self.trading_plan_service.repository.fetch_instrument_by_symbol(symbol)
if not instrument:
blockers.append(f"missing_instrument:{symbol}")
continue
instrument_id = int(instrument["id"])
self._run_plan_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
market_payload = self._run_market_state_task(
symbol=symbol,
instrument_id=instrument_id,
now=now,
blockers=blockers,
warnings=warnings,
noisy_incidents=noisy_incidents,
)
self._run_broker_sync_task(
symbol=symbol,
instrument_id=instrument_id,
now=now,
blockers=blockers,
warnings=warnings,
)
self._run_reconciliation_task(
symbol=symbol,
instrument_id=instrument_id,
now=now,
blockers=blockers,
warnings=warnings,
)
self._run_watch_task(
symbol=symbol,
instrument_id=instrument_id,
now=now,
market_payload=market_payload,
blockers=blockers,
warnings=warnings,
noisy_incidents=noisy_incidents,
)
self._run_research_council_task(
symbol=symbol,
instrument_id=instrument_id,
now=now,
warnings=warnings,
)
self._run_decision_packet_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
self._run_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)
self.current_blockers = _dedupe(blockers)
self.current_warnings = _dedupe(warnings)
self.current_noisy_incidents = _dedupe(noisy_incidents)
snapshot = self.status_snapshot()
self.runtime_health = dict(snapshot.get("runtime_health") or {})
self.degraded_mode_state = dict(snapshot.get("degraded_mode_state") or {})
self.runtime_watchdog = dict(snapshot.get("runtime_watchdog") or {})
return snapshot
def status_snapshot(self) -> dict:
runtime_watchdog = (
self.runtime_task_watchdog_service.build_watchdog(self.tasks)
if settings.runtime_task_watchdog_enabled
else {"checked_ts": datetime.now(timezone.utc).isoformat(), "stalled_task_count": 0, "stalled_task_names": [], "stalled_tasks": [], "warning_codes": []}
)
current_warnings = _dedupe([*self.current_warnings, *list(runtime_watchdog.get("warning_codes") or [])])
runtime_health = _build_runtime_health(self.current_blockers, current_warnings, self.current_noisy_incidents)
degraded_mode_state = {
"is_degraded": bool(self.current_blockers or current_warnings),
"codes": [*self.current_blockers, *current_warnings],
}
snapshot = {
"enabled": bool(settings.trading_runtime_enabled),
"scheduler_state": "running" if settings.trading_runtime_enabled else "disabled",
"symbols": list(self.symbols),
"timeframe_set": {
"bias_tf": self.bias_tf,
"setup_tf": self.setup_tf,
"trigger_tf": self.trigger_tf,
},
"tasks": {name: task.to_dict() for name, task in self.tasks.items()},
"current_blockers": list(self.current_blockers),
"current_warnings": list(current_warnings),
"current_noisy_incidents": list(self.current_noisy_incidents),
"runtime_health": runtime_health,
"degraded_mode_state": degraded_mode_state,
"runtime_watchdog": runtime_watchdog,
"incidents": list(self.incidents[-50:]),
"latest_market_state_by_symbol": dict(self.latest_market_state_by_symbol),
"latest_broker_state_by_symbol": dict(self.latest_broker_state_by_symbol),
}
snapshot["incident_summary"] = self.runtime_incident_journal_service.build_from_runtime_status(
snapshot,
limit=50,
persist=False,
)
return snapshot
def _run_plan_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None:
task = self.tasks["plan"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
candidate = self.trading_plan_service.build_plan(
instrument_id=instrument_id,
bias_tf=self.bias_tf,
setup_tf=self.setup_tf,
trigger_tf=self.trigger_tf,
plan_date=now.date(),
)
plan_id = self.trading_plan_service.persist_plan(candidate)
task.details = {"symbol": symbol, "instrument_id": instrument_id, "plan_id": plan_id, "status": candidate.status}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"plan_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now)
def _run_market_state_task(
self,
*,
symbol: str,
instrument_id: int,
now: datetime,
blockers: list[str],
warnings: list[str],
noisy_incidents: list[str],
) -> dict:
task = self.tasks["market_state"]
if not _task_due(task, now):
return self.latest_market_state_by_symbol.get(symbol, {})
_task_start(task, now)
try:
market_state = self.market_state_service.build_market_state(
instrument_id=instrument_id,
timeframe=self.trigger_tf,
)
snapshot_id = self.market_state_service.persist_market_state_snapshot(market_state=market_state)
payload = market_state.to_dict()
payload["snapshot_id"] = snapshot_id
self.latest_market_state_by_symbol[symbol] = payload
task.details = {"symbol": symbol, "instrument_id": instrument_id, "snapshot_id": snapshot_id}
_task_success(task, now)
if payload.get("freshness", {}).get("code") != "market_data_fresh":
code = f"market_state_degraded:{symbol}:{payload.get('freshness', {}).get('code')}"
warnings.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", observed_ts=now)
if payload.get("stream_state", {}).get("code") != "market_stream_connected":
code = f"market_stream_degraded:{symbol}:{payload.get('stream_state', {}).get('code')}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", observed_ts=now)
if payload.get("data_gap_state", {}).get("code") != "market_data_gap_clear":
code = f"market_gap_degraded:{symbol}:{payload.get('data_gap_state', {}).get('code')}"
warnings.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", observed_ts=now)
return payload
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"market_state_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now)
return self.latest_market_state_by_symbol.get(symbol, {})
def _run_broker_sync_task(
self,
*,
symbol: str,
instrument_id: int,
now: datetime,
blockers: list[str],
warnings: list[str],
) -> None:
task = self.tasks["broker_sync"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
snapshot = self.broker_read_model_service.fetch_snapshot(
instrument_id=instrument_id,
known_execution_refs={"broker_order_ids": [], "open_execution_count": 0},
)
self.latest_broker_state_by_symbol[symbol] = snapshot.to_dict()
task.details = {"symbol": symbol, "instrument_id": instrument_id, "status": snapshot.status}
if snapshot.status != BROKER_STATE_OK:
code = f"broker_sync_degraded:{symbol}:{snapshot.error or snapshot.status}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=snapshot.error, observed_ts=now)
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"broker_sync_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now)
def _run_watch_task(
self,
*,
symbol: str,
instrument_id: int,
now: datetime,
market_payload: dict,
blockers: list[str],
warnings: list[str],
noisy_incidents: list[str],
) -> None:
task = self.tasks["watch"]
if not _task_due(task, now):
return
_task_start(task, now)
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")
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,
setup_tf=self.setup_tf,
trigger_tf=self.trigger_tf,
plan_date=now.date(),
)
candidate_id = self.setup_watcher_service.persist_candidate(candidate) if candidate is not None else None
task.details = {
"symbol": symbol,
"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:
_task_failure(task, now, str(exc))
code = f"watch_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now)
def _run_reconciliation_task(
self,
*,
symbol: str,
instrument_id: int,
now: datetime,
blockers: list[str],
warnings: list[str],
) -> None:
task = self.tasks["reconciliation"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
summary = self.reconciliation_service.reconcile_instrument(instrument_id=instrument_id, now=now)
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"status": summary.get("status"),
"matched_execution_ids": list(summary.get("matched_execution_ids") or []),
"imported_execution_ids": list(summary.get("imported_execution_ids") or []),
"journal_updates": list(summary.get("journal_updates") or []),
}
if summary.get("status") != "ok":
code = f"reconciliation_degraded:{symbol}:{summary.get('error') or summary.get('status')}"
warnings.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(summary.get("error")), observed_ts=now)
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"reconciliation_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="blocker", error=str(exc), observed_ts=now)
def _run_decision_packet_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None:
task = self.tasks["decision_packet"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
packet = self.decision_packet_service.build_persist_and_alert(instrument_id=instrument_id)
visual_trigger_types: list[str] = []
visual_trigger_policy_statuses: list[str] = []
visual_trigger_skip_reasons: list[str] = []
visual_trigger_policy_reasons: list[str] = []
visual_triggered_alert_count = 0
visual_trigger_skipped_alert_count = 0
for item in list(packet.get("alerts") or []):
payload = dict(item.get("payload") or {})
visual_trigger_types.extend(str(value) for value in list(payload.get("visual_trigger_types") or []) if value)
if payload.get("visual_trigger_type"):
visual_trigger_types.append(str(payload.get("visual_trigger_type")))
policy = dict(payload.get("visual_trigger_policy") or {})
if policy.get("status"):
policy_status = str(policy.get("status"))
visual_trigger_policy_statuses.append(policy_status)
if policy_status == "triggered":
visual_triggered_alert_count += 1
if policy_status == "skipped":
visual_trigger_skipped_alert_count += 1
if policy.get("skip_reason"):
visual_trigger_skip_reasons.append(str(policy.get("skip_reason")))
if policy.get("reason"):
visual_trigger_policy_reasons.append(str(policy.get("reason")))
visual_trigger_types = _dedupe(visual_trigger_types)
visual_trigger_policy_statuses = _dedupe(visual_trigger_policy_statuses)
visual_trigger_skip_reasons = _dedupe(visual_trigger_skip_reasons)
visual_trigger_policy_reasons = _dedupe(visual_trigger_policy_reasons)
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"packet_id": packet.get("id"),
"status": packet.get("status"),
"action_recommendation": packet.get("action_recommendation"),
"alerts": len(packet.get("alerts") or []),
"visual_trigger_types": visual_trigger_types,
"visual_watch_reserved": bool(visual_trigger_types),
"visual_trigger_policy_statuses": visual_trigger_policy_statuses,
"visual_trigger_skip_reasons": visual_trigger_skip_reasons,
"visual_trigger_policy_reasons": visual_trigger_policy_reasons,
"visual_triggered_alert_count": visual_triggered_alert_count,
"visual_trigger_skipped_alert_count": visual_trigger_skipped_alert_count,
"research_council_present": bool((packet.get("research_council") or {}).get("present")),
}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"decision_packet_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, 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),
"latest_market_state_by_symbol": dict(self.latest_market_state_by_symbol),
},
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,
*,
symbol: str,
instrument_id: int,
now: datetime,
warnings: list[str],
) -> None:
task = self.tasks["research_council"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
payload = self.research_council_service.build_and_persist(
instrument_id=instrument_id,
timeframe=self.trigger_tf,
now=now,
)
decision = dict((payload.get("payload") or {}).get("decision") or payload.get("decision") or {})
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"run_id": payload.get("id"),
"status": payload.get("status") or "completed",
"overall_stance": decision.get("overall_stance"),
"committee_confidence": decision.get("committee_confidence"),
"recommended_watch_items": list(decision.get("recommended_watch_items") or []),
}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"research_council_task_failed:{symbol}"
warnings.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(exc), observed_ts=now)
def _run_visual_watch_task(self, *, symbol: str, instrument_id: int, now: datetime, warnings: list[str]) -> None:
task = self.tasks["visual_watch"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
payload = self.visual_review_service.review_pending_watch_runs(limit=1, instrument_id=instrument_id)
items = list(payload.get("items") or [])
latest = items[-1] if items else {}
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"status": payload.get("status"),
"skip_reason": payload.get("skip_reason"),
"attempted": int(payload.get("attempted") or 0),
"visual_reviews_created": int(payload.get("reviewed") or 0),
"trigger_type_counts": dict(payload.get("trigger_type_counts") or {}),
"review_state_counts": dict(payload.get("review_state_counts") or {}),
"last_visual_state": latest.get("visual_state"),
"last_snapshot_source": (
latest.get("snapshot_source_name") or "snapshot_repository"
if latest.get("snapshot_id")
else "none"
),
"last_snapshot_image_path": latest.get("snapshot_image_path"),
"last_snapshot_artifact_path": latest.get("snapshot_artifact_path"),
"last_watch_run_id": latest.get("watch_run_id"),
}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"visual_watch_task_failed:{symbol}"
warnings.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, severity="warning", error=str(exc), observed_ts=now)
def _run_alert_dispatch_task(self, *, now: datetime, blockers: list[str], warnings: list[str]) -> None:
task = self.tasks["alert_dispatch"]
if not _task_due(task, now):
return
if not settings.alert_dispatch_enabled:
task.details = {"status": "disabled"}
_task_success(task, now)
return
_task_start(task, now)
try:
payload = self.alert_dispatcher_service.dispatch_pending(limit=settings.alert_dispatch_max_per_run)
items = list(payload.get("items") or [])
failed = [item for item in items if item.get("status") == "failed"]
silent_window = dict(payload.get("silent_window") or {})
task.details = {
"attempted": int(payload.get("attempted") or 0),
"queued_candidates": int(payload.get("queued_candidates") or 0),
"retry_candidates": int(payload.get("retry_candidates") or 0),
"retried": int(payload.get("retried") or 0),
"deferred_retry_backoff": int(payload.get("deferred_retry_backoff") or 0),
"retry_exhausted": int(payload.get("retry_exhausted") or 0),
"transport_retried": int(payload.get("transport_retried") or 0),
"transport_retry_failed": int(payload.get("transport_retry_failed") or 0),
"policy_external_allowed": int(payload.get("policy_external_allowed") or 0),
"workbench_only": int(payload.get("workbench_only") or 0),
"suppressed_noise_policy": int(payload.get("suppressed_noise_policy") or 0),
"media_attached": int(payload.get("media_attached") or 0),
"media_linked": int(payload.get("media_linked") or 0),
"media_unavailable": int(payload.get("media_unavailable") or 0),
"channel_disabled": int(payload.get("channel_disabled") or 0),
"channel_summary": dict(payload.get("channel_summary") or {}),
"channel_health": dict(payload.get("channel_health") or {}),
"delivered": sum(1 for item in items if item.get("status") == "delivered"),
"skipped": sum(1 for item in items if item.get("status") == "skipped"),
"failed": len(failed),
"silent_window_active": bool(silent_window.get("active")),
"silent_window_label": silent_window.get("window_label"),
"silent_window_next_allowed_ts": silent_window.get("next_allowed_ts"),
"suppressed_silent_window": int(payload.get("suppressed_silent_window") or 0),
}
if failed:
code = "alert_dispatch_failed"
warnings.append(code)
self._record_incident(task_name=task.name, symbol="runtime", code=code, severity="warning", error=str(failed[0].get("error") or "dispatch_failed"), observed_ts=now)
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = "alert_dispatch_task_failed"
blockers.append(code)
self._record_incident(task_name=task.name, symbol="runtime", code=code, severity="blocker", error=str(exc), observed_ts=now)
def _run_visual_capture_cleanup_task(self, *, now: datetime, warnings: list[str]) -> None:
task = self.tasks["visual_capture_cleanup"]
if not _task_due(task, now):
return
if not settings.visual_capture_cleanup_enabled:
task.details = {"status": "disabled"}
_task_success(task, now)
return
_task_start(task, now)
try:
payload = self.visual_capture_retention_service.cleanup_captures(
retention_hours=settings.visual_capture_retention_hours,
max_delete_files=settings.visual_capture_cleanup_max_delete_files,
dry_run=False,
)
task.details = {
"status": payload.get("status"),
"root": payload.get("root"),
"retention_hours": payload.get("retention_hours"),
"eligible_file_count": int(payload.get("eligible_file_count") or 0),
"deleted_file_count": int(payload.get("deleted_file_count") or 0),
"deleted_bytes": int(payload.get("deleted_bytes") or 0),
"retained_file_count": int(payload.get("retained_file_count") or 0),
"reason": payload.get("reason"),
}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = "visual_capture_cleanup_task_failed"
warnings.append(code)
self._record_incident(task_name=task.name, symbol="runtime", code=code, severity="warning", error=str(exc), observed_ts=now)
def _record_incident(
self,
*,
task_name: str,
symbol: str,
code: str,
severity: str,
observed_ts: datetime,
error: Optional[str] = None,
) -> None:
self.incidents.append(
{
"task_name": task_name,
"symbol": symbol,
"code": code,
"severity": severity,
"error": error,
"observed_ts": observed_ts.isoformat(),
}
)
if len(self.incidents) > 200:
self.incidents = self.incidents[-200:]
class TradingRuntimeSupervisor:
def __init__(self, scheduler: Optional[TradingRuntimeScheduler] = None) -> None:
self.scheduler = scheduler or TradingRuntimeScheduler()
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._lock = threading.RLock()
self._last_status: dict = self.scheduler.status_snapshot()
self._started_ts: Optional[str] = None
def start(self) -> None:
if not settings.trading_runtime_enabled:
return
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._started_ts = datetime.now(timezone.utc).isoformat()
self._thread = threading.Thread(target=self._run_loop, name="trading-runtime-supervisor", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
with self._lock:
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2)
def status(self) -> dict:
with self._lock:
status = self.scheduler.status_snapshot()
self._last_status = status
status["supervisor_state"] = "running" if self._thread and self._thread.is_alive() else "stopped"
status["started_ts"] = self._started_ts
return status
def run_once(self) -> dict:
status = self.scheduler.run_cycle()
with self._lock:
self._last_status = status
current = dict(status)
current["supervisor_state"] = "running" if self._thread and self._thread.is_alive() else "stopped"
current["started_ts"] = self._started_ts
return current
def _run_loop(self) -> None:
while not self._stop_event.is_set():
self.run_once()
for _ in range(5):
if self._stop_event.is_set():
return
time.sleep(1)
_runtime_supervisor: Optional[TradingRuntimeSupervisor] = None
def get_trading_runtime_service() -> TradingRuntimeSupervisor:
global _runtime_supervisor
if _runtime_supervisor is None:
_runtime_supervisor = TradingRuntimeSupervisor()
return _runtime_supervisor
def start_trading_runtime_service() -> TradingRuntimeSupervisor:
service = get_trading_runtime_service()
service.start()
return service
def _task_due(task: RuntimeTaskState, now: datetime) -> bool:
if task.last_started_ts is None:
return True
last_started = _parse_iso(task.last_started_ts)
return (now - last_started).total_seconds() >= max(task.interval_seconds, 1)
def _task_start(task: RuntimeTaskState, now: datetime) -> None:
task.last_started_ts = now.isoformat()
task.last_status = "running"
task.last_error = None
task.run_count += 1
def _task_success(task: RuntimeTaskState, now: datetime) -> None:
iso = now.isoformat()
task.last_completed_ts = iso
task.last_success_ts = iso
task.next_run_ts = _next_run_iso(task, now)
task.last_status = "ok"
task.last_error = None
task.success_count += 1
def _task_failure(task: RuntimeTaskState, now: datetime, error: str) -> None:
task.last_completed_ts = now.isoformat()
task.next_run_ts = _next_run_iso(task, now)
task.last_status = "error"
task.last_error = error
task.failure_count += 1
def _parse_iso(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
def _next_run_iso(task: RuntimeTaskState, now: datetime) -> str:
return datetime.fromtimestamp(now.timestamp() + max(task.interval_seconds, 1), tz=timezone.utc).isoformat()
def _dedupe(values: list[str]) -> list[str]:
result = []
seen = set()
for item in values:
text = str(item or "").strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
return result
def _build_runtime_health(blockers: list[str], warnings: list[str], noisy_incidents: list[str]) -> dict:
if blockers:
level = "blocked"
elif warnings:
level = "warning"
elif noisy_incidents:
level = "noisy"
else:
level = "ok"
return {
"level": level,
"blocker_count": len(blockers),
"warning_count": len(warnings),
"noisy_incident_count": len(noisy_incidents),
}