Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,27 @@ def get_market_prices(
symbols,
*,
fetch_quote_snapshots,
quote_recorder=None,
):
"""Fetch market prices for multiple symbols in one pass."""
quotes = fetch_quote_snapshots(ib, symbols)
if quote_recorder is not None:
for symbol, quote in quotes.items():
quote_recorder(symbol, quote)
return {symbol: quote.last_price for symbol, quote in quotes.items()}


def _serialize_quote_snapshot(snapshot, *, symbol: str | None = None) -> dict:
return {
"symbol": str(getattr(snapshot, "symbol", "") or symbol or "").strip().upper(),
"as_of": str(getattr(snapshot, "as_of", "") or ""),
"last_price": float(getattr(snapshot, "last_price", 0.0) or 0.0),
"bid_price": getattr(snapshot, "bid_price", None),
"ask_price": getattr(snapshot, "ask_price", None),
"currency": str(getattr(snapshot, "currency", "") or "").strip(),
}


def check_order_submitted(report, *, translator):
"""Check if order was accepted. DAY orders auto-expire at close if not filled."""
order_id = report.broker_order_id
Expand Down Expand Up @@ -1005,6 +1020,14 @@ def execute_rebalance(
equity = account_values.get("equity", 0)
normalized_account_ids = _normalize_account_ids(account_ids)
order_account_id = _resolve_order_account_id(normalized_account_ids)
quote_snapshots_by_symbol: dict[str, dict] = {}

def record_quote_snapshot(symbol, snapshot) -> None:
payload = _serialize_quote_snapshot(snapshot, symbol=symbol)
symbol = payload.get("symbol")
if symbol:
quote_snapshots_by_symbol[symbol] = payload

execution_summary = {
"mode": "dry_run" if dry_run_only else "paper",
"strategy_profile": strategy_profile,
Expand Down Expand Up @@ -1091,6 +1114,7 @@ def execute_rebalance(
ib,
all_symbols,
fetch_quote_snapshots=fetch_quote_snapshots,
quote_recorder=record_quote_snapshot,
)
prices, snapshot_price_fallback_symbols = _apply_snapshot_price_fallbacks(
prices,
Expand All @@ -1105,6 +1129,9 @@ def execute_rebalance(
execution_summary["price_fallback_symbols"] = list(snapshot_price_fallback_symbols)
execution_summary["price_fallback_count"] = len(snapshot_price_fallback_symbols)
execution_summary["price_fallback_source"] = price_fallback_source if snapshot_price_fallback_symbols else None
execution_summary["quote_snapshot"] = {
"quotes": list(quote_snapshots_by_symbol.values()),
}
if snapshot_price_fallback_symbols:
execution_summary["price_source_mode"] = f"mixed_market_quote_{price_fallback_source}"

Expand Down
1 change: 1 addition & 0 deletions application/reconciliation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def build_reconciliation_record(
"current_stock_weight": execution_summary.get("current_stock_weight"),
"current_safe_haven_weight": execution_summary.get("current_safe_haven_weight"),
"price_source_mode": execution_summary.get("price_source_mode"),
"quote_snapshot": execution_summary.get("quote_snapshot") or {},
"snapshot_price_fallback_used": execution_summary.get("snapshot_price_fallback_used"),
"snapshot_price_fallback_count": execution_summary.get("snapshot_price_fallback_count"),
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols") or [],
Expand Down
12 changes: 9 additions & 3 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ class IBKRRuntimeComposer:
runtime_target: RuntimeTarget | None = None
extra_reporting_fields: Mapping[str, Any] = field(default_factory=dict)

def build_notification_adapters(self):
def build_notification_adapters(self, *, delivery_events: list[dict[str, Any]] | None = None):
return self.notification_builder(
send_message=self.send_message,
log_message=lambda message: self.printer(message, flush=True),
delivery_events=delivery_events,
)

def build_reporting_adapters(self):
Expand Down Expand Up @@ -124,8 +125,13 @@ def build_reporting_adapters(self):
printer=lambda line: self.printer(line, flush=True),
)

def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False):
notification_adapters = self.build_notification_adapters()
def build_rebalance_runtime(
self,
*,
silent_cycle_notifications: bool = False,
notification_delivery_events: list[dict[str, Any]] | None = None,
):
notification_adapters = self.build_notification_adapters(delivery_events=notification_delivery_events)
notifications = (
CallableNotificationPort(lambda _message: None)
if silent_cycle_notifications
Expand Down
23 changes: 21 additions & 2 deletions application/runtime_notification_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any

from notifications.events import NotificationPublisher, RenderedNotification
from quant_platform_kit.common.port_adapters import CallableNotificationPort
Expand All @@ -13,6 +15,7 @@
class IBKRNotificationAdapters:
notification_port: NotificationPort
cycle_publisher: NotificationPublisher
delivery_events: list[dict[str, Any]]

def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> None:
self.cycle_publisher.publish(
Expand All @@ -27,11 +30,27 @@ def build_runtime_notification_adapters(
*,
send_message,
log_message=None,
delivery_events: list[dict[str, Any]] | None = None,
) -> IBKRNotificationAdapters:
recorded_delivery_events = delivery_events if delivery_events is not None else []

def send_recorded_message(message: str) -> None:
send_message(message)
compact = str(message or "")
recorded_delivery_events.append(
{
"sink": "telegram",
"delivery_status": "sent",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid marking failed Telegram delivery as sent

In environments where Telegram is not configured or the API returns a non-2xx response, send_tg_message/send_telegram_message returns without raising (it only logs failures), but this wrapper still appends a delivery_status: "sent" event. That makes the new dry-run evidence report claim notification delivery succeeded even when no Telegram message was actually delivered; the recorder needs an explicit success signal or should not record sent for silent/no-op failures.

Useful? React with 👍 / 👎.

"compact_text_sha256": hashlib.sha256(compact.encode("utf-8")).hexdigest(),
"compact_text_length": len(compact),
}
)

return IBKRNotificationAdapters(
notification_port=CallableNotificationPort(send_message),
notification_port=CallableNotificationPort(send_recorded_message),
cycle_publisher=NotificationPublisher(
log_message=log_message or (lambda message: print(message, flush=True)),
send_message=send_message,
send_message=send_recorded_message,
),
delivery_events=recorded_delivery_events,
)
137 changes: 109 additions & 28 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,78 @@ def _has_signal_snapshot_details(snapshot: dict[str, object]) -> bool:
)


def _count_orders(*candidates) -> int:
for candidate in candidates:
if isinstance(candidate, (list, tuple)):
return len(candidate)
return 0


def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, *, dry_run: bool) -> dict:
orders_submitted_count = _count_orders(
execution_summary.get("orders_submitted"),
reconciliation_record.get("orders_submitted"),
)
orders_skipped_count = _count_orders(
execution_summary.get("orders_skipped"),
reconciliation_record.get("orders_skipped"),
)
orders_previewed_count = orders_submitted_count if dry_run else 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count dry-run option previews in the report

When a dry run contains only option intents, execute_rebalance records those previews in execution_summary["option_orders_submitted"] and even treats them as an executed cycle, but orders_submitted remains empty. Deriving orders_previewed_count only from stock orders_submitted_count makes orders_previewed_count zero and dry_run_order_preview_available false for real option order previews, so the machine-readable dry-run report loses evidence for option-only plans.

Useful? React with 👍 / 👎.

summary = {
"result": cycle_result.result,
"execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"),
"no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"),
"orders_submitted_count": orders_submitted_count,
"orders_previewed_count": orders_previewed_count,
"orders_skipped_count": orders_skipped_count,
"dry_run_order_preview_available": bool(dry_run and orders_previewed_count > 0),
"snapshot_price_fallback_used": bool(
execution_summary.get("snapshot_price_fallback_used")
or reconciliation_record.get("snapshot_price_fallback_used")
),
"snapshot_price_fallback_count": int(
execution_summary.get("snapshot_price_fallback_count")
or reconciliation_record.get("snapshot_price_fallback_count")
or 0
),
}
quote_snapshot = execution_summary.get("quote_snapshot") or reconciliation_record.get("quote_snapshot")
if quote_snapshot:
summary["quote_snapshot"] = quote_snapshot
return summary


def _build_notification_delivery_log_for_report(
*,
platform: str,
strategy_profile: str,
run_id: str,
dry_run: bool,
orders_previewed_count: int,
delivery_events: list[dict],
) -> dict:
events = [dict(event) for event in delivery_events if dict(event).get("delivery_status") == "sent"]
if not dry_run or orders_previewed_count <= 0 or not events:
return {}
return {
"notification_schema_version": "hk_live_enablement_notification.v1",
"notification_event_type": "hk_snapshot_live_enablement_dry_run",
"notification_correlation_id": str(run_id or ""),
"locales": ["en", "zh-Hans"],
"profile": str(strategy_profile or ""),
"platform": str(platform or ""),
"validation_status": "passed",
"orders_previewed": int(orders_previewed_count),
"delivery_events": events,
"notification_contains_profile": True,
"notification_contains_platform": True,
"notification_contains_validation_status": True,
"notification_contains_order_preview_summary": True,
"notification_redacts_sensitive_fields": True,
"redaction_policy": "raw notification text is not persisted; only sha256 and length are recorded",
}


def publish_strategy_plugin_alerts(signals, *, report=None):
result = dispatch_strategy_plugin_alerts(
signals,
Expand Down Expand Up @@ -813,17 +885,31 @@ def run_paper_liquidation_cycle():
)


def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool | None = None):
def run_strategy_core(
*,
strategy_plugin_signals=(),
dry_run_only_override: bool | None = None,
notification_delivery_events: list[dict] | None = None,
):
if PAPER_LIQUIDATE_ONLY and dry_run_only_override is None:
return run_paper_liquidation_cycle()
composer = build_composer(
dry_run_only_override=dry_run_only_override,
strategy_plugin_signals=strategy_plugin_signals,
)
return run_rebalance_cycle(
runtime=composer.build_rebalance_runtime(
try:
rebalance_runtime = composer.build_rebalance_runtime(
silent_cycle_notifications=bool(dry_run_only_override),
),
notification_delivery_events=notification_delivery_events,
)
except TypeError as exc:
if "notification_delivery_events" not in str(exc):
raise
rebalance_runtime = composer.build_rebalance_runtime(
silent_cycle_notifications=bool(dry_run_only_override),
)
return run_rebalance_cycle(
runtime=rebalance_runtime,
config=composer.build_rebalance_config(
extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals),
),
Expand Down Expand Up @@ -894,10 +980,12 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
)
if dry_run_only_override is None:
publish_strategy_plugin_alerts(strategy_plugin_signals, report=report)
notification_delivery_events: list[dict] = []
cycle_result = coerce_strategy_cycle_result(
run_strategy_core(
strategy_plugin_signals=strategy_plugin_signals,
dry_run_only_override=dry_run_only_override,
notification_delivery_events=notification_delivery_events,
)
)
execution_summary = dict(cycle_result.execution_summary or {})
Expand Down Expand Up @@ -931,33 +1019,26 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
execution_window="precheck" if dry_run_only_override else "execution",
**signal_snapshot,
)
report_summary = _build_cycle_report_summary(
cycle_result,
execution_summary,
reconciliation_record,
dry_run=bool(report.get("dry_run")),
)
notification_delivery_log = _build_notification_delivery_log_for_report(
platform="interactive_brokers",
strategy_profile=STRATEGY_PROFILE,
run_id=str(report.get("run_id") or ""),
dry_run=bool(report.get("dry_run")),
orders_previewed_count=int(report_summary.get("orders_previewed_count") or 0),
delivery_events=notification_delivery_events,
)
if notification_delivery_log:
report_summary["notification_delivery_log"] = notification_delivery_log
finalize_runtime_report(
report,
status="ok",
summary={
"result": cycle_result.result,
"execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"),
"no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"),
"orders_submitted_count": len(
execution_summary.get("orders_submitted")
or reconciliation_record.get("orders_submitted")
or ()
),
"orders_skipped_count": len(
execution_summary.get("orders_skipped")
or reconciliation_record.get("orders_skipped")
or ()
),
"snapshot_price_fallback_used": bool(
execution_summary.get("snapshot_price_fallback_used")
or reconciliation_record.get("snapshot_price_fallback_used")
),
"snapshot_price_fallback_count": int(
execution_summary.get("snapshot_price_fallback_count")
or reconciliation_record.get("snapshot_price_fallback_count")
or 0
),
},
summary=report_summary,
diagnostics={
"result": cycle_result.result,
"price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"),
Expand Down
1 change: 1 addition & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ def accountValues(self):
assert {order["symbol"] for order in summary["orders_submitted"]} == {"02834", "03110"}
assert all(order["status"] == "dry_run" for order in summary["orders_submitted"])
assert all(float(order["quantity"]).is_integer() for order in summary["orders_submitted"])
assert {quote["symbol"] for quote in summary["quote_snapshot"]["quotes"]} == {"02834", "03110"}
assert any(log.startswith("DRY_RUN buy 02834") for log in trade_logs)
assert any(log.startswith("DRY_RUN buy 03110") for log in trade_logs)

Expand Down
Loading