Add live readiness protocol

This commit is contained in:
Codex
2026-05-08 16:18:12 +08:00
parent e918c11107
commit a1aeaacfc3
16 changed files with 569 additions and 50 deletions
+37
View File
@@ -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())
+2
View File
@@ -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",
+6
View File
@@ -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"](
+11 -45
View File
@@ -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 "")
+14
View File
@@ -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",
]
+141
View File
@@ -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,
}
+16 -1
View File
@@ -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)
@@ -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"
+7
View File
@@ -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(),
},
)
+12 -3
View File
@@ -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
+36 -1
View File
@@ -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,
+5
View File
@@ -92,6 +92,11 @@
<div id="trustScorePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Live Readiness</h2>
<div id="liveReadinessPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Session Focus</h2>
<div id="sessionFocusPanel" class="detail-panel"></div>
+11
View File
@@ -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",
+43
View File
@@ -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,
+83
View File
@@ -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()
+14
View File
@@ -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()