Add decision packets and alert records

This commit is contained in:
Codex
2026-05-08 14:39:44 +08:00
parent 62fb1abd73
commit bbed0581ad
19 changed files with 888 additions and 9 deletions
+1
View File
@@ -43,6 +43,7 @@ TRADING_RUNTIME_TRIGGER_TIMEFRAME=1m
TRADING_RUNTIME_MARKET_STATE_INTERVAL_SECONDS=15 TRADING_RUNTIME_MARKET_STATE_INTERVAL_SECONDS=15
TRADING_RUNTIME_BROKER_SYNC_INTERVAL_SECONDS=30 TRADING_RUNTIME_BROKER_SYNC_INTERVAL_SECONDS=30
TRADING_RUNTIME_WATCH_INTERVAL_SECONDS=20 TRADING_RUNTIME_WATCH_INTERVAL_SECONDS=20
TRADING_RUNTIME_DECISION_PACKET_INTERVAL_SECONDS=20
TRADING_RUNTIME_PLAN_INTERVAL_SECONDS=900 TRADING_RUNTIME_PLAN_INTERVAL_SECONDS=900
DEFAULT_TIMEZONE=UTC DEFAULT_TIMEZONE=UTC
SESSION_TIMEZONE=America/New_York SESSION_TIMEZONE=America/New_York
+21 -6
View File
@@ -2,7 +2,7 @@
## Current Goal ## Current Goal
Continue the trading-module mainline in autonomous mode until `Real-time Assisted Trading V1` is complete. The next active stage is `P0-4` Decision Packet And Proactive Alerting. Continue the trading-module mainline in autonomous mode until `Real-time Assisted Trading V1` is complete. The next active stage is `P0-5` Real Trade Mapping And Post-Trade Loop.
## Current Branch ## Current Branch
@@ -26,10 +26,26 @@ Continue the trading-module mainline in autonomous mode until `Real-time Assiste
- `/trading/runtime/run-once` - `/trading/runtime/run-once`
- `scripts/run_trading_runtime.py` - `scripts/run_trading_runtime.py`
- workbench Runtime Supervisor panel - workbench Runtime Supervisor panel
- Completed `P0-4` Decision Packet And Proactive Alerting implementation:
- `DecisionPacketService`
- `decision_packets` and `decision_alerts` persistence
- `/trading/decision-packet`
- `/trading/alerts`
- runtime decision-packet generation task
- workbench Decision Packet panel with recent alerts
- `scripts/run_decision_packet.py`
- Local quality gates passed: - Local quality gates passed:
- `npm run lint` - `npm run lint`
- `npm run build` - `npm run build`
- `npm run visual:snapshots` - `npm run visual:snapshots`
- Remote deploy and smoke passed:
- `/health`
- `/trading/broker-state`
- `/trading/runtime-status`
- `/trading/runtime/run-once`
- `/trading/market-state`
- `/trading/decision-packet`
- `/trading/alerts`
## Current Working State ## Current Working State
@@ -46,14 +62,13 @@ Continue the trading-module mainline in autonomous mode until `Real-time Assiste
Proceed directly to: Proceed directly to:
### `P0-4` Decision Packet And Proactive Alerting ### `P0-5` Real Trade Mapping And Post-Trade Loop
Goals: Goals:
- build `DecisionPacketService` - map real orders and fills back to framework executions
- combine plan, candidate, target, invalidation, and Risk Governor into one operator decision packet - let real position changes trigger post-trade journal updates
- add status-change alert plumbing - strengthen weekly review with real-trade attribution and next-step actions
- show the decision packet in the workbench
## Validation Commands ## Validation Commands
+2 -2
View File
@@ -8,11 +8,10 @@
## In Progress ## In Progress
- [ ] `P0-4` Decision Packet And Proactive Alerting - [ ] `P0-5` Real Trade Mapping And Post-Trade Loop
## Next Up ## Next Up
- [ ] `P0-5` Real Trade Mapping And Post-Trade Loop
- [ ] `P1-1` Trust Scoring And Model Whitelists - [ ] `P1-1` Trust Scoring And Model Whitelists
- [ ] `P1-2` Operator Experience And Workflow Tools - [ ] `P1-2` Operator Experience And Workflow Tools
- [ ] `P1-3` Live Adapter Readiness Prep - [ ] `P1-3` Live Adapter Readiness Prep
@@ -30,5 +29,6 @@
- [x] `P0-1` Real-time market truth layer - [x] `P0-1` Real-time market truth layer
- [x] `P0-2` Real Account And Order Read Sync - [x] `P0-2` Real Account And Order Read Sync
- [x] `P0-3` Runtime Scheduling And Supervision - [x] `P0-3` Runtime Scheduling And Supervision
- [x] `P0-4` Decision Packet And Proactive Alerting
- [x] Autonomous execution protocol codified in repo docs - [x] Autonomous execution protocol codified in repo docs
- [x] Market-truth proxy switched to direct v2rayN LAN proxy `192.168.3.73:10808` - [x] Market-truth proxy switched to direct v2rayN LAN proxy `192.168.3.73:10808`
@@ -0,0 +1,35 @@
create table if not exists decision_packets (
id bigserial primary key,
instrument_id bigint not null references instruments(id),
trading_plan_id bigint references trading_plans(id),
candidate_id bigint references trade_plan_candidates(id),
signal_id bigint references trade_signals(id),
execution_ticket_id bigint references execution_tickets(id),
packet_ts timestamptz not null,
status text not null,
action_recommendation text not null,
quality_score numeric,
ai_explanation text,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists idx_decision_packets_instrument_ts_desc
on decision_packets (instrument_id, packet_ts desc, id desc);
create table if not exists decision_alerts (
id bigserial primary key,
instrument_id bigint not null references instruments(id),
decision_packet_id bigint references decision_packets(id),
alert_key text not null unique,
alert_type text not null,
status text not null,
channel text not null default 'workbench',
message text not null,
payload jsonb not null default '{}'::jsonb,
created_ts timestamptz not null,
delivered_ts timestamptz
);
create index if not exists idx_decision_alerts_instrument_ts_desc
on decision_alerts (instrument_id, created_ts desc, id desc);
+36
View File
@@ -0,0 +1,36 @@
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.decision import DecisionPacketService
def main(env=None) -> int:
env = os.environ if env is None else env
instrument_id = int(env.get("DECISION_PACKET_INSTRUMENT_ID", "1"))
output_json = _get_bool(env, "DECISION_PACKET_OUTPUT_JSON", False)
payload = DecisionPacketService().build_persist_and_alert(instrument_id=instrument_id)
if output_json:
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
else:
print(f"Decision packet: {payload.get('status')} / {payload.get('action_recommendation')}")
print(f"Why blocked: {', '.join(payload.get('why_blocked') or []) or 'none'}")
return 0
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())
+4
View File
@@ -20,6 +20,8 @@ from src.api.trading_runtime import (
execute_ticket, execute_ticket,
generate_trading_journal, generate_trading_journal,
generate_trading_plan, generate_trading_plan,
get_trading_decision_alerts,
get_trading_decision_packet,
get_latest_trading_plan, get_latest_trading_plan,
get_trading_candidate_by_id, get_trading_candidate_by_id,
get_trading_market_state, get_trading_market_state,
@@ -58,6 +60,8 @@ __all__ = [
"get_weekly_review", "get_weekly_review",
"generate_trading_journal", "generate_trading_journal",
"generate_trading_plan", "generate_trading_plan",
"get_trading_decision_packet",
"get_trading_decision_alerts",
"activate_trading_plan", "activate_trading_plan",
"cancel_trading_plan", "cancel_trading_plan",
"run_trading_watch", "run_trading_watch",
+11
View File
@@ -21,6 +21,8 @@ from src.api.trading_runtime import (
execute_ticket, execute_ticket,
generate_trading_journal, generate_trading_journal,
generate_trading_plan, generate_trading_plan,
get_trading_decision_alerts,
get_trading_decision_packet,
get_latest_trading_plan, get_latest_trading_plan,
get_trading_candidate_by_id, get_trading_candidate_by_id,
get_trading_market_state, get_trading_market_state,
@@ -69,6 +71,8 @@ def build_api_dependencies() -> dict:
"trading_plan_activate": activate_trading_plan, "trading_plan_activate": activate_trading_plan,
"trading_plan_cancel": cancel_trading_plan, "trading_plan_cancel": cancel_trading_plan,
"trading_market_state": get_trading_market_state, "trading_market_state": get_trading_market_state,
"trading_decision_packet": get_trading_decision_packet,
"trading_decision_alerts": get_trading_decision_alerts,
"trading_runtime_status": get_trading_runtime_status, "trading_runtime_status": get_trading_runtime_status,
"trading_runtime_run_once": run_trading_runtime_once, "trading_runtime_run_once": run_trading_runtime_once,
"trading_watch_run": run_trading_watch, "trading_watch_run": run_trading_watch,
@@ -175,6 +179,13 @@ def route_request(
return 200, deps["trading_runtime_status"]() return 200, deps["trading_runtime_status"]()
if method == "POST" and path == "/trading/runtime/run-once": if method == "POST" and path == "/trading/runtime/run-once":
return 200, deps["trading_runtime_run_once"]() return 200, deps["trading_runtime_run_once"]()
if method == "GET" and path == "/trading/decision-packet":
instrument_id = _require_int(query, "instrument_id")
return 200, deps["trading_decision_packet"](instrument_id=instrument_id)
if method == "GET" and path == "/trading/alerts":
instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20)
return 200, {"items": deps["trading_decision_alerts"](instrument_id=instrument_id, limit=limit)}
if method == "POST" and path == "/trading/watch/run": if method == "POST" and path == "/trading/watch/run":
instrument_id = _require_json_int(body or {}, "instrument_id") instrument_id = _require_json_int(body or {}, "instrument_id")
candidate = deps["trading_watch_run"]( candidate = deps["trading_watch_run"](
+9
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.trade_plan_candidate_repository import TradePlanCandidateRepository
from src.repositories.trading_plan_repository import TradingPlanRepository from src.repositories.trading_plan_repository import TradingPlanRepository
from src.services.broker import BrokerReadModelService, BROKER_STATE_OK from src.services.broker import BrokerReadModelService, BROKER_STATE_OK
from src.services.decision import DecisionPacketService
from src.services.execution import ExecutionService from src.services.execution import ExecutionService
from src.services.journal import TradingJournalService, WeeklyReviewService from src.services.journal import TradingJournalService, WeeklyReviewService
from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.market_state import MarketStateService, get_market_truth_supervisor
@@ -406,6 +407,14 @@ def run_trading_runtime_once() -> dict:
return get_trading_runtime_service().run_once() 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 market_truth_degraded(market_state) -> bool: def market_truth_degraded(market_state) -> bool:
payload = market_state.to_dict() if hasattr(market_state, "to_dict") else dict(market_state or {}) 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 "") freshness_code = str(((payload.get("freshness") or {}).get("code")) or "")
+1
View File
@@ -47,6 +47,7 @@ class Settings(BaseSettings):
trading_runtime_market_state_interval_seconds: int = 15 trading_runtime_market_state_interval_seconds: int = 15
trading_runtime_broker_sync_interval_seconds: int = 30 trading_runtime_broker_sync_interval_seconds: int = 30
trading_runtime_watch_interval_seconds: int = 20 trading_runtime_watch_interval_seconds: int = 20
trading_runtime_decision_packet_interval_seconds: int = 20
trading_runtime_plan_interval_seconds: int = 900 trading_runtime_plan_interval_seconds: int = 900
default_timezone: str = "UTC" default_timezone: str = "UTC"
session_timezone: str = "America/New_York" session_timezone: str = "America/New_York"
@@ -0,0 +1,165 @@
import json
from typing import Optional
from sqlalchemy import text
from src.db.session import SessionLocal
class DecisionPacketRepository:
def insert_decision_packet(
self,
*,
instrument_id: int,
trading_plan_id: Optional[int],
candidate_id: Optional[int],
signal_id: Optional[int],
execution_ticket_id: Optional[int],
packet_ts,
status: str,
action_recommendation: str,
quality_score: Optional[float],
ai_explanation: str,
payload: dict,
) -> int:
sql = text(
"""
insert into decision_packets (
instrument_id,
trading_plan_id,
candidate_id,
signal_id,
execution_ticket_id,
packet_ts,
status,
action_recommendation,
quality_score,
ai_explanation,
payload
) values (
:instrument_id,
:trading_plan_id,
:candidate_id,
:signal_id,
:execution_ticket_id,
:packet_ts,
:status,
:action_recommendation,
:quality_score,
:ai_explanation,
cast(:payload as jsonb)
)
returning id
"""
)
with SessionLocal.begin() as session:
result = session.execute(
sql,
{
"instrument_id": instrument_id,
"trading_plan_id": trading_plan_id,
"candidate_id": candidate_id,
"signal_id": signal_id,
"execution_ticket_id": execution_ticket_id,
"packet_ts": packet_ts,
"status": status,
"action_recommendation": action_recommendation,
"quality_score": quality_score,
"ai_explanation": ai_explanation,
"payload": json.dumps(payload, default=str),
},
)
return int(result.scalar_one())
def insert_decision_alert(
self,
*,
instrument_id: int,
decision_packet_id: Optional[int],
alert_key: str,
alert_type: str,
status: str,
channel: str,
message: str,
payload: dict,
created_ts,
delivered_ts=None,
) -> Optional[int]:
sql = text(
"""
insert into decision_alerts (
instrument_id,
decision_packet_id,
alert_key,
alert_type,
status,
channel,
message,
payload,
created_ts,
delivered_ts
) values (
:instrument_id,
:decision_packet_id,
:alert_key,
:alert_type,
:status,
:channel,
:message,
cast(:payload as jsonb),
:created_ts,
:delivered_ts
)
on conflict (alert_key) do nothing
returning id
"""
)
with SessionLocal.begin() as session:
result = session.execute(
sql,
{
"instrument_id": instrument_id,
"decision_packet_id": decision_packet_id,
"alert_key": alert_key,
"alert_type": alert_type,
"status": status,
"channel": channel,
"message": message,
"payload": json.dumps(payload, default=str),
"created_ts": created_ts,
"delivered_ts": delivered_ts,
},
)
value = result.scalar_one_or_none()
return int(value) if value is not None else None
def fetch_latest_decision_packet(self, instrument_id: int) -> Optional[dict]:
sql = text(
"""
select id, instrument_id, trading_plan_id, candidate_id, signal_id,
execution_ticket_id, packet_ts, status, action_recommendation,
quality_score, ai_explanation, payload
from decision_packets
where instrument_id = :instrument_id
order by packet_ts desc, id desc
limit 1
"""
)
with SessionLocal() as session:
row = session.execute(sql, {"instrument_id": instrument_id}).mappings().first()
return dict(row) if row else None
def fetch_recent_decision_alerts(self, instrument_id: int, limit: int = 20) -> list[dict]:
sql = text(
"""
select id, instrument_id, decision_packet_id, alert_key, alert_type,
status, channel, message, payload, created_ts, delivered_ts
from decision_alerts
where instrument_id = :instrument_id
order by created_ts desc, id desc
limit :limit
"""
)
with SessionLocal() as session:
rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all()
return [dict(row) for row in rows]
+3
View File
@@ -0,0 +1,3 @@
from src.services.decision.decision_packet_service import DecisionPacketService
__all__ = ["DecisionPacketService"]
@@ -0,0 +1,346 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Optional
from src.repositories.decision_packet_repository import DecisionPacketRepository
from src.repositories.execution_repository import ExecutionRepository
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.execution import ExecutionService
DEFAULT_MODEL_CODE = "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"
@dataclass(frozen=True)
class DecisionPacket:
instrument_id: int
packet_ts: object
status: str
action_recommendation: str
symbol: str
trading_plan_id: Optional[int]
candidate_id: Optional[int]
signal_id: Optional[int]
execution_ticket_id: Optional[int]
narrative: str
primary_draw: dict
bias: str
setup_type: str
confirmation_chain: dict
entry_zone: dict
stop_loss: Optional[float]
targets: dict
invalidation: dict
expiry_ts: Optional[object]
risk_result: dict
why_allowed: list[str]
why_blocked: list[str]
quality_score: Optional[float]
ai_explanation: str
alerts: list[dict]
raw: dict
def to_dict(self) -> dict:
return _serialize(asdict(self))
class DecisionPacketService:
def __init__(
self,
*,
decision_repository: Optional[DecisionPacketRepository] = None,
plan_repository: Optional[TradingPlanRepository] = None,
candidate_repository: Optional[TradePlanCandidateRepository] = None,
signal_repository: Optional[SignalRepository] = None,
execution_repository: Optional[ExecutionRepository] = None,
execution_service: Optional[ExecutionService] = None,
) -> None:
self.decision_repository = decision_repository or DecisionPacketRepository()
self.plan_repository = plan_repository or TradingPlanRepository()
self.candidate_repository = candidate_repository or TradePlanCandidateRepository()
self.signal_repository = signal_repository or SignalRepository()
self.execution_repository = execution_repository or ExecutionRepository()
self.execution_service = execution_service or ExecutionService()
def build_packet(self, *, instrument_id: int, model_code: str = DEFAULT_MODEL_CODE, now: Optional[datetime] = None) -> DecisionPacket:
observed_ts = _ensure_aware(now or datetime.now(timezone.utc))
plan = self.plan_repository.fetch_latest_trading_plan(instrument_id) or {}
candidates = self.candidate_repository.fetch_recent_candidates(instrument_id=instrument_id, limit=10)
candidate = _select_candidate(candidates, plan_id=plan.get("id"), model_code=model_code)
signal = self.signal_repository.fetch_latest_trade_signal_for_candidate(int(candidate["id"])) if candidate and candidate.get("id") else None
ticket = _latest_ticket(self.execution_repository, signal_id=int(signal["id"])) if signal and signal.get("id") else None
risk_result = _build_risk_result(self.execution_service, signal)
packet = build_decision_packet_from_rows(
instrument_id=instrument_id,
observed_ts=observed_ts,
plan=plan,
candidate=candidate or {},
signal=signal or {},
ticket=ticket or {},
risk_result=risk_result,
)
return packet
def persist_packet(self, packet: DecisionPacket) -> int:
return self.decision_repository.insert_decision_packet(
instrument_id=packet.instrument_id,
trading_plan_id=packet.trading_plan_id,
candidate_id=packet.candidate_id,
signal_id=packet.signal_id,
execution_ticket_id=packet.execution_ticket_id,
packet_ts=packet.packet_ts,
status=packet.status,
action_recommendation=packet.action_recommendation,
quality_score=packet.quality_score,
ai_explanation=packet.ai_explanation,
payload=packet.to_dict(),
)
def build_preview(self, *, instrument_id: int, model_code: str = DEFAULT_MODEL_CODE) -> dict:
packet = self.build_packet(instrument_id=instrument_id, model_code=model_code)
return {
**packet.to_dict(),
"alerts": build_alert_candidates(packet),
}
def build_persist_and_alert(self, *, instrument_id: int, model_code: str = DEFAULT_MODEL_CODE) -> dict:
packet = self.build_packet(instrument_id=instrument_id, model_code=model_code)
packet_id = self.persist_packet(packet)
alerts = []
for alert in build_alert_candidates(packet):
alert_id = self.decision_repository.insert_decision_alert(
instrument_id=instrument_id,
decision_packet_id=packet_id,
alert_key=alert["alert_key"],
alert_type=alert["alert_type"],
status="queued",
channel="workbench",
message=alert["message"],
payload=alert["payload"],
created_ts=packet.packet_ts,
)
alerts.append({**alert, "id": alert_id, "status": "queued"})
return {
"id": packet_id,
**packet.to_dict(),
"alerts": alerts,
}
def fetch_recent_alerts(self, *, instrument_id: int, limit: int = 20) -> list[dict]:
return self.decision_repository.fetch_recent_decision_alerts(instrument_id=instrument_id, limit=limit)
def build_decision_packet_from_rows(
*,
instrument_id: int,
observed_ts: datetime,
plan: dict,
candidate: dict,
signal: dict,
ticket: dict,
risk_result: dict,
) -> DecisionPacket:
confirmation_state = dict(candidate.get("confirmation_state") or signal.get("confirmation_quality") or {})
target_plan = dict(signal.get("target_plan") or (candidate.get("entry") or {}).get("target_plan") or {})
risk_reasons = list(risk_result.get("reasons") or [])
missing = list(candidate.get("missing_confirmations") or confirmation_state.get("missing_conditions") or [])
rejected = list(confirmation_state.get("rejected_conditions") or [])
status, action = resolve_action(plan=plan, candidate=candidate, ticket=ticket, risk_result=risk_result, missing=missing)
why_blocked = _dedupe([*missing, *rejected, *risk_reasons])
why_allowed = []
if action in {"build_execution_ticket", "confirm_ticket", "manage_ticket"}:
why_allowed = _dedupe(["active_plan_bound", "candidate_confirmed", "risk_governor_passed"])
ai_explanation = build_ai_explanation(action=action, plan=plan, candidate=candidate, risk_reasons=risk_reasons, missing=missing)
return DecisionPacket(
instrument_id=instrument_id,
packet_ts=observed_ts,
status=status,
action_recommendation=action,
symbol=str((candidate.get("evidence") or {}).get("symbol") or ""),
trading_plan_id=_int_or_none(candidate.get("trading_plan_id") or plan.get("id")),
candidate_id=_int_or_none(candidate.get("id")),
signal_id=_int_or_none(signal.get("id")),
execution_ticket_id=_int_or_none(ticket.get("id")),
narrative=str(plan.get("narrative") or candidate.get("narrative") or ""),
primary_draw=dict(plan.get("primary_draw") or signal.get("primary_draw") or {}),
bias=str(plan.get("bias") or "unknown"),
setup_type=str(candidate.get("model_code") or signal.get("model_code") or DEFAULT_MODEL_CODE),
confirmation_chain={
"present": list(confirmation_state.get("present_conditions") or confirmation_state.get("present") or []),
"missing": missing,
"rejected": rejected,
"reason_codes": list(confirmation_state.get("reason_codes") or []),
},
entry_zone={
"low": _float_or_none(signal.get("entry_low") or candidate.get("entry_low")),
"high": _float_or_none(signal.get("entry_high") or candidate.get("entry_high")),
"type": signal.get("entry_type") or (candidate.get("entry") or {}).get("entry_type") or "limit",
},
stop_loss=_float_or_none(signal.get("stop_loss") or candidate.get("stop_loss")),
targets={
"tp1": _target_item("tp1", signal=signal, candidate=candidate, target_plan=target_plan),
"tp2": _target_item("tp2", signal=signal, candidate=candidate, target_plan=target_plan),
"management_rules": list(signal.get("management_rules") or candidate.get("management_rules") or target_plan.get("management_rules") or []),
},
invalidation={
"plan": list(plan.get("invalidations") or []),
"candidate": list(candidate.get("invalidations") or []),
"signal": list(signal.get("invalidations") or []),
},
expiry_ts=signal.get("expires_ts") or candidate.get("expiry_ts") or candidate.get("expires_ts"),
risk_result=risk_result,
why_allowed=why_allowed,
why_blocked=why_blocked,
quality_score=_float_or_none(candidate.get("quality_score") or signal.get("quality_score")),
ai_explanation=ai_explanation,
alerts=[],
raw={
"plan": plan,
"candidate": candidate,
"signal": signal,
"ticket": ticket,
},
)
def resolve_action(*, plan: dict, candidate: dict, ticket: dict, risk_result: dict, missing: list[str]) -> tuple[str, str]:
if not plan or plan.get("status") != "active":
return "blocked", "wait_for_active_plan"
if not candidate:
return "monitoring", "wait_for_candidate"
if missing or candidate.get("status") in {"blocked", "draft"}:
return "blocked", "wait_for_confirmations"
if risk_result.get("allowed") is False:
return "blocked", "do_not_execute"
if ticket.get("status") == "awaiting_confirmation":
return "actionable", "confirm_ticket"
if ticket.get("status") in {"confirmed", "pending"}:
return "actionable", "manage_ticket"
return "actionable", "build_execution_ticket"
def build_alert_candidates(packet: DecisionPacket) -> list[dict]:
alerts = []
base = f"{packet.instrument_id}:{packet.candidate_id or 'none'}:{packet.action_recommendation}:{packet.status}"
if packet.action_recommendation in {"confirm_ticket", "build_execution_ticket"}:
alerts.append(_alert(base, "candidate_actionable", "Candidate is actionable inside the current plan.", packet))
if packet.action_recommendation in {"do_not_execute", "wait_for_confirmations", "wait_for_active_plan"}:
alerts.append(_alert(base, "candidate_blocked", f"Candidate blocked: {', '.join(packet.why_blocked) or packet.action_recommendation}", packet))
if packet.action_recommendation == "manage_ticket":
alerts.append(_alert(base, "ticket_ready", "Execution ticket is confirmed or pending management.", packet))
return alerts
def build_ai_explanation(*, action: str, plan: dict, candidate: dict, risk_reasons: list[str], missing: list[str]) -> str:
if not plan or plan.get("status") != "active":
return "No active trading plan is available, so the system should only observe."
if not candidate:
return "Active plan exists, but no current candidate is available."
if missing:
return f"Candidate is not ready because confirmations are missing: {', '.join(missing)}."
if risk_reasons:
return f"Risk Governor blocks execution: {', '.join(risk_reasons)}."
return f"Candidate is inside the active plan and current recommendation is {action}."
def _build_risk_result(execution_service: ExecutionService, signal: Optional[dict]) -> dict:
if not signal or not signal.get("id"):
return {"allowed": None, "status": "signal_missing", "reasons": ["signal_missing"]}
decision = execution_service.prepare_execution(signal_id=int(signal["id"]), execution_mode="semi_auto")
if decision is None:
return {"allowed": False, "status": "signal_not_found", "reasons": ["signal_not_found"]}
return {
"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),
}
def _latest_ticket(repository: ExecutionRepository, *, signal_id: int) -> Optional[dict]:
rows = repository.fetch_recent_execution_ticket_rows(signal_id=signal_id, limit=1)
return rows[0] if rows else None
def _select_candidate(candidates: list[dict], *, plan_id, model_code: str) -> Optional[dict]:
if plan_id:
scoped = [candidate for candidate in candidates if candidate.get("trading_plan_id") == plan_id]
for candidate in scoped:
if candidate.get("model_code") == model_code:
return candidate
return scoped[0] if scoped else None
for candidate in candidates:
if candidate.get("model_code") == model_code:
return candidate
return candidates[0] if candidates else None
def _target_item(name: str, *, signal: dict, candidate: dict, target_plan: dict) -> dict:
target = dict(target_plan.get(name) or {})
return {
"price": _float_or_none(signal.get(name) or candidate.get(name) or target.get("price")),
"source": target.get("source") or target.get("source_type") or "unknown",
"raw": target,
}
def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> dict:
return {
"alert_key": f"{key}:{alert_type}",
"alert_type": alert_type,
"message": message,
"payload": {
"action_recommendation": packet.action_recommendation,
"status": packet.status,
"why_blocked": list(packet.why_blocked),
"why_allowed": list(packet.why_allowed),
},
}
def _float_or_none(value) -> Optional[float]:
if value in (None, ""):
return None
return float(value)
def _int_or_none(value) -> Optional[int]:
if value in (None, ""):
return None
return int(value)
def _ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _serialize(value):
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {key: _serialize(item) for key, item in value.items()}
if isinstance(value, list):
return [_serialize(item) for item in value]
return value
def _dedupe(values: list[str]) -> list[str]:
result = []
seen = set()
for value in values:
text = str(value or "").strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
return result
@@ -8,6 +8,7 @@ from typing import Optional
from src.config.settings import settings from src.config.settings import settings
from src.services.broker import BROKER_STATE_OK, BrokerReadModelService from src.services.broker import BROKER_STATE_OK, BrokerReadModelService
from src.services.decision import DecisionPacketService
from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.market_state import MarketStateService, get_market_truth_supervisor
from src.services.trade_plan_candidates import SetupWatcherService from src.services.trade_plan_candidates import SetupWatcherService
from src.services.trading_plans import TradingPlanService from src.services.trading_plans import TradingPlanService
@@ -53,11 +54,13 @@ class TradingRuntimeScheduler:
trading_plan_service: Optional[TradingPlanService] = None, trading_plan_service: Optional[TradingPlanService] = None,
setup_watcher_service: Optional[SetupWatcherService] = None, setup_watcher_service: Optional[SetupWatcherService] = None,
broker_read_model_service: Optional[BrokerReadModelService] = None, broker_read_model_service: Optional[BrokerReadModelService] = None,
decision_packet_service: Optional[DecisionPacketService] = None,
) -> None: ) -> None:
self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor()) self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor())
self.trading_plan_service = trading_plan_service or TradingPlanService() self.trading_plan_service = trading_plan_service or TradingPlanService()
self.setup_watcher_service = setup_watcher_service or SetupWatcherService() self.setup_watcher_service = setup_watcher_service or SetupWatcherService()
self.broker_read_model_service = broker_read_model_service or BrokerReadModelService() self.broker_read_model_service = broker_read_model_service or BrokerReadModelService()
self.decision_packet_service = decision_packet_service or DecisionPacketService()
self.symbols = [item.strip() for item in settings.trading_runtime_symbols.split(",") if item.strip()] self.symbols = [item.strip() for item in settings.trading_runtime_symbols.split(",") if item.strip()]
self.bias_tf = settings.trading_runtime_bias_timeframe self.bias_tf = settings.trading_runtime_bias_timeframe
self.setup_tf = settings.trading_runtime_setup_timeframe self.setup_tf = settings.trading_runtime_setup_timeframe
@@ -66,6 +69,7 @@ class TradingRuntimeScheduler:
"market_state": RuntimeTaskState("market_state", settings.trading_runtime_market_state_interval_seconds), "market_state": RuntimeTaskState("market_state", settings.trading_runtime_market_state_interval_seconds),
"broker_sync": RuntimeTaskState("broker_sync", settings.trading_runtime_broker_sync_interval_seconds), "broker_sync": RuntimeTaskState("broker_sync", settings.trading_runtime_broker_sync_interval_seconds),
"watch": RuntimeTaskState("watch", settings.trading_runtime_watch_interval_seconds), "watch": RuntimeTaskState("watch", settings.trading_runtime_watch_interval_seconds),
"decision_packet": RuntimeTaskState("decision_packet", settings.trading_runtime_decision_packet_interval_seconds),
"plan": RuntimeTaskState("plan", settings.trading_runtime_plan_interval_seconds), "plan": RuntimeTaskState("plan", settings.trading_runtime_plan_interval_seconds),
} }
self.current_blockers: list[str] = [] self.current_blockers: list[str] = []
@@ -94,6 +98,7 @@ class TradingRuntimeScheduler:
market_payload = self._run_market_state_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) market_payload = self._run_market_state_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
self._run_broker_sync_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers) self._run_broker_sync_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
self._run_watch_task(symbol=symbol, instrument_id=instrument_id, now=now, market_payload=market_payload, blockers=blockers) self._run_watch_task(symbol=symbol, instrument_id=instrument_id, now=now, market_payload=market_payload, blockers=blockers)
self._run_decision_packet_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
self.current_blockers = _dedupe(blockers) self.current_blockers = _dedupe(blockers)
self.degraded_mode_state = { self.degraded_mode_state = {
"is_degraded": bool(self.current_blockers), "is_degraded": bool(self.current_blockers),
@@ -236,6 +241,28 @@ class TradingRuntimeScheduler:
blockers.append(code) blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now) self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now)
def _run_decision_packet_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None:
task = self.tasks["decision_packet"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
packet = self.decision_packet_service.build_persist_and_alert(instrument_id=instrument_id)
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"packet_id": packet.get("id"),
"status": packet.get("status"),
"action_recommendation": packet.get("action_recommendation"),
"alerts": len(packet.get("alerts") or []),
}
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"decision_packet_task_failed:{symbol}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now)
def _record_incident(self, *, task_name: str, symbol: str, code: str, observed_ts: datetime, error: Optional[str] = None) -> None: def _record_incident(self, *, task_name: str, symbol: str, code: str, observed_ts: datetime, error: Optional[str] = None) -> None:
self.incidents.append( self.incidents.append(
{ {
+64 -1
View File
@@ -11,6 +11,7 @@ const activeFilters = document.getElementById('activeFilters');
const overviewCards = document.getElementById('overviewCards'); const overviewCards = document.getElementById('overviewCards');
const marketStatePanel = document.getElementById('marketStatePanel'); const marketStatePanel = document.getElementById('marketStatePanel');
const runtimeStatusPanel = document.getElementById('runtimeStatusPanel'); const runtimeStatusPanel = document.getElementById('runtimeStatusPanel');
const decisionPacketPanel = document.getElementById('decisionPacketPanel');
const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); const preMarketPlanPanel = document.getElementById('preMarketPlanPanel');
const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel');
const executionTicketsPanel = document.getElementById('executionTicketsPanel'); const executionTicketsPanel = document.getElementById('executionTicketsPanel');
@@ -297,6 +298,60 @@ function renderRuntimeStatus(runtimeStatus) {
runtimeStatusPanel.appendChild(grid); runtimeStatusPanel.appendChild(grid);
} }
function renderDecisionPacket(packet, alertRows) {
clearNode(decisionPacketPanel);
if (!packet || packet.status === 'unavailable') {
decisionPacketPanel.appendChild(createElement('div', 'empty-state', 'Decision packet unavailable.'));
return;
}
decisionPacketPanel.appendChild(
buildInfoGrid([
['status', packet.status || 'unknown'],
['recommendation', packet.action_recommendation || 'monitor'],
['bias', packet.bias || 'unknown'],
['setup', packet.setup_type || 'n/a'],
['candidate', packet.candidate_id || 'n/a'],
['ticket', packet.execution_ticket_id || 'n/a'],
['risk result', formatJsonInline(packet.risk_result?.reasons || [])],
['why allowed', formatList(packet.why_allowed || [])],
['why blocked', formatList(packet.why_blocked || [])],
['ai explanation', packet.ai_explanation || 'n/a'],
])
);
const grid = createElement('div', 'cards');
grid.appendChild(
buildItemCard(
'Plan Context',
packet.action_recommendation || 'monitor',
[
['narrative', packet.narrative || 'n/a'],
['primary draw', formatJsonInline(packet.primary_draw || {})],
['confirmation', formatJsonInline(packet.confirmation_chain || {})],
['entry zone', `${formatNumber(packet.entry_zone?.low, 3)} - ${formatNumber(packet.entry_zone?.high, 3)}`],
['stop', formatNumber(packet.stop_loss, 3)],
['targets', formatJsonInline(packet.targets || {})],
['invalidation', formatJsonInline(packet.invalidation || {})],
['expiry', formatTimestamp(packet.expiry_ts)],
]
)
);
const recentAlerts = alertRows?.items || packet.alerts || [];
recentAlerts.slice(0, 3).forEach((alert) => {
grid.appendChild(
buildItemCard(
alert.alert_type || 'alert',
alert.status || 'queued',
[
['channel', alert.channel || 'workbench'],
['message', alert.message || 'n/a'],
['payload', formatJsonInline(alert.payload || {})],
]
)
);
});
decisionPacketPanel.appendChild(grid);
}
function renderPreMarketPlan(plan) { function renderPreMarketPlan(plan) {
clearNode(preMarketPlanPanel); clearNode(preMarketPlanPanel);
preMarketPlanPanel.appendChild( preMarketPlanPanel.appendChild(
@@ -646,6 +701,7 @@ function showErrorPanel(message) {
[ [
marketStatePanel, marketStatePanel,
runtimeStatusPanel, runtimeStatusPanel,
decisionPacketPanel,
preMarketPlanPanel, preMarketPlanPanel,
tradeCandidatesPanel, tradeCandidatesPanel,
executionTicketsPanel, executionTicketsPanel,
@@ -667,6 +723,8 @@ function renderDashboardData({
tradingPlan, tradingPlan,
marketState, marketState,
runtimeStatus, runtimeStatus,
decisionPacket,
decisionAlerts,
tradeCandidates, tradeCandidates,
executionTickets, executionTickets,
tradingExecutions, tradingExecutions,
@@ -678,6 +736,7 @@ function renderDashboardData({
renderOverview(weeklyReview, tradeCandidates.items || [], executionTickets.items || [], tradingExecutions.items || []); renderOverview(weeklyReview, tradeCandidates.items || [], executionTickets.items || [], tradingExecutions.items || []);
renderMarketState(marketState, tradingExecutions.items || []); renderMarketState(marketState, tradingExecutions.items || []);
renderRuntimeStatus(runtimeStatus); renderRuntimeStatus(runtimeStatus);
renderDecisionPacket(decisionPacket, decisionAlerts);
renderPreMarketPlan(tradingPlan); renderPreMarketPlan(tradingPlan);
renderCandidates(tradeCandidates.items || []); renderCandidates(tradeCandidates.items || []);
renderTickets(executionTickets.items || []); renderTickets(executionTickets.items || []);
@@ -694,12 +753,14 @@ async function refreshDashboard() {
const limit = limitInput.value || '20'; const limit = limitInput.value || '20';
try { try {
const [health, candles, tradingPlan, marketState, runtimeStatus, tradeCandidates, executionTickets, tradingExecutions, journalSummary, journalItems, weeklyReview] = await Promise.all([ const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, tradeCandidates, executionTickets, tradingExecutions, journalSummary, journalItems, weeklyReview] = await Promise.all([
fetchJson('/health'), fetchJson('/health'),
fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`), fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`),
fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null), fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null),
fetchJson(`/trading/market-state?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ market_state: {} })), fetchJson(`/trading/market-state?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ market_state: {} })),
fetchJson('/trading/runtime-status').catch(() => ({ scheduler_state: 'unavailable', tasks: {}, current_blockers: ['runtime_status_unavailable'] })), fetchJson('/trading/runtime-status').catch(() => ({ scheduler_state: 'unavailable', tasks: {}, current_blockers: ['runtime_status_unavailable'] })),
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/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })),
@@ -714,6 +775,8 @@ async function refreshDashboard() {
tradingPlan, tradingPlan,
marketState, marketState,
runtimeStatus, runtimeStatus,
decisionPacket,
decisionAlerts,
tradeCandidates, tradeCandidates,
executionTickets, executionTickets,
tradingExecutions, tradingExecutions,
+5
View File
@@ -64,6 +64,11 @@
<div id="runtimeStatusPanel" class="detail-panel"></div> <div id="runtimeStatusPanel" class="detail-panel"></div>
</section> </section>
<section class="panel panel-wide">
<h2>Decision Packet</h2>
<div id="decisionPacketPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide"> <section class="panel panel-wide">
<h2>Pre-Market Plan</h2> <h2>Pre-Market Plan</h2>
<div id="preMarketPlanPanel" class="detail-panel"></div> <div id="preMarketPlanPanel" class="detail-panel"></div>
+17
View File
@@ -51,6 +51,23 @@ class ApiServerRoutingTests(unittest.TestCase):
self.assertEqual(payload["snapshot_id"], 1) self.assertEqual(payload["snapshot_id"], 1)
self.assertEqual(payload["market_state"]["timeframe"], "5m") self.assertEqual(payload["market_state"]["timeframe"], "5m")
def test_routes_trading_decision_packet_and_alerts(self) -> None:
status_packet, payload_packet = route_request(
"/trading/decision-packet",
{"instrument_id": ["1"]},
dependencies={"trading_decision_packet": lambda **kwargs: {"status": "actionable", "action_recommendation": "confirm_ticket"}},
)
status_alerts, payload_alerts = route_request(
"/trading/alerts",
{"instrument_id": ["1"], "limit": ["2"]},
dependencies={"trading_decision_alerts": lambda **kwargs: [{"id": 1, "alert_type": "candidate_actionable"}]},
)
self.assertEqual(status_packet, 200)
self.assertEqual(payload_packet["action_recommendation"], "confirm_ticket")
self.assertEqual(status_alerts, 200)
self.assertEqual(payload_alerts["items"], [{"id": 1, "alert_type": "candidate_actionable"}])
def test_routes_trading_broker_state(self) -> None: def test_routes_trading_broker_state(self) -> None:
status, payload = route_request( status, payload = route_request(
"/trading/broker-state", "/trading/broker-state",
+105
View File
@@ -0,0 +1,105 @@
from datetime import datetime, timezone
import os
import unittest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.decision.decision_packet_service import (
_select_candidate,
build_alert_candidates,
build_decision_packet_from_rows,
)
class DecisionPacketServiceTests(unittest.TestCase):
def test_build_packet_recommends_confirm_ticket_when_risk_passes(self) -> None:
packet = build_decision_packet_from_rows(
instrument_id=1,
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
plan={
"id": 10,
"status": "active",
"narrative": "active:bullish",
"primary_draw": {"source": "liquidity_pool", "price_high": 101},
"bias": "bullish",
"invalidations": [{"code": "range_low_lost"}],
},
candidate={
"id": 20,
"trading_plan_id": 10,
"model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE",
"status": "executable",
"entry_low": 100,
"entry_high": 101,
"stop_loss": 99,
"tp1": 102,
"tp2": 104,
"quality_score": 0.8,
"confirmation_state": {"present_conditions": ["sweep", "mss", "fvg"], "reason_codes": ["confirmed"]},
"missing_confirmations": [],
"management_rules": [{"rule": "tp1_partial"}],
"entry": {"target_plan": {"tp1": {"source": "irl"}, "tp2": {"source": "erl"}}},
},
signal={"id": 30, "entry_type": "limit", "target_plan": {"tp1": {"source": "irl"}, "tp2": {"source": "erl"}}},
ticket={"id": 40, "status": "awaiting_confirmation"},
risk_result={"allowed": True, "status": "allowed", "reasons": []},
)
self.assertEqual(packet.status, "actionable")
self.assertEqual(packet.action_recommendation, "confirm_ticket")
self.assertEqual(packet.targets["tp1"]["source"], "irl")
self.assertIn("risk_governor_passed", packet.why_allowed)
def test_build_packet_explains_missing_confirmations(self) -> None:
packet = build_decision_packet_from_rows(
instrument_id=1,
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
plan={"id": 10, "status": "active", "bias": "bearish"},
candidate={
"id": 20,
"trading_plan_id": 10,
"status": "blocked",
"missing_confirmations": ["mss_confirmed"],
"confirmation_state": {"rejected_conditions": ["fvg_missing"]},
},
signal={},
ticket={},
risk_result={"allowed": None, "status": "signal_missing", "reasons": ["signal_missing"]},
)
self.assertEqual(packet.status, "blocked")
self.assertEqual(packet.action_recommendation, "wait_for_confirmations")
self.assertIn("mss_confirmed", packet.why_blocked)
self.assertIn("fvg_missing", packet.why_blocked)
self.assertIn("signal_missing", packet.why_blocked)
def test_alert_candidates_are_stable_for_actionable_packet(self) -> None:
packet = build_decision_packet_from_rows(
instrument_id=1,
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
plan={"id": 10, "status": "active"},
candidate={"id": 20, "trading_plan_id": 10, "status": "executable", "missing_confirmations": []},
signal={"id": 30},
ticket={"id": 40, "status": "awaiting_confirmation"},
risk_result={"allowed": True, "status": "allowed", "reasons": []},
)
alerts = build_alert_candidates(packet)
self.assertEqual(alerts[0]["alert_type"], "candidate_actionable")
self.assertIn("confirm_ticket", alerts[0]["alert_key"])
def test_select_candidate_prefers_active_plan_scope(self) -> None:
selected = _select_candidate(
[
{"id": 1, "trading_plan_id": 9, "model_code": "OTHER"},
{"id": 2, "trading_plan_id": 10, "model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"},
],
plan_id=10,
model_code="OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE",
)
self.assertEqual(selected["id"], 2)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -21,7 +21,9 @@ from scripts import (
run_liquidity, run_liquidity,
run_mss, run_mss,
run_ranges, run_ranges,
run_decision_packet,
run_session_generator, run_session_generator,
run_trading_runtime,
run_sweeps, run_sweeps,
run_swings, run_swings,
show_models, show_models,
@@ -113,6 +115,26 @@ class RuntimeScriptJsonTests(unittest.TestCase):
self.assertEqual(exit_code, 0) self.assertEqual(exit_code, 0)
serve_api.assert_called_once_with(host="127.0.0.1", port=8123) serve_api.assert_called_once_with(host="127.0.0.1", port=8123)
def test_run_trading_runtime_outputs_json(self) -> None:
service = type("Service", (), {"run_once": lambda self: {"scheduler_state": "running", "current_blockers": []}})()
output = io.StringIO()
with patch("scripts.run_trading_runtime.get_trading_runtime_service", return_value=service), contextlib.redirect_stdout(output):
exit_code = run_trading_runtime.main(env={"TRADING_RUNTIME_OUTPUT_JSON": "1"})
payload = json.loads(output.getvalue())
self.assertEqual(exit_code, 0)
self.assertEqual(payload["scheduler_state"], "running")
def test_run_decision_packet_outputs_json(self) -> None:
service = type("Service", (), {"build_persist_and_alert": lambda self, instrument_id: {"id": 1, "status": "actionable", "instrument_id": instrument_id}})()
output = io.StringIO()
with patch("scripts.run_decision_packet.DecisionPacketService", return_value=service), contextlib.redirect_stdout(output):
exit_code = run_decision_packet.main(env={"DECISION_PACKET_OUTPUT_JSON": "1", "DECISION_PACKET_INSTRUMENT_ID": "2"})
payload = json.loads(output.getvalue())
self.assertEqual(exit_code, 0)
self.assertEqual(payload["instrument_id"], 2)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -85,6 +85,16 @@ class FakeSetupWatcherService:
return 401 if candidate is not None else None return 401 if candidate is not None else None
class FakeDecisionPacketService:
def build_persist_and_alert(self, **kwargs):
return {
"id": 501,
"status": "actionable",
"action_recommendation": "confirm_ticket",
"alerts": [{"alert_type": "candidate_actionable"}],
}
class TradingRuntimeSchedulerTests(unittest.TestCase): class TradingRuntimeSchedulerTests(unittest.TestCase):
def test_run_cycle_records_successful_task_state(self) -> None: def test_run_cycle_records_successful_task_state(self) -> None:
scheduler = TradingRuntimeScheduler( scheduler = TradingRuntimeScheduler(
@@ -92,6 +102,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(), trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(), setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(), broker_read_model_service=FakeBrokerReadModelService(),
decision_packet_service=FakeDecisionPacketService(),
) )
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
payload = scheduler.run_cycle() payload = scheduler.run_cycle()
@@ -100,6 +111,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
self.assertEqual(payload["current_blockers"], []) self.assertEqual(payload["current_blockers"], [])
self.assertEqual(payload["tasks"]["plan"]["last_status"], "ok") self.assertEqual(payload["tasks"]["plan"]["last_status"], "ok")
self.assertEqual(payload["tasks"]["broker_sync"]["details"]["status"], "broker_state_ok") self.assertEqual(payload["tasks"]["broker_sync"]["details"]["status"], "broker_state_ok")
self.assertEqual(payload["tasks"]["decision_packet"]["details"]["packet_id"], 501)
self.assertEqual(payload["latest_broker_state_by_symbol"]["BTC-USDT"]["account"]["equity"], 1000.0) self.assertEqual(payload["latest_broker_state_by_symbol"]["BTC-USDT"]["account"]["equity"], 1000.0)
self.assertTrue(payload["tasks"]["watch"]["next_run_ts"]) self.assertTrue(payload["tasks"]["watch"]["next_run_ts"])
@@ -109,6 +121,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(), trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(), setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(), broker_read_model_service=FakeBrokerReadModelService(),
decision_packet_service=FakeDecisionPacketService(),
) )
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
payload = scheduler.run_cycle() payload = scheduler.run_cycle()
@@ -124,6 +137,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(), trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(), setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(), broker_read_model_service=FakeBrokerReadModelService(),
decision_packet_service=FakeDecisionPacketService(),
) )
supervisor = TradingRuntimeSupervisor(scheduler=scheduler) supervisor = TradingRuntimeSupervisor(scheduler=scheduler)
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"): with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):