Add broker reconciliation trade loop

This commit is contained in:
Codex
2026-05-08 15:00:37 +08:00
parent bbed0581ad
commit e8820e647c
21 changed files with 1093 additions and 1 deletions
+1
View File
@@ -42,6 +42,7 @@ TRADING_RUNTIME_SETUP_TIMEFRAME=1m
TRADING_RUNTIME_TRIGGER_TIMEFRAME=1m
TRADING_RUNTIME_MARKET_STATE_INTERVAL_SECONDS=15
TRADING_RUNTIME_BROKER_SYNC_INTERVAL_SECONDS=30
TRADING_RUNTIME_RECONCILIATION_INTERVAL_SECONDS=45
TRADING_RUNTIME_WATCH_INTERVAL_SECONDS=20
TRADING_RUNTIME_DECISION_PACKET_INTERVAL_SECONDS=20
TRADING_RUNTIME_PLAN_INTERVAL_SECONDS=900
@@ -0,0 +1,8 @@
alter table trading_journal_entries
add column if not exists real_trade_detected boolean not null default false,
add column if not exists out_of_model_trade boolean not null default false,
add column if not exists reconciliation_status text,
add column if not exists broker_fill_count integer not null default 0;
create index if not exists idx_trading_journal_entries_real_trade_date_desc
on trading_journal_entries (real_trade_detected, journal_date desc, journal_ts desc);
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from pathlib import Path
import json
import os
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.services.reconciliation import BrokerExecutionReconciliationService
def main(env=None) -> int:
env = os.environ if env is None else env
instrument_id = int(env.get("BROKER_RECONCILIATION_INSTRUMENT_ID", "1"))
output_json = _get_bool(env, "BROKER_RECONCILIATION_OUTPUT_JSON", False)
summary = BrokerExecutionReconciliationService().reconcile_instrument(instrument_id=instrument_id)
if output_json:
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
else:
print(f"Reconciliation status: {summary.get('status')}")
print(f"Matched executions: {summary.get('matched_execution_ids') or []}")
print(f"Imported executions: {summary.get('imported_execution_ids') or []}")
return 0 if summary.get("status") == "ok" else 2
def _get_bool(env, key: str, default: bool = False) -> bool:
value = env.get(key)
if value is None or value == "":
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -31,6 +31,7 @@ from src.api.trading_runtime import (
get_weekly_review,
rerun_ticket_risk_check,
run_candidate_confirm_check,
run_trading_reconciliation,
run_trading_runtime_once,
run_trading_watch,
sync_execution,
@@ -67,6 +68,7 @@ __all__ = [
"run_trading_watch",
"get_trading_candidate_by_id",
"run_candidate_confirm_check",
"run_trading_reconciliation",
"run_trading_runtime_once",
"build_ticket_from_candidate",
"rerun_ticket_risk_check",
+5
View File
@@ -33,6 +33,7 @@ from src.api.trading_runtime import (
get_weekly_review,
rerun_ticket_risk_check,
run_candidate_confirm_check,
run_trading_reconciliation,
run_trading_runtime_once,
run_trading_watch,
sync_execution,
@@ -73,6 +74,7 @@ def build_api_dependencies() -> dict:
"trading_market_state": get_trading_market_state,
"trading_decision_packet": get_trading_decision_packet,
"trading_decision_alerts": get_trading_decision_alerts,
"trading_reconciliation_run": run_trading_reconciliation,
"trading_runtime_status": get_trading_runtime_status,
"trading_runtime_run_once": run_trading_runtime_once,
"trading_watch_run": run_trading_watch,
@@ -186,6 +188,9 @@ def route_request(
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/reconcile":
instrument_id = _require_json_int(body or {}, "instrument_id")
return 200, deps["trading_reconciliation_run"](instrument_id=instrument_id)
if method == "POST" and path == "/trading/watch/run":
instrument_id = _require_json_int(body or {}, "instrument_id")
candidate = deps["trading_watch_run"](
+5
View File
@@ -12,6 +12,7 @@ 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.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
@@ -415,6 +416,10 @@ def get_trading_decision_alerts(instrument_id: int, limit: int = 20) -> list[dic
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 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 "")
+1
View File
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
trading_runtime_trigger_timeframe: str = "1m"
trading_runtime_market_state_interval_seconds: int = 15
trading_runtime_broker_sync_interval_seconds: int = 30
trading_runtime_reconciliation_interval_seconds: int = 45
trading_runtime_watch_interval_seconds: int = 20
trading_runtime_decision_packet_interval_seconds: int = 20
trading_runtime_plan_interval_seconds: int = 900
+36
View File
@@ -191,6 +191,42 @@ class ExecutionRepository:
rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all()
return [dict(row) for row in rows]
def fetch_recent_execution_reconciliation_rows(self, instrument_id: int, limit: int = 200) -> list[dict]:
sql = text(
"""
select
te.id as execution_id,
te.signal_id,
te.execution_ticket_id,
te.venue_order_id,
te.execution_mode,
te.status as execution_status,
te.side,
te.entry_price,
te.size,
te.opened_ts,
te.closed_ts,
te.broker_order_ids,
te.fill_state,
te.position_state,
te.close_reason,
te.reconciliation_state,
te.meta as execution_meta,
ts.instrument_id,
ts.model_code,
ts.session_code,
ts.meta as signal_meta
from trade_executions te
join trade_signals ts on ts.id = te.signal_id
where ts.instrument_id = :instrument_id
order by coalesce(te.closed_ts, te.opened_ts) desc nulls last, te.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]
def update_trade_execution_lifecycle(
self,
*,
+127
View File
@@ -347,3 +347,130 @@ class SignalRepository:
"meta": json.dumps(meta),
},
)
def insert_trade_signal_returning_id(
self,
instrument_id: int,
model_code: str,
side: str,
status: str,
trading_plan_id: Optional[int],
candidate_id: Optional[int],
bias_snapshot_id,
entry_type: str,
entry_low: float,
entry_high: float,
stop_loss: float,
tp1: float,
tp2: float,
rr_tp1: float,
rr_tp2: float,
session_code,
created_ts,
expires_ts,
invalidations: list,
evidence: dict,
primary_draw: dict,
target_plan: dict,
risk_governor_decision: dict,
confirmation_quality: dict,
quality_score: Optional[float],
ai_explanation: Optional[str],
meta: dict,
) -> int:
sql = text(
"""
insert into trade_signals (
instrument_id,
model_code,
side,
status,
trading_plan_id,
candidate_id,
bias_snapshot_id,
entry_type,
entry_low,
entry_high,
stop_loss,
tp1,
tp2,
rr_tp1,
rr_tp2,
session_code,
created_ts,
expires_ts,
invalidations,
evidence,
primary_draw,
target_plan,
risk_governor_decision,
confirmation_quality,
quality_score,
ai_explanation,
meta
) values (
:instrument_id,
:model_code,
:side,
:status,
:trading_plan_id,
:candidate_id,
:bias_snapshot_id,
:entry_type,
:entry_low,
:entry_high,
:stop_loss,
:tp1,
:tp2,
:rr_tp1,
:rr_tp2,
:session_code,
:created_ts,
:expires_ts,
cast(:invalidations as jsonb),
cast(:evidence as jsonb),
cast(:primary_draw as jsonb),
cast(:target_plan as jsonb),
cast(:risk_governor_decision as jsonb),
cast(:confirmation_quality as jsonb),
:quality_score,
:ai_explanation,
cast(:meta as jsonb)
)
returning id
"""
)
with SessionLocal.begin() as session:
result = session.execute(
sql,
{
"instrument_id": instrument_id,
"model_code": model_code,
"side": side,
"status": status,
"trading_plan_id": trading_plan_id,
"candidate_id": candidate_id,
"bias_snapshot_id": bias_snapshot_id,
"entry_type": entry_type,
"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": session_code,
"created_ts": created_ts,
"expires_ts": expires_ts,
"invalidations": json.dumps(invalidations, default=str),
"evidence": json.dumps(evidence, default=str),
"primary_draw": json.dumps(primary_draw, default=str),
"target_plan": json.dumps(target_plan, default=str),
"risk_governor_decision": json.dumps(risk_governor_decision, default=str),
"confirmation_quality": json.dumps(confirmation_quality, default=str),
"quality_score": quality_score,
"ai_explanation": ai_explanation,
"meta": json.dumps(meta, default=str),
},
)
return int(result.scalar_one())
@@ -26,6 +26,11 @@ class TradingJournalRepository:
te.opened_ts,
te.closed_ts,
te.pnl,
te.broker_order_ids,
te.fill_state,
te.position_state,
te.close_reason,
te.reconciliation_state,
te.meta as execution_meta,
ts.instrument_id,
ts.model_code,
@@ -109,6 +114,10 @@ class TradingJournalRepository:
framework_execution: bool,
model_compliance: bool,
risk_compliance: bool,
real_trade_detected: bool,
out_of_model_trade: bool,
reconciliation_status: Optional[str],
broker_fill_count: int,
model_failure: bool,
execution_violation: bool,
risk_violation: bool,
@@ -131,6 +140,10 @@ class TradingJournalRepository:
framework_execution,
model_compliance,
risk_compliance,
real_trade_detected,
out_of_model_trade,
reconciliation_status,
broker_fill_count,
model_failure,
execution_violation,
risk_violation,
@@ -150,6 +163,10 @@ class TradingJournalRepository:
:framework_execution,
:model_compliance,
:risk_compliance,
:real_trade_detected,
:out_of_model_trade,
:reconciliation_status,
:broker_fill_count,
:model_failure,
:execution_violation,
:risk_violation,
@@ -170,6 +187,10 @@ class TradingJournalRepository:
framework_execution = excluded.framework_execution,
model_compliance = excluded.model_compliance,
risk_compliance = excluded.risk_compliance,
real_trade_detected = excluded.real_trade_detected,
out_of_model_trade = excluded.out_of_model_trade,
reconciliation_status = excluded.reconciliation_status,
broker_fill_count = excluded.broker_fill_count,
model_failure = excluded.model_failure,
execution_violation = excluded.execution_violation,
risk_violation = excluded.risk_violation,
@@ -196,6 +217,10 @@ class TradingJournalRepository:
"framework_execution": framework_execution,
"model_compliance": model_compliance,
"risk_compliance": risk_compliance,
"real_trade_detected": real_trade_detected,
"out_of_model_trade": out_of_model_trade,
"reconciliation_status": reconciliation_status,
"broker_fill_count": broker_fill_count,
"model_failure": model_failure,
"execution_violation": execution_violation,
"risk_violation": risk_violation,
@@ -41,6 +41,10 @@ class TradingJournalEntryCandidate:
framework_execution: bool
model_compliance: bool
risk_compliance: bool
real_trade_detected: bool
out_of_model_trade: bool
reconciliation_status: Optional[str]
broker_fill_count: int
model_failure: bool
execution_violation: bool
risk_violation: bool
@@ -77,6 +81,12 @@ class TradingJournalService:
execution_audit = dict(review.get("execution_audit") or {})
error_types = list(review.get("error_types") or [])
lifecycle_state = review.get("backtest_lifecycle_state") or classify_backtest_lifecycle_state_from_result(backtest_result)
execution_reconciliation = dict(trade_execution.get("reconciliation_state") or {})
real_trade_detected = is_real_trade_detected(execution_reconciliation)
out_of_model_trade = real_trade_detected and (
bool(execution_reconciliation.get("imported_external_trade"))
or str(trade_signal.get("meta", {}).get("structure_state") or "") == "external_trade"
)
risk_violation = any(code in RISK_VIOLATION_CODES for code in error_types)
execution_violation = execution_audit.get("audit_status") == "review" and not risk_violation and any(
@@ -89,7 +99,7 @@ class TradingJournalService:
and not execution_violation
and not plan_violation
)
model_compliance = not plan_violation and _signal_structure_is_aligned(trade_signal)
model_compliance = not out_of_model_trade and not plan_violation and _signal_structure_is_aligned(trade_signal)
risk_compliance = not risk_violation
framework_execution = model_compliance and risk_compliance and not execution_violation and not model_failure
@@ -97,6 +107,8 @@ class TradingJournalService:
trade_execution=trade_execution,
backtest_result=backtest_result,
framework_execution=framework_execution,
real_trade_detected=real_trade_detected,
out_of_model_trade=out_of_model_trade,
risk_violation=risk_violation,
execution_violation=execution_violation,
plan_violation=plan_violation,
@@ -105,6 +117,9 @@ class TradingJournalService:
next_actions = build_next_actions(
review=review,
lifecycle_state=lifecycle_state,
real_trade_detected=real_trade_detected,
out_of_model_trade=out_of_model_trade,
reconciliation_status=str(execution_reconciliation.get("status") or ""),
risk_violation=risk_violation,
execution_violation=execution_violation,
plan_violation=plan_violation,
@@ -117,6 +132,7 @@ class TradingJournalService:
"signal": trade_signal,
"backtest_result": backtest_result,
"ticket": _build_ticket_payload(bundle),
"real_trade_mapping": execution_reconciliation,
}
return TradingJournalEntryCandidate(
execution_id=int(bundle["execution_id"]),
@@ -130,6 +146,10 @@ class TradingJournalService:
framework_execution=framework_execution,
model_compliance=model_compliance,
risk_compliance=risk_compliance,
real_trade_detected=real_trade_detected,
out_of_model_trade=out_of_model_trade,
reconciliation_status=str(execution_reconciliation.get("status") or "") or None,
broker_fill_count=int(execution_reconciliation.get("fill_count") or len(execution_reconciliation.get("broker_fill_ids") or [])),
model_failure=model_failure,
execution_violation=execution_violation,
risk_violation=risk_violation,
@@ -163,6 +183,8 @@ def build_review_tags(
trade_execution: dict,
backtest_result: Optional[dict],
framework_execution: bool,
real_trade_detected: bool,
out_of_model_trade: bool,
risk_violation: bool,
execution_violation: bool,
plan_violation: bool,
@@ -171,6 +193,10 @@ def build_review_tags(
tags: list[str] = []
if framework_execution:
tags.append("framework_execution")
if real_trade_detected:
tags.append("real_trade_detected")
if out_of_model_trade:
tags.append("out_of_model_trade")
if not risk_violation:
tags.append("risk_compliant")
if not execution_violation:
@@ -196,6 +222,9 @@ def build_next_actions(
*,
review: dict,
lifecycle_state: Optional[str],
real_trade_detected: bool,
out_of_model_trade: bool,
reconciliation_status: str,
risk_violation: bool,
execution_violation: bool,
plan_violation: bool,
@@ -208,6 +237,10 @@ def build_next_actions(
actions.append("review_execution_process_and_confirmation_path")
if plan_violation:
actions.append("tighten_plan_and_candidate_validation")
if real_trade_detected and reconciliation_status in {"unmatched", "position_only", "imported_external_fill"}:
actions.append("review_real_trade_reconciliation_rules")
if out_of_model_trade:
actions.append("review_out_of_model_real_trades")
if model_failure:
actions.append("review_model_context_and_target_selection")
if lifecycle_state == "tp2_closed":
@@ -241,6 +274,11 @@ def _build_trade_execution_payload(bundle: dict) -> dict:
"status": bundle.get("execution_status"),
"opened_ts": bundle.get("opened_ts"),
"closed_ts": bundle.get("closed_ts"),
"broker_order_ids": list(bundle.get("broker_order_ids") or []),
"fill_state": bundle.get("fill_state"),
"position_state": bundle.get("position_state"),
"close_reason": bundle.get("close_reason"),
"reconciliation_state": dict(bundle.get("reconciliation_state") or {}),
"meta": dict(bundle.get("execution_meta") or {}),
}
@@ -300,3 +338,10 @@ def _dedupe(values: list[str]) -> list[str]:
seen.add(text)
result.append(text)
return result
def is_real_trade_detected(reconciliation_state: dict) -> bool:
status = str(reconciliation_state.get("status") or "")
if status in {"matched_fill", "matched_fill_flat", "position_only", "imported_external_fill"}:
return True
return bool(reconciliation_state.get("broker_fill_ids"))
@@ -27,6 +27,11 @@ class WeeklyReviewService:
framework_count = sum(1 for entry in entries if entry.get("framework_execution"))
model_outside_count = sum(1 for entry in entries if not entry.get("model_compliance"))
risk_violation_count = sum(1 for entry in entries if entry.get("risk_violation"))
real_trade_count = sum(1 for entry in entries if entry.get("real_trade_detected"))
out_of_model_real_trade_count = sum(1 for entry in entries if entry.get("out_of_model_trade"))
reconciliation_issue_count = sum(
1 for entry in entries if str(entry.get("reconciliation_status") or "") in {"unmatched", "position_only", "imported_external_fill"}
)
session_stats = build_session_stats(entries)
high_quality_session, low_quality_session = rank_sessions(session_stats)
next_actions = recommend_next_actions(entries, low_quality_session=low_quality_session)
@@ -34,6 +39,9 @@ class WeeklyReviewService:
"week_start": week_start,
"week_end": week_end,
"entry_count": total,
"real_trade_count": real_trade_count,
"out_of_model_real_trade_rate": _ratio(out_of_model_real_trade_count, real_trade_count),
"reconciliation_issue_count": reconciliation_issue_count,
"framework_execution_rate": _ratio(framework_count, total),
"out_of_model_trade_rate": _ratio(model_outside_count, total),
"risk_violation_count": risk_violation_count,
@@ -51,6 +59,7 @@ def build_session_stats(entries: list[dict]) -> dict:
"framework_count": 0,
"risk_violations": 0,
"model_outside": 0,
"real_trade_count": 0,
"total_r": 0.0,
}
)
@@ -64,6 +73,8 @@ def build_session_stats(entries: list[dict]) -> dict:
bucket["risk_violations"] += 1
if not entry.get("model_compliance"):
bucket["model_outside"] += 1
if entry.get("real_trade_detected"):
bucket["real_trade_count"] += 1
summary = dict(entry.get("summary") or {})
backtest_result = dict(summary.get("backtest_result") or {})
backtest_meta = dict(backtest_result.get("meta") or {})
@@ -75,6 +86,7 @@ def build_session_stats(entries: list[dict]) -> dict:
"framework_execution_rate": _ratio(bucket["framework_count"], bucket["count"]),
"model_outside_rate": _ratio(bucket["model_outside"], bucket["count"]),
"risk_violation_count": bucket["risk_violations"],
"real_trade_count": bucket["real_trade_count"],
"avg_r_multiple": bucket["total_r"] / bucket["count"] if bucket["count"] else 0.0,
}
return stats
@@ -108,6 +120,10 @@ def recommend_next_actions(entries: list[dict], *, low_quality_session: Optional
actions = [item for item, _count in counter.most_common(3)]
if low_quality_session is not None:
actions.append(f"review_{str(low_quality_session['session_code']).lower()}_session_playbook")
if any(entry.get("out_of_model_trade") for entry in entries):
actions.append("review_out_of_model_real_trades")
if any(str(entry.get("reconciliation_status") or "") in {"unmatched", "position_only", "imported_external_fill"} for entry in entries):
actions.append("tighten_real_trade_reconciliation")
if not actions:
actions = ["collect_more_executed_samples", "review_weekly_trade_selection", "review_risk_process"]
deduped = []
+3
View File
@@ -0,0 +1,3 @@
from src.services.reconciliation.broker_execution_reconciliation_service import BrokerExecutionReconciliationService
__all__ = ["BrokerExecutionReconciliationService"]
@@ -0,0 +1,447 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from src.repositories.execution_repository import ExecutionRepository
from src.repositories.signal_repository import SignalRepository
from src.services.broker import BrokerReadModelService, BROKER_STATE_OK
from src.services.journal import TradingJournalService
EXTERNAL_REAL_TRADE_MODEL_CODE = "REAL_TRADE_IMPORT"
REAL_TRADE_SIGNAL_STATUS = "external_fill_imported"
@dataclass(frozen=True)
class BrokerFillGroup:
group_key: str
order_id: str
instrument_code: str
side: str
fill_ids: list[str]
total_size: float
avg_price: float
first_fill_ts: Optional[datetime]
last_fill_ts: Optional[datetime]
raw_fills: list[dict]
def to_dict(self) -> dict:
return {
"group_key": self.group_key,
"order_id": self.order_id,
"instrument_code": self.instrument_code,
"side": self.side,
"fill_ids": list(self.fill_ids),
"total_size": self.total_size,
"avg_price": self.avg_price,
"first_fill_ts": self.first_fill_ts.isoformat() if self.first_fill_ts else None,
"last_fill_ts": self.last_fill_ts.isoformat() if self.last_fill_ts else None,
"raw_fills": list(self.raw_fills),
}
class BrokerExecutionReconciliationService:
def __init__(
self,
*,
execution_repository: Optional[ExecutionRepository] = None,
signal_repository: Optional[SignalRepository] = None,
broker_read_model_service: Optional[BrokerReadModelService] = None,
journal_service: Optional[TradingJournalService] = None,
) -> None:
self.execution_repository = execution_repository or ExecutionRepository()
self.signal_repository = signal_repository or SignalRepository()
self.broker_read_model_service = broker_read_model_service or BrokerReadModelService()
self.journal_service = journal_service or TradingJournalService()
def reconcile_instrument(
self,
*,
instrument_id: int,
now: Optional[datetime] = None,
limit: int = 200,
) -> dict:
observed_now = _ensure_aware(now or datetime.now(timezone.utc))
snapshot = self.broker_read_model_service.fetch_snapshot(
instrument_id=instrument_id,
now=observed_now,
known_execution_refs={"broker_order_ids": [], "open_execution_count": 0},
)
if snapshot.status != BROKER_STATE_OK:
return {
"status": snapshot.status,
"error": snapshot.error,
"matched_execution_ids": [],
"imported_execution_ids": [],
"journal_updates": [],
"real_trade_groups": [],
}
execution_rows = self.execution_repository.fetch_recent_execution_reconciliation_rows(instrument_id=instrument_id, limit=limit)
fill_groups = build_fill_groups(snapshot.fills)
matched_execution_ids: list[int] = []
imported_execution_ids: list[int] = []
journal_updates: list[int] = []
claimed_group_keys: set[str] = set()
known_fill_ids = collect_known_fill_ids(execution_rows)
positions = list(snapshot.positions or [])
orders = list(snapshot.orders or [])
for row in execution_rows:
match = find_best_fill_group(row=row, fill_groups=fill_groups, claimed_group_keys=claimed_group_keys)
if match is None:
continue
claimed_group_keys.add(match.group_key)
updated_execution_id = self._apply_match(
row=row,
fill_group=match,
positions=positions,
orders=orders,
observed_now=observed_now,
)
matched_execution_ids.append(updated_execution_id)
journal_id = self.journal_service.upsert_entry_for_execution(updated_execution_id)
if journal_id:
journal_updates.append(journal_id)
for fill_group in fill_groups:
if fill_group.group_key in claimed_group_keys:
continue
if any(fill_id in known_fill_ids for fill_id in fill_group.fill_ids):
continue
execution_id = self._import_external_fill_group(
instrument_id=instrument_id,
fill_group=fill_group,
positions=positions,
orders=orders,
observed_now=observed_now,
)
imported_execution_ids.append(execution_id)
journal_id = self.journal_service.upsert_entry_for_execution(execution_id)
if journal_id:
journal_updates.append(journal_id)
return {
"status": "ok",
"matched_execution_ids": matched_execution_ids,
"imported_execution_ids": imported_execution_ids,
"journal_updates": journal_updates,
"real_trade_groups": [group.to_dict() for group in fill_groups],
"fills_seen": len(snapshot.fills or []),
"positions_seen": len(snapshot.positions or []),
"orders_seen": len(snapshot.orders or []),
}
def _apply_match(
self,
*,
row: dict,
fill_group: BrokerFillGroup,
positions: list[dict],
orders: list[dict],
observed_now: datetime,
) -> int:
execution_id = int(row["execution_id"])
trade_execution = self.execution_repository.fetch_execution_by_id(execution_id) or {}
existing_meta = dict(trade_execution.get("meta") or {})
existing_reconciliation = dict(trade_execution.get("reconciliation_state") or existing_meta.get("reconciliation_state") or {})
broker_order_ids = sorted(set(list(trade_execution.get("broker_order_ids") or existing_meta.get("broker_order_ids") or []) + ([fill_group.order_id] if fill_group.order_id else [])))
position_detected = has_matching_position(fill_group=fill_group, positions=positions)
pending_order_detected = has_matching_order(fill_group=fill_group, orders=orders)
reconciliation_state = {
**existing_reconciliation,
"status": "matched_fill" if position_detected or pending_order_detected else "matched_fill_flat",
"matched_at": observed_now.isoformat(),
"broker_fill_ids": list(fill_group.fill_ids),
"broker_order_ids": broker_order_ids,
"fill_count": len(fill_group.fill_ids),
"fill_side": fill_group.side,
"avg_fill_price": fill_group.avg_price,
"total_fill_size": fill_group.total_size,
"position_detected": position_detected,
"pending_order_detected": pending_order_detected,
"imported_external_trade": False,
"confidence": "high" if fill_group.order_id else "medium",
}
meta = {
**existing_meta,
"broker_order_ids": broker_order_ids,
"reconciliation_state": reconciliation_state,
"real_trade_mapping": {
"fill_group": fill_group.to_dict(),
"matched_execution_id": execution_id,
"position_detected": position_detected,
"pending_order_detected": pending_order_detected,
},
}
status = trade_execution.get("status") or row.get("execution_status")
fill_state = trade_execution.get("fill_state") or row.get("fill_state") or "filled"
position_state = trade_execution.get("position_state") or row.get("position_state") or ("open" if position_detected else "closed")
close_reason = trade_execution.get("close_reason") or row.get("close_reason")
if status == "pending":
status = "open" if position_detected or pending_order_detected else "closed"
self.execution_repository.update_trade_execution_lifecycle(
execution_id=execution_id,
status=str(status),
closed_ts=trade_execution.get("closed_ts") or (observed_now if status == "closed" and not position_detected else None),
fill_state=fill_state,
position_state=position_state,
close_reason=close_reason,
reconciliation_state=reconciliation_state,
bracket_state=dict(trade_execution.get("bracket_state") or existing_meta.get("bracket_state") or {}),
broker_order_ids=broker_order_ids,
confirmed_by=trade_execution.get("confirmed_by"),
confirmed_at=trade_execution.get("confirmed_at"),
execution_ticket_id=trade_execution.get("execution_ticket_id"),
confirmation_required=bool(trade_execution.get("confirmation_required")),
quality_score=trade_execution.get("quality_score"),
ai_explanation=trade_execution.get("ai_explanation"),
meta=meta,
)
return execution_id
def _import_external_fill_group(
self,
*,
instrument_id: int,
fill_group: BrokerFillGroup,
positions: list[dict],
orders: list[dict],
observed_now: datetime,
) -> int:
session_row = self.signal_repository.fetch_session_at(instrument_id=instrument_id, ts=fill_group.first_fill_ts or observed_now)
signal_id = self.signal_repository.insert_trade_signal_returning_id(
instrument_id=instrument_id,
model_code=EXTERNAL_REAL_TRADE_MODEL_CODE,
side=fill_group.side,
status=REAL_TRADE_SIGNAL_STATUS,
trading_plan_id=None,
candidate_id=None,
bias_snapshot_id=None,
entry_type="market",
entry_low=fill_group.avg_price,
entry_high=fill_group.avg_price,
stop_loss=0.0,
tp1=0.0,
tp2=0.0,
rr_tp1=0.0,
rr_tp2=0.0,
session_code=session_row.get("session_code") if session_row else None,
created_ts=fill_group.first_fill_ts or observed_now,
expires_ts=fill_group.last_fill_ts or observed_now,
invalidations=[],
evidence={
"external_real_trade": True,
"broker_order_id": fill_group.order_id,
"broker_fill_ids": list(fill_group.fill_ids),
},
primary_draw={},
target_plan={},
risk_governor_decision={},
confirmation_quality={},
quality_score=0.0,
ai_explanation=f"external_real_trade_import:{fill_group.instrument_code}:{fill_group.side}",
meta={
"structure_state": "external_trade",
"execution_permission": False,
"imported_real_trade": True,
"broker_fill_group": fill_group.to_dict(),
},
)
position_detected = has_matching_position(fill_group=fill_group, positions=positions)
pending_order_detected = has_matching_order(fill_group=fill_group, orders=orders)
reconciliation_state = {
"status": "imported_external_fill",
"matched_at": observed_now.isoformat(),
"broker_fill_ids": list(fill_group.fill_ids),
"broker_order_ids": [fill_group.order_id] if fill_group.order_id else [],
"fill_count": len(fill_group.fill_ids),
"fill_side": fill_group.side,
"avg_fill_price": fill_group.avg_price,
"total_fill_size": fill_group.total_size,
"position_detected": position_detected,
"pending_order_detected": pending_order_detected,
"imported_external_trade": True,
"confidence": "high",
}
execution_id = self.execution_repository.insert_trade_execution(
signal_id=signal_id,
execution_ticket_id=None,
venue_order_id=fill_group.order_id or fill_group.group_key,
execution_mode="manual",
side=fill_group.side,
entry_price=fill_group.avg_price,
stop_loss=0.0,
take_profit_1=0.0,
take_profit_2=0.0,
size=fill_group.total_size,
risk_pct=0.0,
status="open" if position_detected or pending_order_detected else "closed",
confirmation_required=False,
confirmed_by="broker_import",
confirmed_at=observed_now,
opened_ts=fill_group.first_fill_ts or observed_now,
broker_order_ids=[fill_group.order_id] if fill_group.order_id else [],
bracket_state={"status": "external_trade", "oco_group_id": None},
fill_state="filled",
position_state="open" if position_detected or pending_order_detected else "closed",
close_reason=None if position_detected or pending_order_detected else "external_trade_flat",
reconciliation_state=reconciliation_state,
quality_score=0.0,
ai_explanation=f"external_real_trade_import:{fill_group.side}",
meta={
"imported_real_trade": True,
"execution_source": "broker_fill_import",
"broker_fill_group": fill_group.to_dict(),
"reconciliation_state": reconciliation_state,
"structure_state": "external_trade",
},
)
if not position_detected and not pending_order_detected:
self.execution_repository.update_trade_execution_lifecycle(
execution_id=execution_id,
status="closed",
closed_ts=fill_group.last_fill_ts or observed_now,
fill_state="filled",
position_state="closed",
close_reason="external_trade_flat",
reconciliation_state=reconciliation_state,
bracket_state={"status": "external_trade", "oco_group_id": None},
broker_order_ids=[fill_group.order_id] if fill_group.order_id else [],
confirmed_by="broker_import",
confirmed_at=observed_now,
execution_ticket_id=None,
confirmation_required=False,
quality_score=0.0,
ai_explanation=f"external_real_trade_import:{fill_group.side}",
meta={
"imported_real_trade": True,
"execution_source": "broker_fill_import",
"broker_fill_group": fill_group.to_dict(),
"reconciliation_state": reconciliation_state,
"structure_state": "external_trade",
},
)
return execution_id
def build_fill_groups(fills: list[dict]) -> list[BrokerFillGroup]:
groups: dict[str, list[dict]] = {}
for fill in fills or []:
order_id = str(fill.get("order_id") or "").strip()
fill_id = str(fill.get("fill_id") or "").strip()
key = order_id or fill_id
if not key:
continue
groups.setdefault(key, []).append(dict(fill))
result = []
for key, rows in groups.items():
first = rows[0]
total_size = sum(float(row.get("fill_size") or 0.0) for row in rows)
total_notional = sum(float(row.get("fill_size") or 0.0) * float(row.get("fill_price") or 0.0) for row in rows)
avg_price = total_notional / total_size if total_size else float(first.get("fill_price") or 0.0)
fill_times = sorted((_parse_dt(row.get("fill_ts")) for row in rows if row.get("fill_ts")), key=lambda value: value or datetime.now(timezone.utc))
result.append(
BrokerFillGroup(
group_key=key,
order_id=str(first.get("order_id") or key),
instrument_code=str(first.get("instrument_id") or ""),
side=str(first.get("side") or "").lower(),
fill_ids=[str(row.get("fill_id") or "") for row in rows if row.get("fill_id")],
total_size=total_size,
avg_price=avg_price,
first_fill_ts=fill_times[0] if fill_times else None,
last_fill_ts=fill_times[-1] if fill_times else None,
raw_fills=rows,
)
)
return sorted(result, key=lambda item: item.last_fill_ts or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
def collect_known_fill_ids(execution_rows: list[dict]) -> set[str]:
known = set()
for row in execution_rows:
reconciliation_state = dict(row.get("reconciliation_state") or {})
for fill_id in list(reconciliation_state.get("broker_fill_ids") or []):
text = str(fill_id or "").strip()
if text:
known.add(text)
return known
def find_best_fill_group(*, row: dict, fill_groups: list[BrokerFillGroup], claimed_group_keys: set[str]) -> Optional[BrokerFillGroup]:
broker_order_ids = {str(value).strip() for value in list(row.get("broker_order_ids") or []) if str(value or "").strip()}
venue_order_id = str(row.get("venue_order_id") or "").strip()
if venue_order_id:
broker_order_ids.add(venue_order_id)
for fill_group in fill_groups:
if fill_group.group_key in claimed_group_keys:
continue
if fill_group.order_id and fill_group.order_id in broker_order_ids:
return fill_group
execution_side = str(row.get("side") or "").lower()
execution_price = float(row.get("entry_price") or 0.0)
opened_ts = _parse_dt(row.get("opened_ts"))
if opened_ts is None or execution_price <= 0 or not execution_side:
return None
window_start = opened_ts - timedelta(hours=4)
window_end = opened_ts + timedelta(hours=4)
candidates = []
for fill_group in fill_groups:
if fill_group.group_key in claimed_group_keys:
continue
if fill_group.side != execution_side:
continue
if fill_group.first_fill_ts is None:
continue
if not (window_start <= fill_group.first_fill_ts <= window_end):
continue
if not price_is_close(fill_group.avg_price, execution_price):
continue
time_distance = abs((fill_group.first_fill_ts - opened_ts).total_seconds())
price_distance = abs(fill_group.avg_price - execution_price)
candidates.append((time_distance, price_distance, fill_group))
if not candidates:
return None
candidates.sort(key=lambda item: (item[0], item[1]))
return candidates[0][2]
def has_matching_position(*, fill_group: BrokerFillGroup, positions: list[dict]) -> bool:
for position in positions or []:
instrument_code = str(position.get("instrument_id") or "")
if instrument_code != fill_group.instrument_code:
continue
side = str(position.get("side") or "").lower()
if side in {fill_group.side, "net"}:
return True
return False
def has_matching_order(*, fill_group: BrokerFillGroup, orders: list[dict]) -> bool:
for order in orders or []:
if fill_group.order_id and str(order.get("order_id") or "") == fill_group.order_id:
return True
return False
def price_is_close(left: float, right: float) -> bool:
anchor = max(abs(right), 1.0)
return abs(left - right) <= max(anchor * 0.005, 25.0)
def _parse_dt(value) -> Optional[datetime]:
if value in (None, ""):
return None
if isinstance(value, datetime):
return _ensure_aware(value)
return _ensure_aware(datetime.fromisoformat(str(value).replace("Z", "+00:00")))
def _ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@@ -10,6 +10,7 @@ from src.config.settings import settings
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.reconciliation import BrokerExecutionReconciliationService
from src.services.trade_plan_candidates import SetupWatcherService
from src.services.trading_plans import TradingPlanService
@@ -54,12 +55,16 @@ class TradingRuntimeScheduler:
trading_plan_service: Optional[TradingPlanService] = None,
setup_watcher_service: Optional[SetupWatcherService] = None,
broker_read_model_service: Optional[BrokerReadModelService] = None,
reconciliation_service: Optional[BrokerExecutionReconciliationService] = None,
decision_packet_service: Optional[DecisionPacketService] = None,
) -> None:
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.setup_watcher_service = setup_watcher_service or SetupWatcherService()
self.broker_read_model_service = broker_read_model_service or BrokerReadModelService()
self.reconciliation_service = reconciliation_service or BrokerExecutionReconciliationService(
broker_read_model_service=self.broker_read_model_service,
)
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.bias_tf = settings.trading_runtime_bias_timeframe
@@ -68,6 +73,7 @@ class TradingRuntimeScheduler:
self.tasks = {
"market_state": RuntimeTaskState("market_state", settings.trading_runtime_market_state_interval_seconds),
"broker_sync": RuntimeTaskState("broker_sync", settings.trading_runtime_broker_sync_interval_seconds),
"reconciliation": RuntimeTaskState("reconciliation", settings.trading_runtime_reconciliation_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),
@@ -97,6 +103,7 @@ class TradingRuntimeScheduler:
self._run_plan_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_reconciliation_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_decision_packet_task(symbol=symbol, instrument_id=instrument_id, now=now, blockers=blockers)
self.current_blockers = _dedupe(blockers)
@@ -241,6 +248,32 @@ class TradingRuntimeScheduler:
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(exc), observed_ts=now)
def _run_reconciliation_task(self, *, symbol: str, instrument_id: int, now: datetime, blockers: list[str]) -> None:
task = self.tasks["reconciliation"]
if not _task_due(task, now):
return
_task_start(task, now)
try:
summary = self.reconciliation_service.reconcile_instrument(instrument_id=instrument_id, now=now)
task.details = {
"symbol": symbol,
"instrument_id": instrument_id,
"status": summary.get("status"),
"matched_execution_ids": list(summary.get("matched_execution_ids") or []),
"imported_execution_ids": list(summary.get("imported_execution_ids") or []),
"journal_updates": list(summary.get("journal_updates") or []),
}
if summary.get("status") != "ok":
code = f"reconciliation_degraded:{symbol}:{summary.get('error') or summary.get('status')}"
blockers.append(code)
self._record_incident(task_name=task.name, symbol=symbol, code=code, error=str(summary.get("error")), observed_ts=now)
_task_success(task, now)
except Exception as exc:
_task_failure(task, now, str(exc))
code = f"reconciliation_task_failed:{symbol}"
blockers.append(code)
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):
+14
View File
@@ -546,6 +546,16 @@ function renderTickets(items) {
function renderExecutions(items) {
clearNode(positionManagementPanel);
positionManagementPanel.appendChild(
buildActionBar([
{
label: 'Reconcile Real Trades',
onClick: () => runAction('/trading/reconcile', {
instrument_id: Number(instrumentIdInput.value || 1),
}),
},
])
);
const filtered = filterExecutions(items);
if (!filtered.length) {
positionManagementPanel.appendChild(createElement('div', 'empty-state', 'No execution rows match the current filters.'));
@@ -574,6 +584,7 @@ function renderExecutions(items) {
['opened', formatTimestamp(execution.opened_ts)],
['closed', formatTimestamp(execution.closed_ts)],
['quality', formatNumber(execution.quality_score)],
['real trade mapping', formatJsonInline(execution.reconciliation_state || {})],
['bracket state', formatJsonInline(execution.bracket_state || {})],
['reconciliation', formatJsonInline(execution.reconciliation_state || {})],
],
@@ -611,9 +622,12 @@ function renderPostTradeReview(summary, journalItems, weeklyReview) {
const top = buildInfoGrid([
['entry count', String(summary?.entry_count ?? weeklyReview?.entry_count ?? 0)],
['real trades', String(weeklyReview?.real_trade_count ?? 0)],
['framework execution rate', formatPercent(weeklyReview?.framework_execution_rate)],
['out-of-model rate', formatPercent(weeklyReview?.out_of_model_trade_rate)],
['out-of-model real trades', formatPercent(weeklyReview?.out_of_model_real_trade_rate)],
['risk violation count', String(weeklyReview?.risk_violation_count ?? 0)],
['reconciliation issues', String(weeklyReview?.reconciliation_issue_count ?? 0)],
['high quality session', formatJsonInline(weeklyReview?.high_quality_session)],
['low quality session', formatJsonInline(weeklyReview?.low_quality_session)],
['next actions', formatList(weeklyReview?.next_week_actions || [])],
+12
View File
@@ -68,6 +68,18 @@ class ApiServerRoutingTests(unittest.TestCase):
self.assertEqual(status_alerts, 200)
self.assertEqual(payload_alerts["items"], [{"id": 1, "alert_type": "candidate_actionable"}])
def test_routes_trading_reconcile(self) -> None:
status, payload = route_request(
"/trading/reconcile",
{},
method="POST",
body={"instrument_id": 1},
dependencies={"trading_reconciliation_run": lambda **kwargs: {"status": "ok", "imported_execution_ids": [7]}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["imported_execution_ids"], [7])
def test_routes_trading_broker_state(self) -> None:
status, payload = route_request(
"/trading/broker-state",
@@ -0,0 +1,222 @@
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.reconciliation import BrokerExecutionReconciliationService
from src.services.reconciliation.broker_execution_reconciliation_service import (
BROKER_STATE_OK,
BrokerFillGroup,
build_fill_groups,
)
class BrokerExecutionReconciliationServiceTests(unittest.TestCase):
def test_reconcile_matches_existing_execution_by_order_id(self) -> None:
repository = _ExecutionRepo(
rows=[
{
"execution_id": 11,
"signal_id": 21,
"execution_ticket_id": 31,
"venue_order_id": None,
"execution_mode": "semi_auto",
"execution_status": "pending",
"side": "buy",
"entry_price": 100.0,
"size": 1.0,
"opened_ts": datetime(2026, 5, 8, 0, 0, tzinfo=timezone.utc),
"closed_ts": None,
"broker_order_ids": ["ord-1"],
"fill_state": "pending_submit",
"position_state": "pending_entry",
"close_reason": None,
"reconciliation_state": {},
"execution_meta": {},
"instrument_id": 1,
"model_code": "OSOK",
"session_code": "LONDON",
"signal_meta": {},
}
],
executions={
11: {
"id": 11,
"signal_id": 21,
"execution_ticket_id": 31,
"status": "pending",
"fill_state": "pending_submit",
"position_state": "pending_entry",
"close_reason": None,
"reconciliation_state": {},
"bracket_state": {},
"broker_order_ids": ["ord-1"],
"confirmed_by": "alice",
"confirmed_at": datetime(2026, 5, 8, 0, 0, tzinfo=timezone.utc),
"confirmation_required": True,
"quality_score": 80.0,
"ai_explanation": "ready",
"meta": {},
"closed_ts": None,
}
},
)
signal_repository = _SignalRepo()
journal_service = _JournalService()
broker_service = _BrokerService(
fills=[
{
"fill_id": "fill-1",
"order_id": "ord-1",
"instrument_id": "BTC-USDT-SWAP",
"side": "buy",
"fill_size": 1.0,
"fill_price": 100.0,
"fill_ts": datetime(2026, 5, 8, 0, 1, tzinfo=timezone.utc),
}
],
positions=[{"instrument_id": "BTC-USDT-SWAP", "side": "buy", "size": 1.0}],
orders=[],
)
service = BrokerExecutionReconciliationService(
execution_repository=repository,
signal_repository=signal_repository,
broker_read_model_service=broker_service,
journal_service=journal_service,
)
summary = service.reconcile_instrument(instrument_id=1, now=datetime(2026, 5, 8, 0, 2, tzinfo=timezone.utc))
self.assertEqual(summary["status"], "ok")
self.assertEqual(summary["matched_execution_ids"], [11])
self.assertEqual(summary["imported_execution_ids"], [])
self.assertEqual(journal_service.execution_ids, [11])
self.assertEqual(repository.updated[0]["reconciliation_state"]["status"], "matched_fill")
self.assertEqual(repository.updated[0]["status"], "open")
def test_reconcile_imports_unmatched_fill_group(self) -> None:
repository = _ExecutionRepo(rows=[], executions={})
signal_repository = _SignalRepo()
journal_service = _JournalService()
broker_service = _BrokerService(
fills=[
{
"fill_id": "fill-2",
"order_id": "ord-2",
"instrument_id": "BTC-USDT-SWAP",
"side": "sell",
"fill_size": 0.5,
"fill_price": 105.0,
"fill_ts": datetime(2026, 5, 8, 2, 1, tzinfo=timezone.utc),
}
],
positions=[],
orders=[],
)
service = BrokerExecutionReconciliationService(
execution_repository=repository,
signal_repository=signal_repository,
broker_read_model_service=broker_service,
journal_service=journal_service,
)
summary = service.reconcile_instrument(instrument_id=1, now=datetime(2026, 5, 8, 2, 2, tzinfo=timezone.utc))
self.assertEqual(summary["status"], "ok")
self.assertEqual(summary["matched_execution_ids"], [])
self.assertEqual(summary["imported_execution_ids"], [901])
self.assertEqual(signal_repository.created_signal_ids, [801])
self.assertEqual(journal_service.execution_ids, [901])
def test_build_fill_groups_merges_partial_fills_by_order(self) -> None:
groups = build_fill_groups(
[
{
"fill_id": "f-1",
"order_id": "ord-1",
"instrument_id": "BTC-USDT-SWAP",
"side": "buy",
"fill_size": 1.0,
"fill_price": 100.0,
"fill_ts": datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
},
{
"fill_id": "f-2",
"order_id": "ord-1",
"instrument_id": "BTC-USDT-SWAP",
"side": "buy",
"fill_size": 2.0,
"fill_price": 101.0,
"fill_ts": datetime(2026, 5, 8, 1, 1, tzinfo=timezone.utc),
},
]
)
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0].total_size, 3.0)
self.assertAlmostEqual(groups[0].avg_price, (1.0 * 100.0 + 2.0 * 101.0) / 3.0)
class _ExecutionRepo:
def __init__(self, *, rows, executions):
self.rows = rows
self.executions = executions
self.updated = []
self.inserted = []
def fetch_recent_execution_reconciliation_rows(self, instrument_id: int, limit: int = 200):
return list(self.rows)
def fetch_execution_by_id(self, execution_id: int):
return dict(self.executions.get(execution_id) or {})
def update_trade_execution_lifecycle(self, **kwargs):
self.updated.append(kwargs)
def insert_trade_execution(self, **kwargs):
self.inserted.append(kwargs)
return 901
class _SignalRepo:
def __init__(self):
self.created_signal_ids = []
def fetch_session_at(self, instrument_id: int, ts):
return {"session_code": "LONDON"}
def insert_trade_signal_returning_id(self, **kwargs):
self.created_signal_ids.append(801)
return 801
class _JournalService:
def __init__(self):
self.execution_ids = []
def upsert_entry_for_execution(self, execution_id: int):
self.execution_ids.append(execution_id)
return len(self.execution_ids)
class _BrokerSnapshot:
status = BROKER_STATE_OK
error = None
def __init__(self, *, fills, positions, orders):
self.fills = fills
self.positions = positions
self.orders = orders
class _BrokerService:
def __init__(self, *, fills, positions, orders):
self.snapshot = _BrokerSnapshot(fills=fills, positions=positions, orders=orders)
def fetch_snapshot(self, **kwargs):
return self.snapshot
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -19,6 +19,7 @@ from scripts import (
run_displacement,
run_fvg,
run_liquidity,
run_broker_reconciliation,
run_mss,
run_ranges,
run_decision_packet,
@@ -135,6 +136,18 @@ class RuntimeScriptJsonTests(unittest.TestCase):
self.assertEqual(exit_code, 0)
self.assertEqual(payload["instrument_id"], 2)
def test_run_broker_reconciliation_outputs_json(self) -> None:
service = type("Service", (), {"reconcile_instrument": lambda self, instrument_id: {"status": "ok", "instrument_id": instrument_id, "matched_execution_ids": [1]}})()
output = io.StringIO()
with patch("scripts.run_broker_reconciliation.BrokerExecutionReconciliationService", return_value=service), contextlib.redirect_stdout(output):
exit_code = run_broker_reconciliation.main(
env={"BROKER_RECONCILIATION_OUTPUT_JSON": "1", "BROKER_RECONCILIATION_INSTRUMENT_ID": "2"}
)
payload = json.loads(output.getvalue())
self.assertEqual(exit_code, 0)
self.assertEqual(payload["instrument_id"], 2)
if __name__ == "__main__":
unittest.main()
@@ -25,6 +25,8 @@ class TradingJournalServiceTests(unittest.TestCase):
self.assertTrue(entry.plan_violation)
self.assertTrue(entry.risk_violation)
self.assertFalse(entry.framework_execution)
self.assertTrue(entry.real_trade_detected)
self.assertFalse(entry.out_of_model_trade)
self.assertIn("plan_violation", entry.review_tags)
self.assertIn("tighten_plan_and_candidate_validation", entry.next_actions)
@@ -45,6 +47,9 @@ class TradingJournalServiceTests(unittest.TestCase):
"framework_execution": True,
"model_compliance": True,
"risk_violation": False,
"real_trade_detected": True,
"out_of_model_trade": False,
"reconciliation_status": "matched_fill",
"next_actions": ["keep_same_model"],
"summary": {"backtest_result": {"meta": {"r_multiple": 1.2}}},
},
@@ -53,6 +58,9 @@ class TradingJournalServiceTests(unittest.TestCase):
"framework_execution": False,
"model_compliance": False,
"risk_violation": True,
"real_trade_detected": True,
"out_of_model_trade": True,
"reconciliation_status": "imported_external_fill",
"next_actions": ["review_risk_process"],
"summary": {"backtest_result": {"meta": {"r_multiple": -0.8}}},
},
@@ -63,6 +71,7 @@ class TradingJournalServiceTests(unittest.TestCase):
)
self.assertEqual(summary["entry_count"], 2)
self.assertEqual(summary["real_trade_count"], 2)
self.assertEqual(summary["risk_violation_count"], 1)
self.assertEqual(summary["high_quality_session"]["session_code"], "LONDON")
self.assertEqual(summary["low_quality_session"]["session_code"], "ASIA")
@@ -131,6 +140,11 @@ def _bundle() -> dict:
"execution_status": "closed",
"opened_ts": datetime(2026, 5, 6, 0, 0, tzinfo=timezone.utc),
"closed_ts": datetime(2026, 5, 6, 1, 0, tzinfo=timezone.utc),
"broker_order_ids": ["ord-1"],
"fill_state": "filled",
"position_state": "closed",
"close_reason": "tp2_closed",
"reconciliation_state": {"status": "matched_fill", "broker_fill_ids": ["fill-1"], "fill_count": 1},
"execution_meta": {"risk_checks": ["missing_structural_targets", "news_window_active"]},
"instrument_id": 1,
"model_code": "SWEEP_MSS_FVG",
@@ -95,6 +95,16 @@ class FakeDecisionPacketService:
}
class FakeReconciliationService:
def reconcile_instrument(self, **kwargs):
return {
"status": "ok",
"matched_execution_ids": [91],
"imported_execution_ids": [92],
"journal_updates": [11, 12],
}
class TradingRuntimeSchedulerTests(unittest.TestCase):
def test_run_cycle_records_successful_task_state(self) -> None:
scheduler = TradingRuntimeScheduler(
@@ -102,6 +112,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(),
reconciliation_service=FakeReconciliationService(),
decision_packet_service=FakeDecisionPacketService(),
)
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
@@ -111,6 +122,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
self.assertEqual(payload["current_blockers"], [])
self.assertEqual(payload["tasks"]["plan"]["last_status"], "ok")
self.assertEqual(payload["tasks"]["broker_sync"]["details"]["status"], "broker_state_ok")
self.assertEqual(payload["tasks"]["reconciliation"]["details"]["matched_execution_ids"], [91])
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.assertTrue(payload["tasks"]["watch"]["next_run_ts"])
@@ -121,6 +133,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(),
reconciliation_service=FakeReconciliationService(),
decision_packet_service=FakeDecisionPacketService(),
)
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
@@ -137,6 +150,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
trading_plan_service=FakeTradingPlanService(),
setup_watcher_service=FakeSetupWatcherService(),
broker_read_model_service=FakeBrokerReadModelService(),
reconciliation_service=FakeReconciliationService(),
decision_packet_service=FakeDecisionPacketService(),
)
supervisor = TradingRuntimeSupervisor(scheduler=scheduler)
@@ -167,6 +181,18 @@ class TradingRuntimeApiTests(unittest.TestCase):
self.assertEqual(run_status, 200)
self.assertIn("tasks", run_payload)
def test_routes_reconciliation_run(self) -> None:
status, payload = route_request(
"/trading/reconcile",
{},
method="POST",
body={"instrument_id": 1},
dependencies={"trading_reconciliation_run": lambda **kwargs: {"status": "ok", "matched_execution_ids": [1]}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["matched_execution_ids"], [1])
class TradingRuntimeScriptTests(unittest.TestCase):
def test_script_outputs_json(self) -> None: