diff --git a/scripts/run_live_readiness.py b/scripts/run_live_readiness.py new file mode 100644 index 0000000..7a5bc96 --- /dev/null +++ b/scripts/run_live_readiness.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.broker import LiveBrokerService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("LIVE_READINESS_INSTRUMENT_ID", "1")) + timeframe = str(env.get("LIVE_READINESS_TIMEFRAME") or "1m") + output_json = _get_bool(env, "LIVE_READINESS_OUTPUT_JSON", False) + payload = LiveBrokerService().build_live_readiness(instrument_id=instrument_id, timeframe=timeframe) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"Live readiness: {payload.get('ready_for_real_live')}") + print(f"Blocking reasons: {payload.get('blocking_reasons') or []}") + return 0 if payload.get("ready_for_real_live") else 2 + + +def _get_bool(env, key: str, default: bool = False) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/__init__.py b/src/api/__init__.py index 2ba819c..522fbef 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -25,6 +25,7 @@ from src.api.trading_runtime import ( get_latest_trading_plan, get_trading_candidate_by_id, get_trading_market_state, + get_trading_live_readiness, get_trading_operator_notes, get_trading_plan_by_id, get_trading_review_packet, @@ -57,6 +58,7 @@ __all__ = [ "get_recent_trading_journal", "get_latest_trading_plan", "get_trading_market_state", + "get_trading_live_readiness", "get_trading_plan_by_id", "get_trading_runtime_status", "get_trading_trust_score", diff --git a/src/api/server.py b/src/api/server.py index 79c2fbd..8bd13cf 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -29,6 +29,7 @@ from src.api.trading_runtime import ( get_trading_operator_notes, get_trading_plan_by_id, get_trading_review_packet, + get_trading_live_readiness, get_trading_runtime_status, get_trading_session_view, get_trading_ticket_by_id, @@ -87,6 +88,7 @@ def build_api_dependencies() -> dict: "trading_operator_notes": get_trading_operator_notes, "trading_operator_note_create": create_trading_operator_note, "trading_review_packet": get_trading_review_packet, + "trading_live_readiness": get_trading_live_readiness, "trading_watch_run": run_trading_watch, "trading_candidate_by_id": get_trading_candidate_by_id, "trading_candidate_confirm_check": run_candidate_confirm_check, @@ -229,6 +231,10 @@ def route_request( if method == "GET" and path == "/trading/review-packet": instrument_id = _require_int(query, "instrument_id") return 200, deps["trading_review_packet"](instrument_id=instrument_id) + if method == "GET" and path == "/trading/live-readiness": + instrument_id = _require_int(query, "instrument_id") + timeframe = _get_single(query, "timeframe", "1m") + return 200, deps["trading_live_readiness"](instrument_id=instrument_id, timeframe=timeframe) if method == "POST" and path == "/trading/watch/run": instrument_id = _require_json_int(body or {}, "instrument_id") candidate = deps["trading_watch_run"]( diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index 00324f3..5d6f2d9 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -8,6 +8,7 @@ from src.repositories.signal_repository import SignalRepository from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository from src.repositories.trading_plan_repository import TradingPlanRepository from src.services.broker import BrokerReadModelService, BROKER_STATE_OK +from src.services.broker import LiveBrokerService from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService from src.services.journal import TradingJournalService, WeeklyReviewService @@ -209,51 +210,8 @@ def rerun_ticket_risk_check( def confirm_ticket_only(ticket_id: int, confirmed_by: str) -> Optional[dict]: service = build_execution_service() - ticket = service.repository.fetch_execution_ticket_by_id(ticket_id) - if not ticket: - return None - signal = service.repository.fetch_signal_for_execution(int(ticket["signal_id"])) - if not signal: - return None - risk_context = service.build_risk_context_for_signal(signal) - decision = service.prepare_execution( - signal_id=int(ticket["signal_id"]), - execution_mode=str(ticket["execution_mode"]), - risk_context=risk_context, - ) - confirmed_at = datetime.now(timezone.utc) - status = "confirmed" if decision and decision.allowed else "rejected" - snapshot = service.repository.fetch_execution_ticket_by_id(ticket_id).get("ticket_snapshot") or {} - new_snapshot = service.repository.fetch_execution_ticket_by_id(ticket_id).get("ticket_snapshot") or {} - if decision: - new_snapshot = { - **dict(snapshot), - "confirmation": { - "confirmed_by": confirmed_by if decision.allowed else None, - "confirmed_at": confirmed_at.isoformat() if decision.allowed else None, - }, - "decision": { - "allowed": decision.allowed, - "status": decision.status, - "reasons": list(decision.reasons), - "requires_confirmation": decision.requires_confirmation, - "risk_pct": decision.risk_pct, - "size": decision.size, - "entry_price": decision.entry_price, - "meta": dict(decision.meta), - }, - "status": status, - } - service.repository.update_execution_ticket( - ticket_id=ticket_id, - status=status, - ticket_snapshot=new_snapshot, - confirmed_by=confirmed_by if status == "confirmed" else None, - confirmed_at=confirmed_at if status == "confirmed" else None, - execution_id=ticket.get("execution_id"), - ) - updated = service.repository.fetch_execution_ticket_by_id(ticket_id) - return dict(updated) if updated else None + decision = service.confirm_execution_ticket(ticket_id, confirmed_by=confirmed_by) + return decision.to_dict() if decision else None def execute_ticket(ticket_id: int, *, confirmed_by: str, execution_mode_expected: str) -> Optional[dict]: @@ -426,6 +384,10 @@ def get_trading_trust_score(instrument_id: int) -> dict: return ModelTrustService().build_summary(instrument_id=instrument_id) +def get_trading_live_readiness(instrument_id: int, timeframe: str = "1m") -> dict: + return build_live_broker_service().build_live_readiness(instrument_id=instrument_id, timeframe=timeframe) + + def get_trading_session_view(instrument_id: int, limit: int = 12) -> dict: return SessionFocusService().build_summary(instrument_id=instrument_id, limit=limit) @@ -465,6 +427,10 @@ def get_trading_review_packet(instrument_id: int) -> dict: return ReviewPacketService().build_packet(instrument_id=instrument_id) +def build_live_broker_service() -> LiveBrokerService: + return LiveBrokerService() + + def market_truth_degraded(market_state) -> bool: payload = market_state.to_dict() if hasattr(market_state, "to_dict") else dict(market_state or {}) freshness_code = str(((payload.get("freshness") or {}).get("code")) or "") diff --git a/src/services/broker/__init__.py b/src/services/broker/__init__.py index d755b89..a09acf0 100644 --- a/src/services/broker/__init__.py +++ b/src/services/broker/__init__.py @@ -6,7 +6,13 @@ from src.services.broker.broker_adapter import ( BrokerOperationResult, CancelAllRequest, KillSwitchRequest, + LiveOrderEnvelope, + LiveOrderLeg, ReconciliationRequest, + build_bracket_semantics, + build_live_order_envelope, + build_operator_confirmation_protocol, + build_reconciliation_semantics, ) from src.services.broker.broker_read_model_service import ( ALERT_UNKNOWN_OPEN_POSITION, @@ -22,6 +28,7 @@ from src.services.broker.live_broker_service import ( validate_live_dangerous_operation, validate_live_submit_request, ) +from src.services.broker.live_readiness_service import LiveReadinessService from src.services.broker.okx_order_adapter import OKXOrderAdapter __all__ = [ @@ -30,16 +37,23 @@ __all__ = [ "BrokerOperationResult", "CancelAllRequest", "KillSwitchRequest", + "LiveOrderEnvelope", + "LiveOrderLeg", "LiveBrokerService", + "LiveReadinessService", "LiveOperationDecision", "OKXOrderAdapter", "ReconciliationRequest", + "build_bracket_semantics", "BROKER_STATE_OK", "BROKER_STATE_UNAVAILABLE", "ALERT_UNKNOWN_OPEN_POSITION", "ALERT_UNKNOWN_PENDING_ORDER", "BrokerReadModelService", "build_broker_adapter", + "build_live_order_envelope", + "build_operator_confirmation_protocol", + "build_reconciliation_semantics", "validate_live_dangerous_operation", "validate_live_submit_request", ] diff --git a/src/services/broker/broker_adapter.py b/src/services/broker/broker_adapter.py index 6c0ef64..7885ebc 100644 --- a/src/services/broker/broker_adapter.py +++ b/src/services/broker/broker_adapter.py @@ -21,6 +21,38 @@ class BracketOrderRequest: return dict(asdict(self)) +@dataclass(frozen=True) +class LiveOrderLeg: + leg_type: str + side: str + price: float + trigger_price: Optional[float] + size: float + reduce_only: bool + + def to_dict(self) -> dict: + return dict(asdict(self)) + + +@dataclass(frozen=True) +class LiveOrderEnvelope: + schema_version: str + broker: str + instrument_id: int + execution_ticket_id: int + signal_id: int + side: str + size: float + entry_leg: dict + take_profit_legs: list[dict] + stop_loss_leg: dict + bracket_semantics: dict + reconciliation_semantics: dict + + def to_dict(self) -> dict: + return dict(asdict(self)) + + @dataclass(frozen=True) class ReconciliationRequest: instrument_id: Optional[int] @@ -86,3 +118,112 @@ class BrokerAdapter(ABC): @abstractmethod def activate_kill_switch(self, request: KillSwitchRequest) -> BrokerOperationResult: raise NotImplementedError + + +def build_live_order_envelope(request: BracketOrderRequest, *, broker: str) -> LiveOrderEnvelope: + entry_leg = LiveOrderLeg( + leg_type="entry", + side=request.side, + price=request.entry_price, + trigger_price=None, + size=request.size, + reduce_only=False, + ) + stop_side = "sell" if str(request.side).lower() == "buy" else "buy" + stop_loss_leg = LiveOrderLeg( + leg_type="stop_loss", + side=stop_side, + price=request.stop_loss, + trigger_price=request.stop_loss, + size=request.size, + reduce_only=True, + ) + tp1_size = round(float(request.size) * 0.5, 10) + tp2_size = max(float(request.size) - tp1_size, 0.0) + take_profit_legs = [ + LiveOrderLeg( + leg_type="take_profit_1", + side=stop_side, + price=request.take_profit_1, + trigger_price=request.take_profit_1, + size=tp1_size, + reduce_only=True, + ).to_dict(), + LiveOrderLeg( + leg_type="take_profit_2", + side=stop_side, + price=request.take_profit_2, + trigger_price=request.take_profit_2, + size=tp2_size, + reduce_only=True, + ).to_dict(), + ] + return LiveOrderEnvelope( + schema_version="live-order-v1", + broker=broker, + instrument_id=request.instrument_id, + execution_ticket_id=request.execution_ticket_id, + signal_id=request.signal_id, + side=request.side, + size=request.size, + entry_leg=entry_leg.to_dict(), + take_profit_legs=take_profit_legs, + stop_loss_leg=stop_loss_leg.to_dict(), + bracket_semantics=build_bracket_semantics(), + reconciliation_semantics=build_reconciliation_semantics(), + ) + + +def build_bracket_semantics() -> dict: + return { + "schema_version": "bracket-v1", + "oco_required": True, + "take_profit_policy": "split_tp1_tp2", + "stop_policy": "single_stop_reduce_only", + "states": [ + "not_submitted", + "submitted", + "live", + "tp1_partial", + "tp2_filled", + "stop_triggered", + "cancelled", + "rejected", + ], + } + + +def build_reconciliation_semantics() -> dict: + return { + "schema_version": "reconciliation-v1", + "states": [ + "not_reconciled", + "matched_fill", + "matched_fill_flat", + "position_only", + "imported_external_fill", + "placeholder_reconciliation", + ], + "required_keys": [ + "status", + "broker_order_ids", + "broker_fill_ids", + "position_detected", + "pending_order_detected", + ], + } + + +def build_operator_confirmation_protocol() -> dict: + return { + "protocol_version": "live-confirmation-v1", + "steps": [ + "review_live_readiness", + "confirm_market_truth_fresh", + "confirm_risk_governor_pass", + "confirm_broker_state_clean", + "confirm_order_schema_preview", + ], + "requires_human_identity": True, + "requires_explicit_live_confirmation": True, + } diff --git a/src/services/broker/live_broker_service.py b/src/services/broker/live_broker_service.py index 6dfa185..83026a9 100644 --- a/src/services/broker/live_broker_service.py +++ b/src/services/broker/live_broker_service.py @@ -10,8 +10,11 @@ from src.services.broker.broker_adapter import ( CancelAllRequest, KillSwitchRequest, ReconciliationRequest, + build_live_order_envelope, + build_operator_confirmation_protocol, ) from src.services.broker.okx_order_adapter import OKXOrderAdapter +from src.services.broker.live_readiness_service import LiveReadinessService @dataclass(frozen=True) @@ -40,6 +43,9 @@ class LiveBrokerService: def __init__(self, repository: Optional[ExecutionRepository] = None) -> None: self.repository = repository or ExecutionRepository() + def build_live_readiness(self, *, instrument_id: int, timeframe: str = "1m") -> dict: + return LiveReadinessService(execution_repository=self.repository).build_summary(instrument_id=instrument_id, timeframe=timeframe) + def submit_confirmed_ticket( self, ticket_id: int, @@ -128,6 +134,7 @@ class LiveBrokerService: oco_group_id=f"ticket:{ticket_id}", ticket_snapshot=snapshot, ) + envelope = build_live_order_envelope(request, broker=adapter_name) result = adapter.submit_bracket_order(request) return self._record_live_audit( adapter_name=adapter_name, @@ -139,7 +146,11 @@ class LiveBrokerService: signal_id=int(ticket["signal_id"]), instrument_id=int(signal["instrument_id"]), explicit_confirmation=explicit_live_confirmation, - payload=request.to_dict(), + payload={ + "request": request.to_dict(), + "live_order_envelope": envelope.to_dict(), + "operator_confirmation_protocol": build_operator_confirmation_protocol(), + }, result=result.to_dict(), reason_codes=result.reason_codes, ) @@ -379,6 +390,10 @@ def validate_live_submit_request( reason_codes.append("execution_ticket_not_confirmed") if not ticket.get("confirmed_by") or not ticket.get("confirmed_at"): reason_codes.append("execution_ticket_missing_confirmation_metadata") + confirmation = dict((ticket.get("ticket_snapshot") or {}).get("confirmation") or {}) + protocol = dict(confirmation.get("protocol") or {}) + if protocol.get("protocol_version") != build_operator_confirmation_protocol()["protocol_version"]: + reason_codes.append("operator_confirmation_protocol_missing") if not ticket.get("execution_id"): reason_codes.append("execution_ticket_missing_execution_record") return _dedupe(reason_codes) diff --git a/src/services/broker/live_readiness_service.py b/src/services/broker/live_readiness_service.py new file mode 100644 index 0000000..303ca01 --- /dev/null +++ b/src/services/broker/live_readiness_service.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Optional + +from src.config.settings import settings +from src.repositories.execution_repository import ExecutionRepository +from src.services.broker.broker_adapter import ( + build_bracket_semantics, + build_operator_confirmation_protocol, + build_reconciliation_semantics, +) +from src.services.broker.broker_read_model_service import BROKER_STATE_OK, BrokerReadModelService +from src.services.decision import DecisionPacketService +from src.services.market_state import MarketStateService +from src.services.runtime import get_trading_runtime_service +from src.services.trust import ModelTrustService + + +@dataclass(frozen=True) +class LiveReadinessCheck: + code: str + passed: bool + detail: str + + def to_dict(self) -> dict: + return dict(asdict(self)) + + +class LiveReadinessService: + def __init__( + self, + *, + broker_read_model_service: Optional[BrokerReadModelService] = None, + market_state_service: Optional[MarketStateService] = None, + execution_repository: Optional[ExecutionRepository] = None, + decision_packet_service: Optional[DecisionPacketService] = None, + trust_service: Optional[ModelTrustService] = None, + ) -> None: + self.broker_read_model_service = broker_read_model_service or BrokerReadModelService() + self.market_state_service = market_state_service or MarketStateService() + self.execution_repository = execution_repository or ExecutionRepository() + self.decision_packet_service = decision_packet_service or DecisionPacketService() + self.trust_service = trust_service or ModelTrustService() + + def build_summary(self, *, instrument_id: int, timeframe: str = "1m") -> dict: + now = datetime.now(timezone.utc) + market_state = self.market_state_service.build_market_state(instrument_id=instrument_id, timeframe=timeframe) + broker_state = self.broker_read_model_service.fetch_snapshot( + instrument_id=instrument_id, + now=now, + known_execution_refs={"broker_order_ids": [], "open_execution_count": 0}, + ) + runtime_status = get_trading_runtime_service().status() + trust_score = self.trust_service.build_summary(instrument_id=instrument_id) + latest_ticket = self.execution_repository.fetch_recent_execution_ticket_rows(instrument_id=instrument_id, limit=1) + latest_audits = self.execution_repository.fetch_recent_live_operation_audits(instrument_id=instrument_id, limit=10) + decision_preview = self.decision_packet_service.build_preview(instrument_id=instrument_id) + protocol = build_operator_confirmation_protocol() + checklist = [ + LiveReadinessCheck( + code="live_trading_disabled_by_default", + passed=not bool(settings.live_trading_enabled), + detail="`LIVE_TRADING_ENABLED` should stay off unless an operator explicitly flips it.", + ), + LiveReadinessCheck( + code="broker_read_ready", + passed=broker_state.status == BROKER_STATE_OK, + detail=f"broker_state={broker_state.status}", + ), + LiveReadinessCheck( + code="market_truth_ready", + passed=market_truth_ready(market_state.to_dict()), + detail=f"freshness={market_state.freshness.code}, stream={market_state.stream_state.code}, gap={market_state.data_gap_state.code}", + ), + LiveReadinessCheck( + code="runtime_ready", + passed=runtime_status.get("scheduler_state") == "running" and not list(runtime_status.get("current_blockers") or []), + detail=f"runtime_blockers={runtime_status.get('current_blockers') or []}", + ), + LiveReadinessCheck( + code="decision_packet_ready", + passed=bool(decision_preview.get("action_recommendation")), + detail=f"decision_action={decision_preview.get('action_recommendation')}", + ), + LiveReadinessCheck( + code="trust_guard_ready", + passed=bool(trust_score.get("models")), + detail=f"whitelist={trust_score.get('whitelist') or []}, blacklist={trust_score.get('blacklist') or []}", + ), + LiveReadinessCheck( + code="operator_confirmation_protocol_ready", + passed=True, + detail=f"protocol_version={protocol['protocol_version']}", + ), + LiveReadinessCheck( + code="adapter_real_live_not_ready", + passed=False, + detail="OKX order adapter is still placeholder-only; real live submit remains intentionally blocked.", + ), + ] + blocking_reasons = [item.code for item in checklist if not item.passed] + return { + "generated_ts": now.isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "ready_for_real_live": len(blocking_reasons) == 0, + "blocking_reasons": blocking_reasons, + "checklist": [item.to_dict() for item in checklist], + "operator_confirmation_protocol": protocol, + "live_order_schema": { + "schema_version": "live-order-v1", + "bracket_semantics": build_bracket_semantics(), + "reconciliation_semantics": build_reconciliation_semantics(), + }, + "market_state": market_state.to_dict(), + "broker_state": broker_state.to_dict(), + "runtime_status": runtime_status, + "trust_score": trust_score, + "decision_packet_preview": decision_preview, + "recent_live_audits": latest_audits, + "latest_ticket": latest_ticket[0] if latest_ticket else None, + } + + +def market_truth_ready(payload: dict) -> bool: + freshness_code = str(((payload.get("freshness") or {}).get("code")) or "") + stream_code = str(((payload.get("stream_state") or {}).get("code")) or "") + gap_code = str(((payload.get("data_gap_state") or {}).get("code")) or "") + return freshness_code == "market_data_fresh" and stream_code == "market_stream_connected" and gap_code == "market_data_gap_clear" diff --git a/src/services/broker/okx_order_adapter.py b/src/services/broker/okx_order_adapter.py index 16503dc..d0608df 100644 --- a/src/services/broker/okx_order_adapter.py +++ b/src/services/broker/okx_order_adapter.py @@ -8,6 +8,9 @@ from src.services.broker.broker_adapter import ( CancelAllRequest, KillSwitchRequest, ReconciliationRequest, + build_bracket_semantics, + build_live_order_envelope, + build_reconciliation_semantics, ) @@ -18,6 +21,7 @@ class OKXOrderAdapter(BrokerAdapter): self.base_url = base_url or settings.okx_order_base_url def submit_bracket_order(self, request: BracketOrderRequest) -> BrokerOperationResult: + envelope = build_live_order_envelope(request, broker=self.adapter_name) return BrokerOperationResult( ok=False, status="placeholder_not_implemented", @@ -28,6 +32,7 @@ class OKXOrderAdapter(BrokerAdapter): "base_url": self.base_url, "supports_bracket": True, "supports_oco": True, + "order_envelope": envelope.to_dict(), "request": request.to_dict(), }, ) @@ -43,6 +48,7 @@ class OKXOrderAdapter(BrokerAdapter): "base_url": self.base_url, "request": request.to_dict(), "supports_reconciliation": True, + "reconciliation_semantics": build_reconciliation_semantics(), }, ) @@ -57,6 +63,7 @@ class OKXOrderAdapter(BrokerAdapter): "base_url": self.base_url, "request": request.to_dict(), "supports_cancel_all": True, + "bracket_semantics": build_bracket_semantics(), }, ) diff --git a/src/services/execution/execution_service.py b/src/services/execution/execution_service.py index 9de464d..5f38a88 100644 --- a/src/services/execution/execution_service.py +++ b/src/services/execution/execution_service.py @@ -4,7 +4,13 @@ import math from typing import Optional from src.repositories.execution_repository import ExecutionRepository -from src.services.broker import BrokerReadModelService, BROKER_STATE_OK +from src.services.broker import ( + BROKER_STATE_OK, + BrokerReadModelService, + build_bracket_semantics, + build_operator_confirmation_protocol, + build_reconciliation_semantics, +) from src.services.backtest import ( BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY, BACKTEST_LIFECYCLE_MISSED, @@ -857,8 +863,8 @@ def evaluate_execution_decision( meta.setdefault("confirmed_by", None) meta.setdefault("confirmed_at", None) meta.setdefault("broker_order_ids", []) - meta.setdefault("bracket_state", {"status": "not_submitted", "oco_group_id": None}) - meta.setdefault("reconciliation_state", {"status": "not_reconciled"}) + meta.setdefault("bracket_state", {"status": "not_submitted", "oco_group_id": None, "semantics": build_bracket_semantics()}) + meta.setdefault("reconciliation_state", {"status": "not_reconciled", "semantics": build_reconciliation_semantics()}) allowed = len(reasons) == 0 requires_confirmation = execution_mode == "semi_auto" @@ -944,7 +950,10 @@ def build_execution_ticket_snapshot( snapshot["confirmation"] = { "confirmed_by": confirmed_by, "confirmed_at": confirmed_at.isoformat() if hasattr(confirmed_at, "isoformat") else confirmed_at, + "protocol": build_operator_confirmation_protocol(), + "explicit_live_confirmation_required": True, } + snapshot["operator_confirmation_protocol"] = build_operator_confirmation_protocol() return snapshot diff --git a/src/web/app.js b/src/web/app.js index 093942a..eb78b82 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -15,6 +15,7 @@ const marketStatePanel = document.getElementById('marketStatePanel'); const runtimeStatusPanel = document.getElementById('runtimeStatusPanel'); const decisionPacketPanel = document.getElementById('decisionPacketPanel'); const trustScorePanel = document.getElementById('trustScorePanel'); +const liveReadinessPanel = document.getElementById('liveReadinessPanel'); const sessionFocusPanel = document.getElementById('sessionFocusPanel'); const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); @@ -412,6 +413,35 @@ function renderTrustScore(payload) { trustScorePanel.appendChild(grid); } +function renderLiveReadiness(payload) { + clearNode(liveReadinessPanel); + liveReadinessPanel.appendChild( + buildInfoGrid([ + ['ready for real live', String(Boolean(payload?.ready_for_real_live))], + ['blocking reasons', formatList(payload?.blocking_reasons || [])], + ['protocol version', payload?.operator_confirmation_protocol?.protocol_version || 'n/a'], + ['latest live audits', String((payload?.recent_live_audits || []).length)], + ['latest ticket', payload?.latest_ticket?.id || 'n/a'], + ]) + ); + const grid = createElement('div', 'cards'); + (payload?.checklist || []).forEach((item) => { + grid.appendChild( + buildItemCard( + item.code || 'check', + item.passed ? 'passed' : 'blocked', + [ + ['passed', String(Boolean(item.passed))], + ['detail', item.detail || 'n/a'], + ] + ) + ); + }); + if ((payload?.checklist || []).length) { + liveReadinessPanel.appendChild(grid); + } +} + function renderSessionFocus(payload) { clearNode(sessionFocusPanel); const items = payload?.items || []; @@ -909,6 +939,7 @@ function showErrorPanel(message) { runtimeStatusPanel, decisionPacketPanel, trustScorePanel, + liveReadinessPanel, sessionFocusPanel, preMarketPlanPanel, tradeCandidatesPanel, @@ -936,6 +967,7 @@ function renderDashboardData({ decisionPacket, decisionAlerts, trustScore, + liveReadiness, sessionView, tradeCandidates, executionTickets, @@ -952,6 +984,7 @@ function renderDashboardData({ renderRuntimeStatus(runtimeStatus); renderDecisionPacket(decisionPacket, decisionAlerts); renderTrustScore(trustScore); + renderLiveReadiness(liveReadiness); renderSessionFocus(sessionView); renderPreMarketPlan(tradingPlan); renderCandidates(tradeCandidates.items || []); @@ -971,7 +1004,7 @@ async function refreshDashboard() { const limit = limitInput.value || '20'; try { - const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ + const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, liveReadiness, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ fetchJson('/health'), fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`), fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null), @@ -980,6 +1013,7 @@ async function refreshDashboard() { fetchJson(`/trading/decision-packet?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ status: 'unavailable', action_recommendation: 'monitor' })), fetchJson(`/trading/alerts?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/trust-score?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ models: [], whitelist: [], observe_list: [], blacklist: [], next_actions: [] })), + fetchJson(`/trading/live-readiness?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ ready_for_real_live: false, blocking_reasons: ['live_readiness_unavailable'], checklist: [] })), fetchJson(`/trading/session-view?${queryString({ instrument_id: instrumentId, limit: 12 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), @@ -1000,6 +1034,7 @@ async function refreshDashboard() { decisionPacket, decisionAlerts, trustScore, + liveReadiness, sessionView, tradeCandidates, executionTickets, diff --git a/src/web/index.html b/src/web/index.html index b387b94..b749a1e 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -92,6 +92,11 @@
+
+

Live Readiness

+
+
+

Session Focus

diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 4e8fb41..8707a30 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -123,6 +123,17 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(packet_status, 200) self.assertEqual(packet_payload["instrument_id"], 1) + def test_routes_trading_live_readiness(self) -> None: + status, payload = route_request( + "/trading/live-readiness", + {"instrument_id": ["1"], "timeframe": ["5m"]}, + dependencies={"trading_live_readiness": lambda **kwargs: {"ready_for_real_live": False, "timeframe": kwargs["timeframe"]}}, + ) + + self.assertEqual(status, 200) + self.assertFalse(payload["ready_for_real_live"]) + self.assertEqual(payload["timeframe"], "5m") + def test_routes_trading_broker_state(self) -> None: status, payload = route_request( "/trading/broker-state", diff --git a/tests/unit/test_live_adapter_service.py b/tests/unit/test_live_adapter_service.py index 8d43fce..5f6c2cd 100644 --- a/tests/unit/test_live_adapter_service.py +++ b/tests/unit/test_live_adapter_service.py @@ -69,6 +69,7 @@ class LiveBrokerServiceTests(unittest.TestCase): self.assertEqual(result.status, "placeholder_not_implemented") self.assertEqual(repo.live_audits[-1]["adapter_name"], "okx") self.assertEqual(repo.live_audits[-1]["execution_ticket_id"], 10) + self.assertIn("live_order_envelope", repo.live_audits[-1]["payload"]) def test_submit_confirmed_ticket_rejects_paper_ticket(self) -> None: repo = _LiveRepo() @@ -85,6 +86,22 @@ class LiveBrokerServiceTests(unittest.TestCase): self.assertEqual(result.status, "rejected") self.assertIn("execution_ticket_mode_not_live", result.reason_codes) + def test_submit_confirmed_ticket_rejects_missing_operator_protocol(self) -> None: + repo = _LiveRepo() + repo.tickets[10]["ticket_snapshot"] = dict(repo.tickets[10]["ticket_snapshot"]) + repo.tickets[10]["ticket_snapshot"].pop("confirmation") + service = LiveBrokerService(repository=repo) + + with patch.object(settings, "live_trading_enabled", True): + result = service.submit_confirmed_ticket( + 10, + requested_by="alice", + explicit_live_confirmation=True, + ) + + self.assertEqual(result.status, "rejected") + self.assertIn("operator_confirmation_protocol_missing", result.reason_codes) + def test_unsupported_adapter_is_audited(self) -> None: repo = _LiveRepo() service = LiveBrokerService(repository=repo) @@ -151,10 +168,31 @@ class LiveValidationTests(unittest.TestCase): "execution_ticket_mode_not_live", "execution_ticket_not_confirmed", "execution_ticket_missing_confirmation_metadata", + "operator_confirmation_protocol_missing", "execution_ticket_missing_execution_record", ], ) + def test_validate_live_submit_request_accepts_protocolized_ticket(self) -> None: + reasons = validate_live_submit_request( + ticket={ + "status": "confirmed", + "execution_mode": "semi_auto", + "confirmed_by": "bob", + "confirmed_at": "2026-05-06T00:00:00+00:00", + "execution_id": 20, + "ticket_snapshot": { + "confirmation": { + "protocol": {"protocol_version": "live-confirmation-v1"} + } + }, + }, + live_trading_enabled=True, + explicit_live_confirmation=True, + ) + + self.assertEqual(reasons, []) + class _LiveRepo: def __init__(self) -> None: @@ -173,6 +211,11 @@ class _LiveRepo: "side": "buy", "signal_id": 1, }, + "confirmation": { + "confirmed_by": "bob", + "confirmed_at": "2026-05-06T00:00:00+00:00", + "protocol": {"protocol_version": "live-confirmation-v1"}, + }, "decision": { "entry_price": 100.5, "size": 1.0, diff --git a/tests/unit/test_live_readiness_service.py b/tests/unit/test_live_readiness_service.py new file mode 100644 index 0000000..25247c3 --- /dev/null +++ b/tests/unit/test_live_readiness_service.py @@ -0,0 +1,83 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.broker.live_readiness_service import LiveReadinessService + + +class LiveReadinessServiceTests(unittest.TestCase): + def test_build_summary_reports_blocking_reasons(self) -> None: + service = LiveReadinessService( + broker_read_model_service=_BrokerReadService(), + market_state_service=_MarketStateService(), + execution_repository=_ExecutionRepo(), + decision_packet_service=_DecisionPacketService(), + trust_service=_TrustService(), + ) + + with patch("src.services.broker.live_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()): + payload = service.build_summary(instrument_id=1, timeframe="1m") + + self.assertFalse(payload["ready_for_real_live"]) + self.assertIn("adapter_real_live_not_ready", payload["blocking_reasons"]) + self.assertEqual(payload["operator_confirmation_protocol"]["protocol_version"], "live-confirmation-v1") + self.assertEqual(payload["live_order_schema"]["schema_version"], "live-order-v1") + + +class _BrokerState: + status = "broker_state_ok" + + def to_dict(self): + return {"status": "broker_state_ok"} + + +class _BrokerReadService: + def fetch_snapshot(self, **kwargs): + return _BrokerState() + + +class _MarketState: + freshness = type("Freshness", (), {"code": "market_data_fresh"})() + stream_state = type("Stream", (), {"code": "market_stream_connected"})() + data_gap_state = type("Gap", (), {"code": "market_data_gap_clear"})() + + def to_dict(self): + return { + "freshness": {"code": "market_data_fresh"}, + "stream_state": {"code": "market_stream_connected"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + + +class _MarketStateService: + def build_market_state(self, **kwargs): + return _MarketState() + + +class _ExecutionRepo: + def fetch_recent_execution_ticket_rows(self, **kwargs): + return [] + + def fetch_recent_live_operation_audits(self, **kwargs): + return [] + + +class _DecisionPacketService: + def build_preview(self, **kwargs): + return {"action_recommendation": "confirm_ticket"} + + +class _TrustService: + def build_summary(self, **kwargs): + return {"models": [{"model_code": "OSOK"}], "whitelist": ["OSOK"], "blacklist": []} + + +class _RuntimeService: + def status(self): + return {"scheduler_state": "running", "current_blockers": []} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index 7300a17..2256dce 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -25,6 +25,7 @@ from scripts import ( run_decision_packet, run_review_packet, run_session_generator, + run_live_readiness, run_trading_runtime, run_sweeps, run_swings, @@ -170,6 +171,19 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(exit_code, 0) self.assertEqual(payload["instrument_id"], 2) + def test_run_live_readiness_outputs_json(self) -> None: + service = type("Service", (), {"build_live_readiness": lambda self, instrument_id, timeframe: {"instrument_id": instrument_id, "timeframe": timeframe, "ready_for_real_live": False, "blocking_reasons": ["adapter_real_live_not_ready"]}})() + output = io.StringIO() + with patch("scripts.run_live_readiness.LiveBrokerService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_live_readiness.main( + env={"LIVE_READINESS_OUTPUT_JSON": "1", "LIVE_READINESS_INSTRUMENT_ID": "2", "LIVE_READINESS_TIMEFRAME": "5m"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertEqual(payload["instrument_id"], 2) + self.assertEqual(payload["timeframe"], "5m") + if __name__ == "__main__": unittest.main()