Files
ai-exchange/src/api/trading_runtime.py
T
2026-05-08 16:18:12 +08:00

453 lines
18 KiB
Python

from datetime import datetime, timezone
from typing import Optional
from src.api.execution_tickets import get_recent_execution_tickets
from src.api.trade_plan_candidates import get_recent_trade_plan_candidates, normalize_trade_plan_candidate_row
from src.api.trading_plans import get_recent_trading_plans, normalize_trading_plan_row
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
from src.services.market_state import MarketStateService, get_market_truth_supervisor
from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService
from src.services.reconciliation import BrokerExecutionReconciliationService
from src.services.runtime import get_trading_runtime_service
from src.services.trade_plan_candidates import SetupWatcherService
from src.services.trading_plans import ACTIVE_PLAN_STATUS, TradingPlanService
from src.services.trust import ModelTrustService
def generate_trading_plan(
*,
instrument_id: int,
bias_tf: str = "1m",
setup_tf: str = "1m",
trigger_tf: str = "1m",
activate: bool = False,
) -> dict:
service = TradingPlanService()
candidate = service.build_plan(
instrument_id=instrument_id,
bias_tf=bias_tf,
setup_tf=setup_tf,
trigger_tf=trigger_tf,
)
plan_id = service.persist_plan(candidate)
row = TradingPlanRepository().fetch_trading_plan_by_id(plan_id) or candidate.to_dict()
payload = normalize_trading_plan_row(dict(row))
if activate and payload.get("status") == ACTIVE_PLAN_STATUS:
updated = activate_trading_plan(plan_id)
if updated is not None:
payload = updated
return payload
def get_latest_trading_plan(instrument_id: int) -> Optional[dict]:
row = TradingPlanRepository().fetch_latest_trading_plan(instrument_id)
return normalize_trading_plan_row(row) if row else None
def get_trading_plan_by_id(plan_id: int) -> Optional[dict]:
row = TradingPlanRepository().fetch_trading_plan_by_id(plan_id)
return normalize_trading_plan_row(row) if row else None
def activate_trading_plan(plan_id: int) -> Optional[dict]:
row = TradingPlanRepository().update_trading_plan_status(
plan_id,
status="active",
activated_ts=datetime.now(timezone.utc),
)
return normalize_trading_plan_row(row) if row else None
def cancel_trading_plan(plan_id: int) -> Optional[dict]:
row = TradingPlanRepository().update_trading_plan_status(
plan_id,
status="cancelled",
activated_ts=None,
)
return normalize_trading_plan_row(row) if row else None
def get_trading_market_state(instrument_id: int, timeframe: str = "1m") -> dict:
service = build_market_state_service()
market_state = service.build_market_state(instrument_id=instrument_id, timeframe=timeframe)
snapshot_id = service.persist_market_state_snapshot(market_state=market_state)
quality_gate = service.repository.fetch_latest_backtest_quality_gate(instrument_id=instrument_id)
incidents = service.repository.fetch_recent_market_truth_incidents(
instrument_id=instrument_id,
timeframe=timeframe,
limit=10,
)
broker_state = get_trading_broker_state(instrument_id=instrument_id)
return {
"snapshot_id": snapshot_id,
"market_state": market_state.to_dict(),
"backtest_quality_gate": quality_gate,
"recent_incidents": incidents,
"broker_state": broker_state,
}
def run_trading_watch(
*,
instrument_id: int,
setup_tf: str = "1m",
trigger_tf: str = "1m",
) -> Optional[dict]:
market_state = build_market_state_service().build_market_state(
instrument_id=instrument_id,
timeframe=trigger_tf,
)
if market_truth_degraded(market_state):
return {
"candidate_generated": False,
"status": "market_truth_blocked",
"market_state": market_state.to_dict(),
"reason_codes": market_truth_reject_codes(market_state),
}
service = SetupWatcherService()
candidate = service.build_candidate(
instrument_id=instrument_id,
setup_tf=setup_tf,
trigger_tf=trigger_tf,
)
candidate_id = service.persist_candidate(candidate)
if candidate is None:
return None
if candidate_id:
row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id)
if row:
return normalize_trade_plan_candidate_row(row)
return normalize_trade_plan_candidate_row(candidate.to_dict())
def get_trading_candidate_by_id(candidate_id: int) -> Optional[dict]:
row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id)
return normalize_trade_plan_candidate_row(row) if row else None
def run_candidate_confirm_check(candidate_id: int) -> Optional[dict]:
candidate = get_trading_candidate_by_id(candidate_id)
if not candidate:
return None
return {
"candidate_id": candidate["id"],
"status": candidate["status"],
"executable": candidate.get("executable", False),
"confirmation_state": candidate.get("confirmation_state") or {},
"missing_conditions": candidate.get("missing_conditions") or [],
"rejected_conditions": candidate.get("rejected_conditions") or [],
"reason_codes": candidate.get("reason_codes") or [],
}
def build_ticket_from_candidate(
*,
candidate_id: int,
execution_mode: str = "semi_auto",
account_equity: float = 10000.0,
risk_pct: float = 0.01,
) -> Optional[dict]:
candidate_row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id)
if not candidate_row:
return None
candidate = normalize_trade_plan_candidate_row(candidate_row)
if not candidate.get("executable"):
raise ValueError("candidate_not_executable")
signal_id = ensure_trade_signal_for_candidate(candidate_row)
ticket = build_execution_service().create_execution_ticket(
signal_id=signal_id,
execution_mode=execution_mode,
account_equity=account_equity,
risk_pct=risk_pct,
)
return ticket.to_dict() if ticket else None
def get_trading_ticket_by_id(ticket_id: int) -> Optional[dict]:
row = build_execution_service().repository.fetch_execution_ticket_by_id(ticket_id)
return dict(row) if row else None
def get_trading_executions(instrument_id: int, limit: int = 20) -> list[dict]:
return build_execution_service().build_execution_dashboard_items(instrument_id=instrument_id, limit=limit)
def rerun_ticket_risk_check(
*,
ticket_id: int,
account_equity: float = 10000.0,
risk_pct: float = 0.01,
) -> 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"]),
account_equity=account_equity,
risk_pct=risk_pct,
risk_context=risk_context,
)
return {
"ticket_id": ticket_id,
"decision": decision.meta if decision else None,
"allowed": decision.allowed if decision else False,
"reasons": decision.reasons if decision else ["signal_not_found"],
}
def confirm_ticket_only(ticket_id: int, confirmed_by: str) -> Optional[dict]:
service = build_execution_service()
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]:
ticket = get_trading_ticket_by_id(ticket_id)
if not ticket:
return None
if ticket.get("execution_mode") != execution_mode_expected:
raise ValueError("ticket_execution_mode_mismatch")
if ticket.get("status") != "confirmed":
raise ValueError("execution_ticket_not_confirmed")
service = build_execution_service()
full = service.submit_execution_ticket(ticket_id)
return full.to_dict() if full else None
def cancel_execution(execution_id: int) -> Optional[dict]:
service = build_execution_service()
return service.persist_execution_observation(
trade_execution=service.repository.fetch_execution_by_id(execution_id),
observation={"source": "manual", "status": "cancelled", "close_reason": "cancelled"},
)
def close_execution(execution_id: int) -> Optional[dict]:
return build_execution_service().close_execution_by_id(execution_id)
def sync_execution(execution_id: int) -> Optional[dict]:
return build_execution_service().settle_execution_by_id_from_latest_backtest(execution_id)
def generate_trading_journal(execution_id: Optional[int] = None) -> dict:
service = TradingJournalService()
if execution_id is not None:
journal_id = service.upsert_entry_for_execution(execution_id)
return {"created": 1 if journal_id else 0, "execution_id": execution_id, "journal_id": journal_id}
return service.backfill_missing_entries()
def get_weekly_review(instrument_id: Optional[int] = None) -> dict:
return WeeklyReviewService().build_summary(instrument_id=instrument_id)
def ensure_trade_signal_for_candidate(candidate_row: dict) -> int:
repository = SignalRepository()
existing = repository.fetch_latest_trade_signal_for_candidate(int(candidate_row["id"]))
if existing:
return int(existing["id"])
entry = dict(candidate_row.get("entry") or {})
if not entry:
raise ValueError("candidate_missing_entry")
trading_plan = TradingPlanRepository().fetch_trading_plan_by_id(int(candidate_row["trading_plan_id"])) or {}
allowed_sessions = list(trading_plan.get("allowed_sessions") or [])
entry_low = float(candidate_row.get("entry_low") or entry.get("entry_low") or 0.0)
entry_high = float(candidate_row.get("entry_high") or entry.get("entry_high") or 0.0)
stop_loss = float(candidate_row.get("stop_loss") or entry.get("stop_loss") or 0.0)
tp1 = float(candidate_row.get("tp1") or entry.get("tp1") or 0.0)
tp2 = float(candidate_row.get("tp2") or entry.get("tp2") or 0.0)
entry_price = (entry_low + entry_high) / 2 if entry_low and entry_high else (entry_low or entry_high)
risk_distance = abs(entry_price - stop_loss)
rr_tp1 = float(candidate_row.get("rr_tp1") or (abs(tp1 - entry_price) / risk_distance if risk_distance else 0.0))
rr_tp2 = float(candidate_row.get("rr_tp2") or (abs(tp2 - entry_price) / risk_distance if risk_distance else 0.0))
invalidation_codes = [
str(item.get("code"))
for item in list(candidate_row.get("invalidations") or [])
if item.get("code")
]
target_plan = dict(entry.get("target_plan") or {})
quality_score = float(candidate_row.get("quality_score") or 0.0)
ai_explanation = str(candidate_row.get("ai_explanation") or "")
repository.insert_trade_signal(
instrument_id=int(candidate_row["instrument_id"]),
model_code=str(candidate_row.get("model_code") or "UNKNOWN"),
side=str(candidate_row.get("side") or "buy"),
status="awaiting_confirmation",
trading_plan_id=int(candidate_row["trading_plan_id"]),
candidate_id=int(candidate_row["id"]),
bias_snapshot_id=None,
entry_type=str(entry.get("entry_type") or "limit"),
entry_low=entry_low,
entry_high=entry_high,
stop_loss=stop_loss,
tp1=tp1,
tp2=tp2,
rr_tp1=rr_tp1,
rr_tp2=rr_tp2,
session_code=allowed_sessions[0] if allowed_sessions else None,
created_ts=candidate_row.get("created_ts"),
expires_ts=candidate_row.get("expires_ts"),
invalidations=invalidation_codes,
evidence={
**dict(candidate_row.get("evidence") or {}),
"candidate_id": candidate_row.get("id"),
"trading_plan_id": candidate_row.get("trading_plan_id"),
},
primary_draw=dict((trading_plan or {}).get("primary_draw") or {}),
target_plan=target_plan,
risk_governor_decision={},
confirmation_quality=dict(candidate_row.get("confirmation_state") or {}),
quality_score=quality_score,
ai_explanation=ai_explanation,
meta={
**dict(candidate_row.get("meta") or {}),
"target_plan_required": True,
"execution_permission": True,
"structure_state": "aligned",
"signal_timeframe_set": {
"setup_tf": candidate_row.get("setup_timeframe"),
"trigger_tf": candidate_row.get("trigger_timeframe"),
},
"reason_codes": list((candidate_row.get("confirmation_state") or {}).get("reason_codes") or []),
"rejected_conditions": list((candidate_row.get("confirmation_state") or {}).get("rejected_conditions") or []),
},
)
created = repository.fetch_latest_trade_signal_for_candidate(int(candidate_row["id"]))
if not created:
raise ValueError("signal_not_created_from_candidate")
return int(created["id"])
def build_market_state_service() -> MarketStateService:
return MarketStateService(market_truth_supervisor=get_market_truth_supervisor())
def build_execution_service() -> ExecutionService:
return ExecutionService(market_state_service=build_market_state_service())
def get_trading_broker_state(instrument_id: int) -> dict:
execution_service = build_execution_service()
items = execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=100)
known_order_ids = []
open_execution_count = 0
for item in items:
if item.get("execution_status") == "open":
open_execution_count += 1
known_order_ids.extend(list(item.get("broker_order_ids") or []))
snapshot = BrokerReadModelService().fetch_snapshot(
instrument_id=instrument_id,
known_execution_refs={
"broker_order_ids": known_order_ids,
"open_execution_count": open_execution_count,
},
)
return snapshot.to_dict()
def get_trading_runtime_status() -> dict:
return get_trading_runtime_service().status()
def run_trading_runtime_once() -> dict:
return get_trading_runtime_service().run_once()
def get_trading_decision_packet(instrument_id: int) -> dict:
return DecisionPacketService().build_preview(instrument_id=instrument_id)
def get_trading_decision_alerts(instrument_id: int, limit: int = 20) -> list[dict]:
return DecisionPacketService().fetch_recent_alerts(instrument_id=instrument_id, limit=limit)
def run_trading_reconciliation(instrument_id: int) -> dict:
return BrokerExecutionReconciliationService().reconcile_instrument(instrument_id=instrument_id)
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)
def get_trading_operator_notes(instrument_id: int, limit: int = 20) -> list[dict]:
return OperatorNoteService().get_recent_notes(instrument_id=instrument_id, limit=limit)
def create_trading_operator_note(
*,
instrument_id: int,
note_scope: str,
author: str,
body: str,
session_code: Optional[str] = None,
execution_id: Optional[int] = None,
signal_id: Optional[int] = None,
candidate_id: Optional[int] = None,
decision_packet_id: Optional[int] = None,
tags: Optional[list[str]] = None,
) -> dict:
return OperatorNoteService().create_note(
instrument_id=instrument_id,
note_scope=note_scope,
author=author,
body=body,
session_code=session_code,
execution_id=execution_id,
signal_id=signal_id,
candidate_id=candidate_id,
decision_packet_id=decision_packet_id,
tags=tags,
)
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 "")
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 in {"market_data_missing", "market_data_stale"} or stream_code in {
"market_stream_disconnected",
"market_stream_stale",
} or gap_code == "market_data_gap_detected"
def market_truth_reject_codes(market_state) -> list[str]:
payload = market_state.to_dict() if hasattr(market_state, "to_dict") else dict(market_state or {})
codes = [
str(((payload.get("freshness") or {}).get("code")) or ""),
str(((payload.get("stream_state") or {}).get("code")) or ""),
str(((payload.get("data_gap_state") or {}).get("code")) or ""),
]
return [code for code in codes if code and code not in {"market_data_fresh", "market_stream_connected", "market_data_gap_clear"}]