Add live readiness protocol
This commit is contained in:
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 "")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user