From 9ff2e2e8c0c4a7a6dc4074842dfed7f8b3697087 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:25:36 +0800 Subject: [PATCH 1/8] refactor: split platform entry into route modules Keep main as an explicit named re-export surface for tests and process entry, with routes and wiring moved into dedicated modules. Co-authored-by: Cursor --- http_routes.py | 609 +++++++++ main.py | 2460 +++++++---------------------------- notifications_runtime.py | 247 ++++ runtime_wiring.py | 579 +++++++++ strategy_plugins_runtime.py | 181 +++ 5 files changed, 2055 insertions(+), 2021 deletions(-) create mode 100644 http_routes.py create mode 100644 notifications_runtime.py create mode 100644 runtime_wiring.py create mode 100644 strategy_plugins_runtime.py diff --git a/http_routes.py b/http_routes.py new file mode 100644 index 00000000..3c43ddfe --- /dev/null +++ b/http_routes.py @@ -0,0 +1,609 @@ +"""Flask application, cycle runners, and HTTP route handlers.""" + +from __future__ import annotations + + +import sys + +_FALLBACK_MODULE_NAME = __name__ + + +class _MainModuleProxy: + """Prefer ``main.`` after re-exports; fall back during module import.""" + + def __getattr__(self, name): + main_mod = sys.modules.get("main") + if main_mod is not None and name in getattr(main_mod, "__dict__", {}): + return main_mod.__dict__[name] + return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) + + +main = _MainModuleProxy() + +import hashlib +import json +import importlib +import os +import re +import threading +import time +from collections.abc import Mapping +from dataclasses import replace +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pandas as pd +import requests +from flask import Flask, request + +try: + from quant_platform_kit.cloud import get_compute_discovery +except ImportError: + get_compute_discovery = None + +from application.cycle_result import coerce_strategy_cycle_result +from application.execution_receipt_adapter import ( + attach_cycle_execution_receipt, + attach_terminal_fallback_execution_receipt, +) +from application.broker_reconciliation import ( + IBKRReconciliationReadError, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, +) +from application.reconciliation_reporting import ( + build_persistable_reconciliation_report, + normalize_reconciliation_request_id, + normalize_reconciliation_scheduler_job_sha256, +) +from application.runtime_broker_adapters import ( + IBKRGatewayUnavailableError, + IBKRTradingPermissionError, + build_runtime_broker_adapters, +) +from application.runtime_composer import build_runtime_composer +from application.runtime_deadline import ( + RuntimeDeadlineExceeded, + classify_runtime_deadline_qualification_caller, + classify_runtime_deadline_stage, + enforce_runtime_deadline, + recycle_current_process_after_response, +) +from application import signal_provenance +from application.runtime_strategy_adapters import ( + build_runtime_strategy_adapters, + fetch_yfinance_historical_candles, +) +from application.rebalance_service import run_strategy_core as run_rebalance_cycle +from application.signal_snapshot import build_signal_snapshot +from decision_mapper import map_strategy_decision +from entrypoints.cloud_run import is_market_open_now +from runtime_execution_policy import ( + IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON, + dca_execution_unsupported_reason, +) +from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message +from notifications.unified_adapter import with_run as with_notification_run +from quant_platform_kit.notifications.strategy_plugin_alerts import ( + StrategyPluginAlertStateSettings, + build_strategy_plugin_alert_context_label as build_alert_context_label, + publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, +) +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.execution_commands import build_execution_command_store_from_env +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt +from quant_platform_kit.common.runtime_reports import ( + append_runtime_report_error, + build_runtime_report_base, + finalize_runtime_report, + persist_runtime_report, +) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) +from quant_platform_kit.ibkr import ( + connect_ib as ibkr_connect_ib, + ensure_event_loop as ibkr_ensure_event_loop, + fetch_historical_price_candles, + fetch_historical_price_series, + fetch_quote_snapshots as qpk_fetch_quote_snapshots, +) +from application.ibkr_market_data import fetch_quote_snapshots_with_expected_error_summary +from application.ibkr_order_execution import probe_order_write_access, submit_order_intent +from application.monitor_dispatcher import ( + dispatch_due_monitor_targets, + load_monitor_targets, + lookback_minutes_from_env, + max_workers_from_env, + timeout_seconds_from_env, +) +from application.ibkr_portfolio import ( + IBKRPortfolioSnapshotUnavailableError, + fetch_reconciled_paper_portfolio_snapshot, + fetch_portfolio_snapshot, +) +from application.execution_service import ( + _SettledCashDeadlineIB, + check_order_submitted as application_check_order_submitted, + execute_rebalance as application_execute_rebalance, + get_market_prices as application_get_market_prices, +) +from application.paper_liquidation_service import execute_paper_liquidation +from application.paper_execution_admission import resolve_paper_execution_admission_enabled +from application.paper_execution_command_consumer import ( + consume_due_paper_execution_commands, + resolve_paper_execution_command_consumer_enabled, +) +from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace +from runtime_config_support import ( + EXECUTION_BACKEND_GATEWAY, + load_platform_runtime_settings, + resolve_ib_gateway_ip_mode, +) +from strategy_runtime import load_strategy_runtime +from application.http_routes import ( + _dispatch_local_monitor, + _handle_monitor_dispatch, + _handle_probe, + _handle_reconciliation, + _handle_live_permission_probe, + health_impl, +) + +app = Flask(__name__) +ensure_event_loop = ibkr_ensure_event_loop +NEW_YORK_TZ = ZoneInfo('America/New_York') +STRATEGY_RUN_LOCK = threading.Lock() + +def _is_execution_failure(execution_status, no_op_reason) -> bool: + status = str(execution_status or '').strip().lower() + if status in {'error', 'failed', 'failure'}: + return True + if status != 'blocked': + return False + reason = str(no_op_reason or '').strip().lower() + return not reason.startswith(main.EXPECTED_EXECUTION_GUARD_REASON_PREFIXES) + +def log_runtime_event(log_context, event, **fields): + return main.build_composer().build_reporting_adapters().log_event(log_context, event, **fields) + +def build_execution_report(log_context, *, dry_run_only_override: bool | None=None): + return main.build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().build_report(log_context) + +def persist_execution_report(report, *, dry_run_only_override: bool | None=None): + adapters = main.build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters() + diagnostics = report.get('diagnostics') + if isinstance(diagnostics, main.Mapping) and diagnostics.get('maintenance_cash_observation') is True: + prefix = adapters.report_gcs_prefix_uri + if not prefix: + raise RuntimeError('maintenance observation requires the existing private report destination') + adapters = main.replace(adapters, report_base_dir=main.os.path.join(adapters.report_base_dir, 'maintenance-cash') if adapters.report_base_dir else None, report_gcs_prefix_uri=prefix.rstrip('/') + '/maintenance-cash') + return adapters.persist_execution_report(report) + +def persist_reconciliation_report(report): + safe_report = main.build_persistable_reconciliation_report(report) + diagnostics = report.get('diagnostics') + initial = diagnostics.get('initial_enrollment_private') if isinstance(diagnostics, main.Mapping) else None + if initial is not None: + adapters = main.build_composer(dry_run_only_override=True).build_reporting_adapters() + if not adapters.report_gcs_prefix_uri: + raise RuntimeError('initial enrollment requires the existing private report destination') + if not isinstance(initial, main.Mapping) or set(initial) != {'facts', 'facts_sha256'}: + raise RuntimeError('initial enrollment private facts are invalid') + from urllib.parse import urlsplit + from google.cloud import storage + prefix = urlsplit(adapters.report_gcs_prefix_uri) + request_id = safe_report['diagnostics'].get('reconciliation_request_id') + if prefix.scheme != 'gs' or not prefix.netloc or (not prefix.path.strip('/')) or prefix.query or prefix.fragment or (not request_id): + raise RuntimeError('initial enrollment private destination or request is invalid') + safe_report['diagnostics']['initial_enrollment_private'] = dict(initial) + name = prefix.path.strip('/') + '/initial-enrollment/' + request_id + '.json' + blob = storage.Client().bucket(prefix.netloc).blob(name) + blob.upload_from_string(main.json.dumps(safe_report, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False), content_type='application/json', if_generation_match=0, retry=None) + generation = str(blob.generation or '') + if not generation.isascii() or not generation.isdigit() or int(generation) <= 0: + raise RuntimeError('initial enrollment private generation is unknown') + return f'gs://{prefix.netloc}/{name}#{generation}' + return main.persist_execution_report(safe_report, dry_run_only_override=True) + +def build_request_log_context(): + return main.build_composer().build_reporting_adapters().build_log_context(trace_header=main.request.headers.get('X-Cloud-Trace-Context')) + +def _previous_market_session(run_date: main.date) -> pd.Timestamp: + import pandas_market_calendars as market_calendars + sessions = market_calendars.get_calendar(main.MARKET_CALENDAR).valid_days(start_date=run_date - main.timedelta(days=14), end_date=run_date - main.timedelta(days=1)) + if len(sessions) == 0: + raise RuntimeError('IBKR previous completed market session is unavailable') + return main.pd.Timestamp(sessions[-1]).tz_localize(None).normalize() + +def compute_signals(ib, current_holdings, *, strategy_plugin_signals=()): + return main.build_strategy_adapters().compute_signals(ib, current_holdings, strategy_plugin_signals=strategy_plugin_signals) + +def _has_signal_snapshot_details(snapshot: dict[str, object]) -> bool: + return any((snapshot.get(field_name) for field_name in ('signal_as_of', 'market_date', 'latest_price_source', 'target_weights', 'target_values', 'indicators', 'signal', 'status'))) + +def _count_orders(*candidates) -> int: + for candidate in candidates: + if isinstance(candidate, (list, tuple)): + return len(candidate) + return 0 + +def _account_facts_match_runtime_target(account_facts: main.Mapping[str, object]) -> bool: + """Only report physical account facts bound by the configured selector.""" + target = getattr(main.RUNTIME_SETTINGS, 'runtime_target', None) + selectors = getattr(target, 'account_selector', ()) if target is not None else () + account_ids = account_facts.get('account_ids') if isinstance(account_facts, main.Mapping) else None + if isinstance(selectors, str): + selectors = (selectors,) + if isinstance(account_ids, str): + account_ids = (account_ids,) + normalized_selectors = tuple((str(value or '').strip() for value in selectors or () if str(value or '').strip())) + normalized_account_ids = tuple((str(value or '').strip() for value in account_ids or () if str(value or '').strip())) + return len(normalized_selectors) == 1 and normalized_selectors[0].lower() != 'default' and (len(normalized_account_ids) == 1) and (normalized_account_ids[0] == normalized_selectors[0]) + +def get_current_portfolio(ib): + return main.build_broker_adapters().get_current_portfolio(ib) + +def build_portfolio_snapshot(ib): + return main.build_broker_adapters().build_portfolio_snapshot(ib, get_current_portfolio_fallback=main.get_current_portfolio) + +def _paper_command_consumer_session_date() -> str: + return main.datetime.now(main.ZoneInfo(main.MARKET_TIMEZONE)).date().isoformat() + +def _paper_command_consumer_runtime_is_isolated() -> bool: + """Require a disabled, paper-only runtime before any Gateway read occurs.""" + runtime_target = getattr(main.RUNTIME_SETTINGS, 'runtime_target', None) + return bool(runtime_target is not None and bool(getattr(main.RUNTIME_SETTINGS, 'dry_run_only', False)) and (not bool(getattr(main.RUNTIME_SETTINGS, 'runtime_target_enabled', True))) and (str(getattr(main.RUNTIME_SETTINGS, 'ib_gateway_mode', '') or '').strip().lower() == 'paper') and (str(getattr(runtime_target, 'execution_mode', '') or '').strip().lower() == 'paper') and bool(main.CASH_ONLY_EXECUTION)) + +def run_paper_execution_command_consumer() -> dict[str, object]: + """Explicitly reconcile due commands without creating a broker order. + + This isolated route deliberately avoids the normal rebalance and execution + adapters. It opens the Gateway only after command release and delivery + binding checks pass, then reads portfolio and quote evidence to simulate + what would have been submitted. + """ + if not main._paper_command_consumer_runtime_is_isolated(): + raise RuntimeError('paper command consumer requires disabled runtime target, IBKR_DRY_RUN_ONLY=true, paper Gateway mode, paper runtime target, and CASH_ONLY_EXECUTION=true') + if not main.resolve_paper_execution_command_consumer_enabled(env_reader=main.os.getenv, dry_run_only=bool(main.RUNTIME_SETTINGS.dry_run_only)): + raise RuntimeError('paper command consumer is not enabled') + runtime_target = main.RUNTIME_SETTINGS.runtime_target + expected_release = getattr(runtime_target, 'strategy_release', None) + expected_binding = {'platform': 'ibkr', 'account_scope': str(getattr(runtime_target, 'account_scope', '') or 'unknown'), 'strategy_profile': str(getattr(runtime_target, 'strategy_profile', '') or 'unknown')} + store = main.build_execution_command_store_from_env(platform_env_prefix='IBKR', env_reader=main.os.getenv, project_id=main.PROJECT_ID) + if not store.cloud_prefix_uri and (not store.local_dir): + raise RuntimeError('IBKR paper command consumer requires an execution command store') + reporting_adapters = main.build_composer(dry_run_only_override=True).build_reporting_adapters() + log_context = reporting_adapters.build_log_context(trace_header=main.request.headers.get('X-Cloud-Trace-Context')) + report = reporting_adapters.build_report(log_context) + ib = None + quote_cache: dict[str, object] = {} + + def _ib(): + nonlocal ib + if ib is None: + ib = main.build_broker_adapters(dry_run_only_override=True).connect_ib() + return ib + + def _load_portfolio(): + return main.fetch_reconciled_paper_portfolio_snapshot(_ib(), account_ids=main.ACCOUNT_IDS, currency=main.MARKET_CURRENCY) + + def _load_quote(symbol: str): + normalized = str(symbol or '').strip().upper() + if normalized not in quote_cache: + quotes = main.fetch_market_quote_snapshots(_ib(), (normalized,)) + quote = quotes.get(normalized) + if quote is None: + raise main.IBKRPortfolioSnapshotUnavailableError('IBKR paper command reconciliation is missing a current quote.') + quote_cache[normalized] = quote + return quote_cache[normalized] + try: + reporting_adapters.log_event(log_context, 'paper_execution_command_consumer_started', message='Starting isolated read-only IBKR paper command consumer') + result = main.consume_due_paper_execution_commands(store=store, as_of_session=main._paper_command_consumer_session_date(), claimant=str(main.os.getenv('K_SERVICE') or 'ibkr-paper-command-consumer'), portfolio_loader=_load_portfolio, quote_loader=_load_quote, managed_symbols=main.resolve_reporting_managed_symbols(), runtime_release_receipt=main.build_runtime_loaded_receipt(strategy_release=expected_release), expected_strategy_release=expected_release, expected_command_binding=expected_binding) + main.finalize_runtime_report(report, status='ok' if result.get('status') == 'ok' else 'skipped', summary={'paper_execution_command_consumer': result}) + reporting_adapters.log_event(log_context, 'paper_execution_command_consumer_completed', message='IBKR paper command consumer completed', result_status=result.get('status'), commands_count=len(tuple(result.get('commands') or ()))) + return result + except Exception as exc: + main.append_runtime_report_error(report, stage='paper_execution_command_consumer', message=str(exc), error_type=type(exc).__name__) + main.finalize_runtime_report(report, status='error') + reporting_adapters.log_event(log_context, 'paper_execution_command_consumer_failed', message='IBKR paper command consumer failed', severity='ERROR', error_type=type(exc).__name__) + raise + finally: + try: + report_path = reporting_adapters.persist_execution_report(report) + print(f'execution_report {report_path}', flush=True) + except Exception as persist_exc: + print(f'failed to persist execution report: {persist_exc}', flush=True) + disconnect_fn = getattr(ib, 'disconnect', None) + if callable(disconnect_fn): + disconnect_fn() + +def get_market_prices(ib, symbols): + return main.build_broker_adapters().get_market_prices(ib, symbols) + +def check_order_submitted(report): + return main.build_broker_adapters().check_order_submitted(report) + +def execute_rebalance(ib, target_weights, positions, account_values, *, strategy_symbols=None, signal_metadata=None, acquire_execution_claim=None, account_execution_session=None, dry_run_only_override: bool | None=None): + return main.build_broker_adapters(dry_run_only_override=dry_run_only_override).execute_rebalance(ib, target_weights, positions, account_values, strategy_symbols=strategy_symbols, signal_metadata=signal_metadata, acquire_execution_claim=acquire_execution_claim, account_execution_session=account_execution_session) + +def _format_liquidation_orders(orders) -> str: + return main.build_broker_adapters().format_liquidation_orders(orders) + +def run_paper_liquidation_cycle(): + main.require_gateway_execution_backend() + if main.RUNTIME_SETTINGS.ib_gateway_mode != 'paper': + raise RuntimeError('IBKR_PAPER_LIQUIDATE_ONLY is only allowed when ib_gateway_mode=paper') + return main.build_broker_adapters().run_paper_liquidation_cycle(connect_ib_fn=main.connect_ib, get_current_portfolio_fn=main.get_current_portfolio, publish_notification_fn=main.publish_notification) + +def run_strategy_core(*, strategy_plugin_signals=(), strategy_plugin_error: str | None=None, dry_run_only_override: bool | None=None, notification_delivery_events: list[dict] | None=None): + if main.PAPER_LIQUIDATE_ONLY and dry_run_only_override is None: + return main.run_paper_liquidation_cycle() + composer = main.build_composer(dry_run_only_override=dry_run_only_override, strategy_plugin_signals=strategy_plugin_signals) + 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 main.run_rebalance_cycle(runtime=rebalance_runtime, config=composer.build_rebalance_config(extra_notification_lines=main.build_extra_notification_lines(strategy_plugin_signals, strategy_plugin_error=strategy_plugin_error), cash_only_execution=main.CASH_ONLY_EXECUTION, notification_attention_reason_codes=('strategy_plugin_error',) if strategy_plugin_error else ())) + +def attach_signal_data_provenance(report) -> None: + """Attach this run's non-sensitive signal data provenance; never raises.""" + try: + provenance = main.signal_provenance.snapshot() + if provenance and isinstance(report, dict): + diagnostics = report.get('diagnostics') + if not isinstance(diagnostics, dict): + diagnostics = {} + report['diagnostics'] = diagnostics + diagnostics['signal_data_provenance'] = provenance + except Exception: + return + +def _handle_request(*, dry_run_only_override: bool | None=None, response_body: str='OK', dry_run_label: str='strategy dry-run', persist_report: bool=True, report_collector: list[dict] | None=None): + if main.request.method == 'GET': + if dry_run_only_override is None: + return ('OK - use POST to execute strategy', 200) + return (f'{response_body} - use POST to run {dry_run_label}', 200) + if dry_run_only_override is None and (not getattr(main.RUNTIME_SETTINGS, 'runtime_target_enabled', True)): + return ('Runtime Target Disabled', 200) + log_context = main.build_request_log_context() + report = main.build_execution_report(log_context, dry_run_only_override=dry_run_only_override) + main.signal_provenance.reset() + if report_collector is not None: + report_collector.append(report) + execution_window = 'dry_run' if dry_run_only_override else 'execution' + lock_acquired = False + deadline_exceeded = False + try: + strategy_plugin_signals, strategy_plugin_error = main.load_strategy_plugin_signals() + main.attach_strategy_plugin_report(report, signals=strategy_plugin_signals, error=strategy_plugin_error) + if not strategy_plugin_error: + from notifications.unified_adapter import routine_observation_counts_from_signals + report.setdefault('summary', {})['routine_observation_counts'] = routine_observation_counts_from_signals(strategy_plugin_signals) + lock_acquired = main.STRATEGY_RUN_LOCK.acquire(blocking=False) + main.log_runtime_event(log_context, 'strategy_cycle_received', message='Received strategy dry-run request' if dry_run_only_override else 'Received strategy execution request', http_method=main.request.method, execution_window=execution_window) + if not lock_acquired: + main.log_runtime_event(log_context, 'strategy_cycle_already_running', message='Another strategy execution is already running; skip overlapping request', severity='WARNING') + main.finalize_runtime_report(report, status='skipped', diagnostics={'skip_reason': 'already_running'}) + return ('Already Running', 200) + market_open = main.is_market_open_now(calendar_name=main.MARKET_CALENDAR, timezone_name=main.MARKET_TIMEZONE, logger=lambda message: print(message, flush=True)) + force_run = (main.os.getenv('IBKR_FORCE_RUN') or '').strip().lower() == 'true' + if not market_open and (not force_run): + main.log_runtime_event(log_context, 'market_closed', message='Market closed; skip strategy execution', execution_window=execution_window, market=main.MARKET, market_calendar=main.MARKET_CALENDAR, market_timezone=main.MARKET_TIMEZONE) + main.finalize_runtime_report(report, status='skipped', diagnostics={'skip_reason': 'market_closed'}) + return ('Market Closed', 200) + if force_run and (not market_open): + main.log_runtime_event(log_context, 'market_hours_bypassed', message='Market hours bypassed for strategy execution', execution_window=execution_window, market=main.MARKET, market_calendar=main.MARKET_CALENDAR, market_timezone=main.MARKET_TIMEZONE) + unsupported_reason = main.dca_execution_unsupported_reason(main.STRATEGY_PROFILE) + if unsupported_reason is not None: + unsupported_message = 'Strategy requires fractional-share execution; IBKR TWS API does not support fractional equity orders (use desktop TWS for manual DCA or choose another platform)' if unsupported_reason == main.IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON else 'Strategy requires fractional-share execution; skip' + main.log_runtime_event(log_context, 'strategy_execution_unsupported', message=unsupported_message, execution_window=execution_window, skip_reason=unsupported_reason, strategy_profile=main.STRATEGY_PROFILE, market=main.MARKET, market_calendar=main.MARKET_CALENDAR, market_timezone=main.MARKET_TIMEZONE) + main.finalize_runtime_report(report, status='skipped', diagnostics={'skip_reason': unsupported_reason}) + return ('Unsupported Strategy', 200) + main.log_runtime_event(log_context, 'strategy_cycle_started', message='Starting strategy dry-run' if dry_run_only_override else 'Starting strategy execution', execution_window=execution_window) + if dry_run_only_override is None: + main.publish_strategy_plugin_alerts(strategy_plugin_signals, report=report) + notification_delivery_events: list[dict] = [] + cycle_result = main.coerce_strategy_cycle_result(main.with_notification_run(report, main.run_strategy_core, strategy_plugin_signals=strategy_plugin_signals, strategy_plugin_error=strategy_plugin_error, dry_run_only_override=dry_run_only_override, notification_delivery_events=notification_delivery_events)) + execution_summary = dict(cycle_result.execution_summary or {}) + reconciliation_record = dict(cycle_result.reconciliation_record or {}) + signal_metadata = dict(cycle_result.signal_metadata or {}) + signal_snapshot = dict(signal_metadata.get('signal_snapshot') or {}) + if not signal_snapshot: + signal_snapshot = main.build_signal_snapshot(platform='ibkr', strategy_profile=signal_metadata.get('strategy_profile') or main.STRATEGY_PROFILE, metadata=signal_metadata, target_weights=cycle_result.target_weights) + if execution_summary.get('price_source_mode') or reconciliation_record.get('price_source_mode'): + signal_snapshot['latest_price_source'] = execution_summary.get('price_source_mode') or reconciliation_record.get('price_source_mode') + fallback_used = bool(execution_summary.get('snapshot_price_fallback_used') or reconciliation_record.get('snapshot_price_fallback_used')) + if fallback_used: + signal_snapshot['data_freshness_warning'] = 'snapshot_price_fallback_used' + has_signal_snapshot = main._has_signal_snapshot_details(signal_snapshot) + if has_signal_snapshot: + main.log_runtime_event(log_context, 'strategy_signal_snapshot', message='Strategy signal snapshot', execution_window=execution_window, **signal_snapshot) + from application.settlement_reporting import settlement_enabled + settlement_observation = None + if not report.get('dry_run') and settlement_enabled(): + from application.settlement_route import runtime_settlement_store + from application.order_settlement import settlement_summary + from application.settlement_reporting import publish_settlement_notices, settlement_candidate, settlement_receipt_summary + from notifications.unified_adapter import event_for, persist_candidate + import sys + settlement_store = runtime_settlement_store(sys.modules[__name__]) + settlement_observation = settlement_summary(settlement_store) + publish_settlement_notices(settlement_store, settlement_observation, send=main.send_tg_message, locale=main.NOTIFY_LANG, publish_candidate=lambda row: settlement_candidate(row, event_factory=event_for, persist=persist_candidate)) + report_summary = main._build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, dry_run=bool(report.get('dry_run'))) + if settlement_observation is not None: + current = [row for row in settlement_observation['orders_submitted'] if row['source_run_id'] == report.get('run_id')] + report_summary['settlement_outcomes'] = current + receipt_observation = {key: [row for row in value if row['source_run_id'] == report.get('run_id')] for key, value in settlement_observation.items() if isinstance(value, list)} + receipt_summary = settlement_receipt_summary(execution_summary, receipt_observation) + else: + receipt_summary = execution_summary + notification_delivery_log = main._build_notification_delivery_log_for_report(platform='interactive_brokers', strategy_profile=main.STRATEGY_PROFILE, run_id=str(report.get('run_id') or ''), dry_run=bool(report.get('dry_run')), orders_previewed_count=report_summary.get('orders_previewed_count'), delivery_events=notification_delivery_events) + if notification_delivery_log: + report_summary['notification_delivery_log'] = notification_delivery_log + notification_delivery_summary = main._build_notification_delivery_summary(notification_delivery_events) + if notification_delivery_summary: + report_summary['notification_delivery_summary'] = notification_delivery_summary + execution_status = str(report_summary.get('execution_status') or '').strip().lower() + execution_failed = main._is_execution_failure(execution_status, report_summary.get('no_op_reason')) + if execution_failed: + main.append_runtime_report_error(report, stage='strategy_execution', message=f"Strategy execution status is {execution_status}; reason={report_summary.get('no_op_reason') or 'unspecified'}", error_type='StrategyExecutionBlocked', failure_category='strategy_execution_blocked') + report_diagnostics = {'result': cycle_result.result, 'price_source_mode': execution_summary.get('price_source_mode') or reconciliation_record.get('price_source_mode'), 'snapshot_price_fallback_symbols': execution_summary.get('snapshot_price_fallback_symbols') or reconciliation_record.get('snapshot_price_fallback_symbols') or [], **({'signal_snapshot': signal_snapshot} if has_signal_snapshot else {})} + if execution_failed: + report_diagnostics['failure_category'] = 'strategy_execution_blocked' + main.attach_cycle_execution_receipt(report, receipt_summary, reconciliation_record, execution_failed=execution_failed) + main.finalize_runtime_report(report, status='error' if execution_failed else 'ok', summary=report_summary, diagnostics=report_diagnostics, artifacts={'reconciliation_record_path': cycle_result.reconciliation_record_path}) + main.log_runtime_event(log_context, 'strategy_cycle_blocked' if execution_failed else 'strategy_cycle_completed', message='Strategy execution blocked' if execution_failed else 'Strategy dry-run completed' if dry_run_only_override else 'Strategy execution completed', severity='ERROR' if execution_failed else 'INFO', execution_window=execution_window, result=cycle_result.result, execution_status=report_summary.get('execution_status'), no_op_reason=report_summary.get('no_op_reason')) + return (response_body if dry_run_only_override else cycle_result.result, 200) + except main.RuntimeDeadlineExceeded as exc: + deadline_exceeded = True + main.append_runtime_report_error(report, stage='strategy_deadline', message=str(exc), error_type=type(exc).__name__) + main.finalize_runtime_report(report, status='error', diagnostics={'failure_category': 'strategy_deadline', 'timeout_seconds': main.IBKR_DRY_RUN_DEADLINE_SECONDS}) + raise + except main.IBKRGatewayUnavailableError as exc: + main.append_runtime_report_error(report, stage='ibkr_connect', message=str(exc), error_type=type(exc).__name__, failure_category='ibkr_gateway_unavailable') + main.finalize_runtime_report(report, status='error', diagnostics={'failure_category': 'ibkr_gateway_unavailable'}) + main.log_runtime_event(log_context, 'ibkr_gateway_connect_failed', message='IBKR gateway connection attempts exhausted', severity='ERROR', error_type=type(exc).__name__, error_message=str(exc), failure_category='ibkr_gateway_unavailable') + error_msg = f'🚨 【IBKR 连接异常】\n{str(exc)}' + main._publish_runtime_failure_notification(detailed_text=error_msg, compact_text=error_msg, exc=exc) + return ('Error', 503) + except (main.IBKRTradingPermissionError, main.IBKRPortfolioSnapshotUnavailableError) as exc: + failure_category = 'ibkr_trading_permission' if isinstance(exc, main.IBKRTradingPermissionError) else 'ibkr_portfolio_snapshot' + main.append_runtime_report_error(report, stage=failure_category, message=str(exc), error_type=type(exc).__name__, failure_category=failure_category) + main.finalize_runtime_report(report, status='error', diagnostics={'failure_category': failure_category}) + main.log_runtime_event(log_context, f'{failure_category}_failed', message='IBKR execution readiness validation failed', severity='ERROR', error_type=type(exc).__name__, error_message=str(exc), failure_category=failure_category) + error_msg = f'🚨 【IBKR 执行条件异常】\n{str(exc)}' + main._publish_runtime_failure_notification(detailed_text=error_msg, compact_text=error_msg, exc=exc) + return ('Error', 503) + except Exception as exc: + main.append_runtime_report_error(report, stage='strategy_cycle', message='strategy_cycle_failed', error_type=type(exc).__name__) + main.finalize_runtime_report(report, status='error') + main.log_runtime_event(log_context, 'strategy_cycle_failed', message=main.t('runtime_failure_log'), severity='ERROR', error_type=type(exc).__name__, error_message='strategy_cycle_failed') + error_msg = main._runtime_error_notification_message(exc) + main._publish_runtime_failure_notification(detailed_text=error_msg, compact_text=error_msg, exc=exc) + return ('Error', 500) + finally: + if lock_acquired: + main.STRATEGY_RUN_LOCK.release() + main.attach_signal_data_provenance(report) + if 'execution_receipt' not in report: + try: + main.attach_terminal_fallback_execution_receipt(report) + except ValueError: + pass + if not deadline_exceeded and persist_report: + try: + if dry_run_only_override is None: + report_path = main.persist_execution_report(report) + else: + report_path = main.persist_execution_report(report, dry_run_only_override=dry_run_only_override) + print(f'execution_report {report_path}', flush=True) + from application.runtime_reporting_adapters import confirmed_gcs_report_uri + if not confirmed_gcs_report_uri(report_path): + raise RuntimeError('durable_execution_report_unconfirmed') + except Exception as persist_exc: + print(f'failed to persist execution report: {persist_exc}', flush=True) + if report.get('status') != 'error': + from quant_platform_kit.common.operational_notification_localization import resolve_operational_notification_locale + zh = resolve_operational_notification_locale(main.NOTIFY_LANG) == 'zh' + message = '⚠️ 运行报告写入未确认;汇总观察可能缺失,请检查运行记录。' if zh else '⚠️ Runtime report write unconfirmed; digest observations may be missing. Check run records.' + try: + if not main.send_tg_message(message): + print('notification_report_coverage_alert_failed', flush=True) + except Exception: + print('notification_report_coverage_alert_failed', flush=True) + +@app.route('/run', methods=['POST']) +def handle_request(): + if main.request.method != 'POST': + return ('Method Not Allowed', 405) + return main._route_with_runtime_error_fallback(main._handle_request) + +def _handle_dry_run_with_deadline(*, recycle_on_timeout: bool=True, timeout_state: dict[str, bool] | None=None): + reports: list[dict] = [] + try: + with main.enforce_runtime_deadline(main.IBKR_DRY_RUN_DEADLINE_SECONDS, operation='IBKR strategy dry-run request'): + response = main._route_with_runtime_error_fallback(main._handle_request, dry_run_only_override=True, response_body='Dry Run OK', dry_run_label='strategy dry-run', persist_report=False, report_collector=reports) + except main.RuntimeDeadlineExceeded as exc: + timeout_stage = main.classify_runtime_deadline_stage(exc) + qualification_caller = main.classify_runtime_deadline_qualification_caller(exc) + if reports: + diagnostics = reports[-1].setdefault('diagnostics', {}) + if isinstance(diagnostics, dict): + diagnostics['timeout_stage'] = timeout_stage + diagnostics['timeout_qualification_caller'] = qualification_caller + main._persist_dry_run_report_with_grace(reports, timeout_context=True) + deadline_event = {'severity': 'ERROR', 'event': 'strategy_cycle_deadline_exceeded', 'message': 'IBKR strategy dry-run exceeded its application deadline', 'service_name': main.SERVICE_NAME or main.os.getenv('K_SERVICE'), 'strategy_profile': main.STRATEGY_PROFILE, 'timeout_seconds': main.IBKR_DRY_RUN_DEADLINE_SECONDS, 'timeout_stage': timeout_stage, 'timeout_qualification_caller': qualification_caller, 'worker_recycle_required': True, 'worker_recycle_scheduled': recycle_on_timeout} + if reports and reports[-1].get('run_id'): + deadline_event['run_id'] = reports[-1]['run_id'] + trace_id = main.request.headers.get('X-Cloud-Trace-Context', '').partition('/')[0] + if main.PROJECT_ID and main.re.fullmatch('[0-9a-f]{32}', trace_id): + deadline_event['logging.googleapis.com/trace'] = f'projects/{main.PROJECT_ID}/traces/{trace_id}' + print(main.json.dumps(deadline_event, ensure_ascii=False, separators=(',', ':')), flush=True) + if timeout_state is not None: + timeout_state['worker_recycle_required'] = True + if recycle_on_timeout: + main.recycle_current_process_after_response() + return ('Error', 503) + else: + main._persist_dry_run_report_with_grace(reports, timeout_context=False) + return response + +def _persist_dry_run_report_with_grace(reports: list[dict], *, timeout_context: bool) -> None: + if not reports: + return + operation = 'IBKR timeout report persistence' if timeout_context else 'IBKR dry-run report persistence' + try: + with main.enforce_runtime_deadline(main.IBKR_DEADLINE_REPORT_GRACE_SECONDS, operation=operation): + report_path = main.persist_execution_report(reports[-1], dry_run_only_override=True) + print(f'execution_report {report_path}', flush=True) + except main.RuntimeDeadlineExceeded as persist_exc: + print(f'dry-run report persistence timed out: {persist_exc}', flush=True) + except Exception as persist_exc: + print(f'failed to persist dry-run report: {persist_exc}', flush=True) + +@app.route('/dry-run', methods=['POST', 'GET']) +def handle_dry_run(): + return main._handle_dry_run_with_deadline() + +@app.route('/paper-command-consumer', methods=['POST']) +def handle_paper_execution_command_consumer(): + """Manual-only endpoint for paper command reconciliation evidence.""" + try: + result = main.run_paper_execution_command_consumer() + except Exception as exc: + return main._handle_route_runtime_error(exc, route_label='POST /paper-command-consumer') + return (main.json.dumps(result, ensure_ascii=False), 200, {'Content-Type': 'application/json'}) + +@app.route('/probe', methods=['POST']) +def handle_probe(): + if main.request.method != 'POST': + return ('Method Not Allowed', 405) + return main._route_with_runtime_error_fallback(main._handle_probe) + +@app.route('/settle', methods=['POST']) +def handle_order_settlement(): + from application.settlement_route import handle_settle + import sys + return handle_settle(sys.modules[__name__]) + +@app.route('/reconcile', methods=['POST']) +def handle_reconciliation(): + return main._route_with_runtime_error_fallback(main._handle_reconciliation, route_label='broker-reconciliation') + +@app.route('/live-permission-probe', methods=['POST']) +def handle_live_permission_probe(): + return main._handle_live_permission_probe() + +@app.route('/monitor-dispatch', methods=['POST', 'GET']) +def handle_monitor_dispatch(): + return main._route_with_runtime_error_fallback(main._handle_monitor_dispatch, route_label='monitor-dispatch') + +@app.route('/healthz', methods=['GET']) +@app.route('/health', methods=['GET']) +def health(): + return main.health_impl() diff --git a/main.py b/main.py index f03de3a5..9ada0862 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,17 @@ -"""IBKR strategy runner for shared us_equity strategy profiles.""" +"""Platform HTTP entrypoint with explicit named re-exports.""" + +from __future__ import annotations + +import sys + +for _split_module_name in ( + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + "http_routes", +): + sys.modules.pop(_split_module_name, None) +del _split_module_name import hashlib import json @@ -132,2035 +145,440 @@ health_impl, ) -app = Flask(__name__) -ensure_event_loop = ibkr_ensure_event_loop -NEW_YORK_TZ = ZoneInfo("America/New_York") -STRATEGY_RUN_LOCK = threading.Lock() -EXPECTED_EXECUTION_GUARD_REASON_PREFIXES = ( - "pending_orders_detected:", - "same_day_fills_detected:", - "same_day_execution_locked:", -) - - -def _is_execution_failure(execution_status, no_op_reason) -> bool: - status = str(execution_status or "").strip().lower() - if status in {"error", "failed", "failure"}: - return True - if status != "blocked": - return False - reason = str(no_op_reason or "").strip().lower() - return not reason.startswith(EXPECTED_EXECUTION_GUARD_REASON_PREFIXES) - - -def get_project_id(): - try: - from quant_platform_kit.cloud import get_deployment_context - - return get_deployment_context().project_id - except Exception: - return os.getenv("GOOGLE_CLOUD_PROJECT") - - -def get_ib_gateway_ip_mode(): - raw_value = os.getenv("IB_GATEWAY_IP_MODE") - if raw_value is None and "RUNTIME_SETTINGS" in globals(): - raw_value = RUNTIME_SETTINGS.ib_gateway_ip_mode - return resolve_ib_gateway_ip_mode( - raw_value, - logger=lambda message: print(message, flush=True), - ) - - -def resolve_gce_instance_ip(instance_name, zone): - gateway_project = getattr(RUNTIME_SETTINGS, "ib_gateway_project_id", None) - if not get_compute_discovery: - if gateway_project: - raise IBKRGatewayUnavailableError( - f"GCE Gateway discovery unavailable for configured project {gateway_project}" - ) - print(f"quant_platform_kit.cloud not installed, using {instance_name} as host directly", flush=True) - return instance_name - try: - ip_mode = get_ib_gateway_ip_mode() - project = gateway_project or get_project_id() - host = get_compute_discovery().resolve_instance_ip( - instance_name, - zone, - project_id=project, - prefer_internal=ip_mode == "internal", - ) - if gateway_project and (not host or host == instance_name): - raise ValueError("GCE Gateway discovery returned no address") - return host - except Exception as exc: - if gateway_project: - raise IBKRGatewayUnavailableError( - f"GCE Gateway address unavailable in configured project {gateway_project}" - ) from None - print(f"GCE resolve failed for {instance_name}: {exc}, using as hostname", flush=True) - return instance_name - - -def get_ib_host(): - global IB_HOST - if IB_HOST: - return IB_HOST - host = RUNTIME_SETTINGS.ib_gateway_instance_name - zone = RUNTIME_SETTINGS.ib_gateway_zone - if getattr(RUNTIME_SETTINGS, "ib_gateway_project_id", None) and not zone: - raise IBKRGatewayUnavailableError( - "GCE Gateway zone is required when a Gateway project is configured" - ) - if zone: - host = resolve_gce_instance_ip(host, zone) - IB_HOST = host - return host - - -def refresh_ib_host(): - """Refresh a zoned gateway address while retaining last-known-good state.""" - global IB_HOST - instance_name = RUNTIME_SETTINGS.ib_gateway_instance_name - zone = RUNTIME_SETTINGS.ib_gateway_zone - if not zone: - return get_ib_host() - - cached_host = IB_HOST - try: - resolved_host = resolve_gce_instance_ip(instance_name, zone) - except IBKRGatewayUnavailableError: - IB_HOST = None - raise - except Exception as exc: - print(f"GCE host refresh failed for {instance_name}: {exc}", flush=True) - resolved_host = cached_host or instance_name - if cached_host and resolved_host == instance_name: - resolved_host = cached_host - IB_HOST = resolved_host - return resolved_host - - -def get_ib_gateway_mode(): - return RUNTIME_SETTINGS.ib_gateway_mode - - -def get_ib_port(): - configured_port = getattr(RUNTIME_SETTINGS, "ib_gateway_port", None) - if configured_port is not None: - return configured_port - return 4002 if get_ib_gateway_mode() == "paper" else 4001 - - -def get_ib_connect_timeout_seconds(): - raw_value = os.getenv("IBKR_CONNECT_TIMEOUT_SECONDS", "60") - try: - timeout_seconds = int(raw_value) - except (TypeError, ValueError): - print(f"Invalid IBKR_CONNECT_TIMEOUT_SECONDS={raw_value!r}; using 60", flush=True) - return 60 - if timeout_seconds <= 0: - print(f"Invalid IBKR_CONNECT_TIMEOUT_SECONDS={raw_value!r}; using 60", flush=True) - return 60 - return timeout_seconds - - -def get_positive_int_env(name, default): - raw_value = os.getenv(name, str(default)) - try: - parsed = int(raw_value) - except (TypeError, ValueError): - print(f"Invalid {name}={raw_value!r}; using {default}", flush=True) - return default - if parsed <= 0: - print(f"Invalid {name}={raw_value!r}; using {default}", flush=True) - return default - return parsed - - -def get_non_negative_float_env(name, default): - raw_value = os.getenv(name, str(default)) - try: - parsed = float(raw_value) - except (TypeError, ValueError): - print(f"Invalid {name}={raw_value!r}; using {default}", flush=True) - return default - if parsed < 0: - print(f"Invalid {name}={raw_value!r}; using {default}", flush=True) - return default - return parsed - - -def _env_flag(name: str) -> bool: - return str(os.getenv(name) or "").strip().lower() in {"1", "true", "yes", "on"} - - -def _split_env_list(value: str | None) -> tuple[str, ...]: - return tuple( - item.strip() - for item in str(value or "").replace(";", ",").split(",") - if item.strip() - ) - - -RUNTIME_SETTINGS = load_platform_runtime_settings(project_id_resolver=get_project_id) -IB_HOST = None -IB_PORT = get_ib_port() -IB_CLIENT_ID = RUNTIME_SETTINGS.ib_client_id -IB_CONNECT_TIMEOUT_SECONDS = get_ib_connect_timeout_seconds() -IB_CONNECT_ATTEMPTS = get_positive_int_env("IBKR_CONNECT_ATTEMPTS", 3) -IB_CONNECT_RETRY_DELAY_SECONDS = get_non_negative_float_env("IBKR_CONNECT_RETRY_DELAY_SECONDS", 5.0) -IB_CLIENT_ID_RETRY_OFFSET = get_positive_int_env("IBKR_CLIENT_ID_RETRY_OFFSET", 100) -IBKR_DRY_RUN_DEADLINE_SECONDS = get_positive_int_env("IBKR_DRY_RUN_DEADLINE_SECONDS", 105) -IBKR_DEADLINE_REPORT_GRACE_SECONDS = get_positive_int_env("IBKR_DEADLINE_REPORT_GRACE_SECONDS", 5) -STRATEGY_PROFILE = RUNTIME_SETTINGS.strategy_profile -STRATEGY_DISPLAY_NAME = RUNTIME_SETTINGS.strategy_display_name - - -def _normalize_plugin_mounts_strategy(raw_mounts_json: str | None) -> str | None: - if not raw_mounts_json: - return raw_mounts_json - try: - mounts = json.loads(raw_mounts_json) - except json.JSONDecodeError: - return raw_mounts_json - if not isinstance(mounts, dict): - return raw_mounts_json - plugins = mounts.get("strategy_plugins") - if not isinstance(plugins, list): - return raw_mounts_json - changed = False - for entry in plugins: - if not isinstance(entry, dict): - continue - old = entry.get("strategy") - if old and old != STRATEGY_PROFILE: - entry["strategy"] = STRATEGY_PROFILE - changed = True - print(f"[config-sync] Plugin mount strategy corrected: {old} → {STRATEGY_PROFILE}", flush=True) - return json.dumps(mounts, ensure_ascii=False) if changed else raw_mounts_json - - -if hasattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json"): - normalized = _normalize_plugin_mounts_strategy(RUNTIME_SETTINGS.strategy_plugin_mounts_json) - if normalized != RUNTIME_SETTINGS.strategy_plugin_mounts_json: - object.__setattr__(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", normalized) - -for _env_name in ("MONITOR_DISPATCH_TARGETS_JSON", "IBKR_MONITOR_DISPATCH_TARGETS_JSON"): - _raw = os.environ.get(_env_name) - if _raw: - try: - _targets = json.loads(_raw) - except json.JSONDecodeError: - continue - if not isinstance(_targets, dict): - continue - _entries = _targets.get("targets") - if not isinstance(_entries, list): - continue - _changed = False - for _entry in _entries: - if isinstance(_entry, dict): - _old = _entry.get("strategy_profile") - if _old and _old != STRATEGY_PROFILE: - _entry["strategy_profile"] = STRATEGY_PROFILE - _changed = True - print(f"[config-sync] {_env_name} strategy_profile corrected: {_old} → {STRATEGY_PROFILE}", flush=True) - if _changed: - os.environ[_env_name] = json.dumps(_targets, ensure_ascii=False) - -ACCOUNT_GROUP = RUNTIME_SETTINGS.account_group -SERVICE_NAME = RUNTIME_SETTINGS.service_name -ACCOUNT_IDS = RUNTIME_SETTINGS.account_ids -PROJECT_ID = RUNTIME_SETTINGS.project_id -EXECUTION_BACKEND = RUNTIME_SETTINGS.execution_backend -QUANTCONNECT_PROJECT_ID = getattr(RUNTIME_SETTINGS, "quantconnect_project_id", None) -QUANTCONNECT_NODE_ID = getattr(RUNTIME_SETTINGS, "quantconnect_node_id", None) -MARKET = RUNTIME_SETTINGS.market -MARKET_CALENDAR = RUNTIME_SETTINGS.market_calendar -MARKET_CURRENCY = RUNTIME_SETTINGS.market_currency -MARKET_DATA_SYMBOL_SUFFIX = RUNTIME_SETTINGS.market_data_symbol_suffix -MARKET_EXCHANGE = RUNTIME_SETTINGS.market_exchange -MARKET_TIMEZONE = RUNTIME_SETTINGS.market_timezone - -STRATEGY_RUNTIME = load_strategy_runtime( +from runtime_wiring import ( + EXPECTED_EXECUTION_GUARD_REASON_PREFIXES, + get_project_id, + get_ib_gateway_ip_mode, + resolve_gce_instance_ip, + get_ib_host, + refresh_ib_host, + get_ib_gateway_mode, + get_ib_port, + get_ib_connect_timeout_seconds, + get_positive_int_env, + get_non_negative_float_env, + _env_flag, + RUNTIME_SETTINGS, + IB_HOST, + IB_PORT, + IB_CLIENT_ID, + IB_CONNECT_TIMEOUT_SECONDS, + IB_CONNECT_ATTEMPTS, + IB_CONNECT_RETRY_DELAY_SECONDS, + IB_CLIENT_ID_RETRY_OFFSET, + IBKR_DRY_RUN_DEADLINE_SECONDS, + IBKR_DEADLINE_REPORT_GRACE_SECONDS, STRATEGY_PROFILE, - runtime_settings=RUNTIME_SETTINGS, - logger=lambda message: print(message, flush=True), -) -STRATEGY_ENTRYPOINT = STRATEGY_RUNTIME.entrypoint -STRATEGY_SIGNAL_SOURCE = ( - "feature_snapshot" - if "feature_snapshot" in STRATEGY_RUNTIME.required_inputs - else "market_data" -) -STRATEGY_STATUS_ICON = STRATEGY_RUNTIME.status_icon -SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS = getattr( - getattr(STRATEGY_RUNTIME.runtime_adapter, "runtime_policy", None), - "signal_effective_after_trading_days", - None, -) -FEATURE_RUNTIME_PARAMETERS = dict(STRATEGY_RUNTIME.runtime_config) -STRATEGY_RUNTIME_CONFIG = dict(STRATEGY_RUNTIME.merged_runtime_config) -SAFE_HAVEN = str(STRATEGY_RUNTIME_CONFIG.get("safe_haven") or "BIL") -RANKING_POOL = list(STRATEGY_RUNTIME_CONFIG.get("ranking_pool", ())) -CANARY_ASSETS = list(STRATEGY_RUNTIME_CONFIG.get("canary_assets", ())) -TOP_N = STRATEGY_RUNTIME_CONFIG.get("top_n") -SMA_PERIOD = int(STRATEGY_RUNTIME_CONFIG.get("sma_period", 200)) -CANARY_BAD_THRESHOLD = STRATEGY_RUNTIME_CONFIG.get("canary_bad_threshold") -REBALANCE_MONTHS = STRATEGY_RUNTIME_CONFIG.get("rebalance_months") -FEATURE_SNAPSHOT_PATH = RUNTIME_SETTINGS.feature_snapshot_path -FEATURE_SNAPSHOT_MANIFEST_PATH = RUNTIME_SETTINGS.feature_snapshot_manifest_path -FEATURE_RUNTIME_CONFIG_PATH = ( - STRATEGY_RUNTIME_CONFIG.get("runtime_config_path") - or RUNTIME_SETTINGS.strategy_config_path + STRATEGY_DISPLAY_NAME, + _normalize_plugin_mounts_strategy, + ACCOUNT_GROUP, + SERVICE_NAME, + ACCOUNT_IDS, + PROJECT_ID, + EXECUTION_BACKEND, + QUANTCONNECT_PROJECT_ID, + QUANTCONNECT_NODE_ID, + MARKET, + MARKET_CALENDAR, + MARKET_CURRENCY, + MARKET_DATA_SYMBOL_SUFFIX, + MARKET_EXCHANGE, + MARKET_TIMEZONE, + STRATEGY_RUNTIME, + STRATEGY_ENTRYPOINT, + STRATEGY_SIGNAL_SOURCE, + STRATEGY_STATUS_ICON, + SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, + FEATURE_RUNTIME_PARAMETERS, + STRATEGY_RUNTIME_CONFIG, + SAFE_HAVEN, + RANKING_POOL, + CANARY_ASSETS, + TOP_N, + SMA_PERIOD, + CANARY_BAD_THRESHOLD, + REBALANCE_MONTHS, + FEATURE_SNAPSHOT_PATH, + FEATURE_SNAPSHOT_MANIFEST_PATH, + FEATURE_RUNTIME_CONFIG_PATH, + FEATURE_RUNTIME_CONFIG_SOURCE, + RECONCILIATION_OUTPUT_PATH, + PAPER_LIQUIDATE_ONLY, + TG_TOKEN, + TG_CHAT_ID, + NOTIFY_LANG, + _NOTIFICATION_CHANNEL, + _NOTIFICATION_WEBHOOK_URL, + CASH_RESERVE_RATIO, + CASH_ONLY_EXECUTION, + CASH_RESERVE_FLOOR_USD, + REBALANCE_THRESHOLD_RATIO, + LIMIT_BUY_PREMIUM, + DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL, + SELL_SETTLE_DELAY_SEC, + HIST_DATA_PACING_SEC, + SEPARATOR, + _load_limit_buy_premium_by_symbol, + LIMIT_BUY_PREMIUM_BY_SYMBOL, + t, + strategy_display_name, + RUNTIME_LOG_CONTEXT, + resolve_reporting_managed_symbols, + build_strategy_adapters, + format_market_data_symbol, + fetch_market_historical_price_series, + fetch_market_historical_price_candles, + fetch_market_fallback_historical_candles, + fetch_market_quote_snapshots, + submit_market_order_intent, + probe_market_order_write_access, + fetch_market_portfolio_snapshot, + build_broker_adapters, + build_composer, + require_gateway_execution_backend, + connect_ib, + resolve_run_as_of_date, + get_historical_close, + get_historical_candles, + _build_cycle_report_summary, + _build_notification_delivery_log_for_report, + _build_notification_delivery_summary, + build_account_notification_lines, + build_extra_notification_lines, ) -FEATURE_RUNTIME_CONFIG_SOURCE = ( - STRATEGY_RUNTIME_CONFIG.get("runtime_config_source") - or RUNTIME_SETTINGS.strategy_config_source -) -RECONCILIATION_OUTPUT_PATH = RUNTIME_SETTINGS.reconciliation_output_path -PAPER_LIQUIDATE_ONLY = _env_flag("IBKR_PAPER_LIQUIDATE_ONLY") - -TG_TOKEN = RUNTIME_SETTINGS.tg_token -TG_CHAT_ID = RUNTIME_SETTINGS.tg_chat_id -from quant_platform_kit.notifications.cycle_channel import resolve_cycle_channel_and_url -NOTIFY_LANG = RUNTIME_SETTINGS.notify_lang - -_NOTIFICATION_CHANNEL, _NOTIFICATION_WEBHOOK_URL = resolve_cycle_channel_and_url( - explicit_channel=RUNTIME_SETTINGS.notification_channel, - telegram_token=TG_TOKEN, - telegram_chat_id=TG_CHAT_ID, - wecom_url=RUNTIME_SETTINGS.wecom_webhook_url, - dingtalk_url=RUNTIME_SETTINGS.dingtalk_webhook_url, - feishu_url=RUNTIME_SETTINGS.feishu_webhook_url, - serverchan_url=RUNTIME_SETTINGS.serverchan_webhook_url, +from notifications_runtime import ( + _split_env_list, + send_tg_message, + _platform_notification_prefix, + _normalize_account_prefix_label, + _with_platform_notification_prefix, + publish_notification, + _runtime_error_notification_targets, + _runtime_error_notification_message, + _notify_runtime_error, + _notify_runtime_error_legacy, + _publish_runtime_failure_notification, + _handle_route_runtime_error, + _route_with_runtime_error_fallback, ) -CASH_RESERVE_RATIO = STRATEGY_RUNTIME.cash_reserve_ratio -CASH_ONLY_EXECUTION = getattr(RUNTIME_SETTINGS, "cash_only_execution", True) -CASH_RESERVE_FLOOR_USD = getattr(STRATEGY_RUNTIME, "cash_reserve_floor_usd", 0.0) -REBALANCE_THRESHOLD_RATIO = STRATEGY_RUNTIME.rebalance_threshold_ratio -LIMIT_BUY_PREMIUM = 1.005 -DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {"SOXL": 1.015, "TQQQ": 1.010} -SELL_SETTLE_DELAY_SEC = 3 -HIST_DATA_PACING_SEC = 0.5 -SEPARATOR = "━━━━━━━━━━━━━━━━━━" - - -def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]: - raw_value = "" - for env_name in env_names: - value = os.getenv(env_name) - if value and value.strip(): - raw_value = value.strip() - break - if not raw_value: - return dict(DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL) - try: - payload = json.loads(raw_value) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid limit buy premium map JSON: {raw_value!r}") from exc - if not isinstance(payload, dict): - raise ValueError("Limit buy premium map must be a JSON object keyed by symbol.") - parsed: dict[str, float] = {} - for symbol, premium in payload.items(): - symbol_text = str(symbol or "").strip().upper() - if not symbol_text: - continue - premium_value = float(premium) - if premium_value <= 0.0: - raise ValueError(f"Limit buy premium for {symbol_text} must be positive.") - parsed[symbol_text] = premium_value - return parsed - - -LIMIT_BUY_PREMIUM_BY_SYMBOL = _load_limit_buy_premium_by_symbol( - "IBKR_LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON", - "LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON", +from strategy_plugins_runtime import ( + load_strategy_plugin_signals, + attach_strategy_plugin_report, + build_strategy_plugin_notification_lines, + build_strategy_plugin_error_notification_lines, + build_strategy_plugin_alert_messages, + build_strategy_plugin_alert_state_settings, + build_strategy_plugin_alert_context_label, + publish_strategy_plugin_alerts, ) - -def t(key, **kwargs): - return build_translator(NOTIFY_LANG)(key, **kwargs) - - -strategy_display_name = build_strategy_display_name(t, NOTIFY_LANG)( - STRATEGY_PROFILE, - fallback_name=STRATEGY_DISPLAY_NAME, - metadata=RUNTIME_SETTINGS.strategy_metadata, +from http_routes import ( + app, + ensure_event_loop, + NEW_YORK_TZ, + STRATEGY_RUN_LOCK, + _is_execution_failure, + log_runtime_event, + build_execution_report, + persist_execution_report, + persist_reconciliation_report, + build_request_log_context, + _previous_market_session, + compute_signals, + _has_signal_snapshot_details, + _count_orders, + _account_facts_match_runtime_target, + get_current_portfolio, + build_portfolio_snapshot, + _paper_command_consumer_session_date, + _paper_command_consumer_runtime_is_isolated, + run_paper_execution_command_consumer, + get_market_prices, + check_order_submitted, + execute_rebalance, + _format_liquidation_orders, + run_paper_liquidation_cycle, + run_strategy_core, + attach_signal_data_provenance, + _handle_request, + handle_request, + _handle_dry_run_with_deadline, + _persist_dry_run_report_with_grace, + handle_dry_run, + handle_paper_execution_command_consumer, + handle_probe, + handle_order_settlement, + handle_reconciliation, + handle_live_permission_probe, + handle_monitor_dispatch, + health, ) -RUNTIME_LOG_CONTEXT = build_runtime_assembly( - platform="interactive_brokers", - deploy_target="cloud_run", - service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"), - strategy_profile=STRATEGY_PROFILE, - runtime_target=RUNTIME_SETTINGS.runtime_target, - account_scope=ACCOUNT_GROUP, - account_group=ACCOUNT_GROUP, - project_id=PROJECT_ID, - instance_name=RUNTIME_SETTINGS.ib_gateway_instance_name, - extra_context_fields={ - "account_ids": list(ACCOUNT_IDS), - "strategy_target_mode": RUNTIME_SETTINGS.strategy_target_mode, - "strategy_artifact_dir": RUNTIME_SETTINGS.strategy_artifact_dir, - "strategy_display_name": STRATEGY_DISPLAY_NAME, - "strategy_display_name_localized": strategy_display_name, - "execution_backend": EXECUTION_BACKEND, - "ib_connect_attempts": IB_CONNECT_ATTEMPTS, - "ib_client_id_retry_offset": IB_CLIENT_ID_RETRY_OFFSET, - "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, - "quantconnect_node_id": QUANTCONNECT_NODE_ID, - "market": MARKET, - "market_calendar": MARKET_CALENDAR, - "market_currency": MARKET_CURRENCY, - "market_data_symbol_suffix": MARKET_DATA_SYMBOL_SUFFIX, - "market_exchange": MARKET_EXCHANGE, - "market_timezone": MARKET_TIMEZONE, - }, -).build_log_context(run_id="") - - -def resolve_reporting_managed_symbols() -> tuple[str, ...]: - configured_managed_symbols = STRATEGY_RUNTIME_CONFIG.get("managed_symbols") - fallback_managed_symbols = tuple(dict.fromkeys([*RANKING_POOL, SAFE_HAVEN])) if RANKING_POOL else (SAFE_HAVEN,) - return tuple( - str(symbol) - for symbol in (configured_managed_symbols or fallback_managed_symbols) - if str(symbol or "").strip() - ) - - -def build_strategy_adapters(): - return build_runtime_strategy_adapters( - strategy_runtime=STRATEGY_RUNTIME, - strategy_profile=STRATEGY_PROFILE, - translator=t, - pacing_sec=HIST_DATA_PACING_SEC, - resolve_run_as_of_date_fn=resolve_run_as_of_date, - fetch_historical_price_series_fn=fetch_market_historical_price_series, - fetch_historical_price_candles_fn=fetch_market_historical_price_candles, - map_strategy_decision_fn=map_strategy_decision, - fallback_historical_candles_fn=fetch_market_fallback_historical_candles, - build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload, - load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals, - parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts, - ) - - -def format_market_data_symbol(symbol: str) -> str: - value = str(symbol or "").strip().upper() - if not value or not MARKET_DATA_SYMBOL_SUFFIX or "." in value: - return value - return f"{value}{MARKET_DATA_SYMBOL_SUFFIX}" - - -def fetch_market_historical_price_series(ib, symbol, **kwargs): - return fetch_historical_price_series( - ib, - str(symbol).strip().upper(), - exchange=MARKET_EXCHANGE, - currency=MARKET_CURRENCY, - **kwargs, - ) - - -def fetch_market_historical_price_candles(ib, symbol, **kwargs): - return fetch_historical_price_candles( - ib, - str(symbol).strip().upper(), - exchange=MARKET_EXCHANGE, - currency=MARKET_CURRENCY, - **kwargs, - ) - - -def fetch_market_fallback_historical_candles(symbol, **kwargs): - return fetch_yfinance_historical_candles(format_market_data_symbol(symbol), **kwargs) - - -def fetch_market_quote_snapshots(ib, symbols, **kwargs): - return fetch_quote_snapshots_with_expected_error_summary( - ib, - symbols, - fetch_quote_snapshots=qpk_fetch_quote_snapshots, - exchange=MARKET_EXCHANGE, - currency=MARKET_CURRENCY, - **kwargs, - ) - - -def submit_market_order_intent( - ib, order_intent, *, account_execution_session=None, execution_grant=None, **kwargs, -): - # The public venue wrapper is the final grant gate for every platform caller, - # including callers outside rebalance. No old-pin/missing-session fallback. - from quant_platform_kit.common import execution_state as contract - - session_type = getattr(contract, "AccountSubmissionSession", ()) - if (not isinstance(account_execution_session, session_type) - or account_execution_session.broker != "ibkr" - or account_execution_session.contract_version != "account_submission.v1"): - raise RuntimeError("IBKR account submission session/contract required") - if isinstance(ib, _SettledCashDeadlineIB) and execution_grant is None: - prepared = ib.prepare_intent( - order_intent, exchange=MARKET_EXCHANGE, currency=MARKET_CURRENCY, - ) - if prepared is None: - from quant_platform_kit.common.models import ExecutionReport - - return ExecutionReport( - symbol=order_intent.symbol, side=order_intent.side, - quantity=order_intent.quantity, status="rejected", - raw_payload={"detail": "settled_cash_changed_before_reservation"}, - ) - order_intent = prepared - execution_grant = account_execution_session.reserve(order_intent, new_risk=True) - def submit_with_observation(): - from application.settlement_reporting import settlement_enabled - def submit(broker): - return submit_order_intent( - broker, order_intent, stock_exchange=MARKET_EXCHANGE, - stock_currency=MARKET_CURRENCY, **kwargs, - ) - if not settlement_enabled(): - return submit(ib) - from application.order_settlement import SettlementStore, track_submission - from notifications.unified_adapter import _RUN - source = _RUN.get() or {} - wait = float(os.getenv("IBKR_ORDER_SETTLEMENT_WAIT_SECONDS", "5")) - if not 0 <= wait <= 30: - raise ValueError("settlement_wait_invalid") - return track_submission( - ib, order_intent, submit=submit, - store=SettlementStore(account_execution_session.store, - account_id=account_execution_session.account_id, profile=account_execution_session.profile), - attempt_id=execution_grant.attempt_id, source_run_id=source.get("run_id"), wait_seconds=wait, - observation_ib=ib._ib if isinstance(ib, _SettledCashDeadlineIB) else ib, - source_started_at=source.get("started_at"), - ) - - return account_execution_session.dispatch(execution_grant, order_intent, submit_with_observation) - - -def probe_market_order_write_access(ib): - """Probe order-write access with a low-notional safe-haven what-if buy. - - Do not use the first managed growth symbol (e.g. SOXL): one share can exceed - small-account initial margin and emit Error 201 even though write access is OK. - """ - if len(ACCOUNT_IDS) != 1: - raise RuntimeError( - "IBKR what-if permission probe requires exactly one account_id" - ) - probe_symbol = str(SAFE_HAVEN or "BIL").strip().upper() or "BIL" - return probe_order_write_access( - ib, - symbol=probe_symbol, - account_id=ACCOUNT_IDS[0], - stock_exchange=MARKET_EXCHANGE, - stock_currency=MARKET_CURRENCY, - ) - - -def fetch_market_portfolio_snapshot(ib, **kwargs): - return fetch_portfolio_snapshot( - ib, - currency=MARKET_CURRENCY, - cash_only_execution=CASH_ONLY_EXECUTION, - **kwargs, - ) - - -def build_broker_adapters(*, dry_run_only_override: bool | None = None): - effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) - effective_execution_mode = "dry_run" if effective_dry_run_only else RUNTIME_SETTINGS.ib_gateway_mode - expected_strategy_release = ( - RUNTIME_SETTINGS.runtime_target.strategy_release - if RUNTIME_SETTINGS.runtime_target is not None - else None - ) - - def connect_runtime_ib(host, port, client_id, *, timeout): - # Shadow and dry-run cycles need account and market reads only. Mark - # their Gateway sessions read-only so they cannot trigger an IBKR - # write-access confirmation or acquire trading authority. - return ibkr_connect_ib( - host, - port, - client_id, - timeout=timeout, - readonly=effective_dry_run_only, - ) - - return build_runtime_broker_adapters( - host_resolver=get_ib_host, - refresh_host_fn=refresh_ib_host, - trading_permission_probe_fn=probe_market_order_write_access, - ib_port=IB_PORT, - ib_client_id=IB_CLIENT_ID, - connect_timeout_seconds=IB_CONNECT_TIMEOUT_SECONDS, - connect_attempts=IB_CONNECT_ATTEMPTS, - connect_retry_delay_seconds=IB_CONNECT_RETRY_DELAY_SECONDS, - client_id_retry_offset=IB_CLIENT_ID_RETRY_OFFSET, - ensure_event_loop_fn=ensure_event_loop, - connect_ib_fn=connect_runtime_ib, - fetch_portfolio_snapshot_fn=fetch_market_portfolio_snapshot, - fetch_quote_snapshots_fn=fetch_market_quote_snapshots, - submit_order_intent_fn=submit_market_order_intent, - application_get_market_prices_fn=application_get_market_prices, - application_check_order_submitted_fn=application_check_order_submitted, - application_execute_rebalance_fn=application_execute_rebalance, - execute_paper_liquidation_fn=execute_paper_liquidation, - translator=t, - strategy_profile=STRATEGY_PROFILE, - account_group=ACCOUNT_GROUP, - service_name=SERVICE_NAME, - account_ids=tuple(ACCOUNT_IDS), - dry_run_only=effective_dry_run_only, - cash_only_execution=CASH_ONLY_EXECUTION, - cash_reserve_ratio=CASH_RESERVE_RATIO, - cash_reserve_floor_usd=CASH_RESERVE_FLOOR_USD, - rebalance_threshold_ratio=REBALANCE_THRESHOLD_RATIO, - limit_buy_premium=LIMIT_BUY_PREMIUM, - limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL, - quantity_step=RUNTIME_SETTINGS.quantity_step, - min_order_notional=RUNTIME_SETTINGS.min_order_notional, - safe_haven_cash_substitute_threshold_usd=RUNTIME_SETTINGS.safe_haven_cash_substitute_threshold_usd, - sell_settle_delay_sec=SELL_SETTLE_DELAY_SEC, - separator=SEPARATOR, - strategy_display_name=strategy_display_name, - sleep_fn=time.sleep, - market_currency=MARKET_CURRENCY, - execution_mode=effective_execution_mode, - paper_execution_admission_enabled=resolve_paper_execution_admission_enabled( - env_reader=os.getenv, - dry_run_only=effective_dry_run_only, - execution_mode=effective_execution_mode, - execution_environment=( - RUNTIME_SETTINGS.runtime_target.execution_environment - if RUNTIME_SETTINGS.runtime_target is not None - else None - ), - ), - runtime_release_receipt=build_runtime_loaded_receipt( - strategy_release=expected_strategy_release, - ), - expected_strategy_release=expected_strategy_release, - printer=print, - ) - - -def build_composer(*, dry_run_only_override: bool | None = None, strategy_plugin_signals=()): - effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) - - def compute_signals_fn(ib, current_holdings): - if strategy_plugin_signals: - return compute_signals( - ib, - current_holdings, - strategy_plugin_signals=strategy_plugin_signals, - ) - return compute_signals(ib, current_holdings) - - def connect_ib_for_cycle(): - # Dry-run/shadow must use a read-only Gateway session and must not run the - # live what-if write probe (that path can emit Error 201 on small accounts). - return connect_ib( - read_only=bool(effective_dry_run_only), - validate_trading_permissions=not bool(effective_dry_run_only), - ) - - return build_runtime_composer( - service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"), - strategy_profile=STRATEGY_PROFILE, - strategy_domain=RUNTIME_SETTINGS.strategy_domain, - account_group=ACCOUNT_GROUP, - project_id=PROJECT_ID, - instance_name=RUNTIME_SETTINGS.ib_gateway_instance_name, - account_ids=tuple(ACCOUNT_IDS), - strategy_target_mode=RUNTIME_SETTINGS.strategy_target_mode, - strategy_artifact_dir=RUNTIME_SETTINGS.strategy_artifact_dir, - strategy_display_name=STRATEGY_DISPLAY_NAME, - strategy_display_name_localized=strategy_display_name, - managed_symbols=resolve_reporting_managed_symbols(), - signal_effective_after_trading_days=SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, - signal_source=STRATEGY_SIGNAL_SOURCE, - status_icon=STRATEGY_STATUS_ICON, - safe_haven=SAFE_HAVEN, - dry_run_only=effective_dry_run_only, - strategy_config_source=FEATURE_RUNTIME_CONFIG_SOURCE, - ib_gateway_host_resolver=get_ib_host, - ib_gateway_port=IB_PORT, - ib_gateway_mode=RUNTIME_SETTINGS.ib_gateway_mode, - ib_gateway_ip_mode=RUNTIME_SETTINGS.ib_gateway_ip_mode, - ib_client_id=IB_CLIENT_ID, - ib_connect_timeout_seconds=IB_CONNECT_TIMEOUT_SECONDS, - feature_snapshot_path=FEATURE_SNAPSHOT_PATH, - feature_snapshot_manifest_path=FEATURE_SNAPSHOT_MANIFEST_PATH, - strategy_config_path=FEATURE_RUNTIME_CONFIG_PATH, - reconciliation_output_path=RECONCILIATION_OUTPUT_PATH, - translator=t, - separator=SEPARATOR, - send_message=send_tg_message, - notification_channel=_NOTIFICATION_CHANNEL, - connect_ib_fn=connect_ib_for_cycle, - build_portfolio_snapshot_fn=build_portfolio_snapshot, - compute_signals_fn=compute_signals_fn, - execute_rebalance_fn=lambda ib, target_weights, positions, account_values, **kwargs: execute_rebalance( - ib, - target_weights, - positions, - account_values, - dry_run_only_override=effective_dry_run_only, - **kwargs, - ), - run_id_builder=build_run_id, - event_logger=emit_runtime_log, - report_builder=build_runtime_report_base, - report_persister=persist_runtime_report, - trace_extractor=extract_cloud_trace, - env_reader=os.getenv, - printer=print, - runtime_target=RUNTIME_SETTINGS.runtime_target, - extra_reporting_fields={ - "execution_backend": EXECUTION_BACKEND, - "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, - "quantconnect_node_id": QUANTCONNECT_NODE_ID, - "market": MARKET, - "market_calendar": MARKET_CALENDAR, - "market_currency": MARKET_CURRENCY, - "market_data_symbol_suffix": MARKET_DATA_SYMBOL_SUFFIX, - "market_exchange": MARKET_EXCHANGE, - "market_timezone": MARKET_TIMEZONE, - }, - ) - - -def send_tg_message(message): - from quant_platform_kit.notifications.cycle_channel import build_cycle_sender - sender = build_cycle_sender( - channel=_NOTIFICATION_CHANNEL, - telegram_token=TG_TOKEN, - telegram_chat_id=TG_CHAT_ID, - webhook_url=_NOTIFICATION_WEBHOOK_URL, - ) - return sender(_with_platform_notification_prefix(message)) - - -def _platform_notification_prefix() -> str: - runtime_target = getattr(RUNTIME_SETTINGS, "runtime_target", None) - account_selector = getattr(runtime_target, "account_selector", None) or () - for selector in account_selector: - label = _normalize_account_prefix_label(selector) - if label: - return f"[{label}]" - raw_scope = ( - getattr(runtime_target, "account_scope", None) - or ACCOUNT_GROUP - ) - label = _normalize_account_prefix_label(raw_scope) - if not label: - return "" - return f"[{label}]" - - -def _normalize_account_prefix_label(value: object) -> str: - label = str(value or "").strip().upper() - if not label or label in {"DEFAULT", "LIVE", "US"}: - return "" - if label.startswith("LIVE-U") and len(label) > len("LIVE-U"): - return label.removeprefix("LIVE-") - return label - - -def _with_platform_notification_prefix(message: str) -> str: - prefix = _platform_notification_prefix() - text = str(message or "") - if not prefix or text.lstrip().startswith(prefix): - return text - return f"{prefix} {text}" - - -def publish_notification(*, detailed_text, compact_text): - return build_composer().build_notification_adapters().publish_cycle_notification( - detailed_text=detailed_text, - compact_text=compact_text, - ) - - -def _runtime_error_notification_targets() -> tuple[tuple[str, str], ...]: - targets: list[tuple[str, str]] = [] - if TG_TOKEN and TG_CHAT_ID: - targets.append((TG_TOKEN, TG_CHAT_ID)) - - seen: set[tuple[str, str]] = set() - unique_targets: list[tuple[str, str]] = [] - for target in targets: - if target in seen: - continue - seen.add(target) - unique_targets.append(target) - return tuple(unique_targets) - - -def _runtime_error_notification_message(exc: Exception, *, route_label: str | None = None) -> str: - title = "runtime_failure_title" - return "\n".join(( - t(title), - t("strategy_label", name=strategy_display_name or STRATEGY_PROFILE), - t("runtime_failure_context", context=ACCOUNT_GROUP), - t("runtime_failure_result"), - t("runtime_failure_action"), - )) - - -def _notify_runtime_error(exc: Exception, *, route_label: str | None = None) -> bool: - from notifications.unified_adapter import publish_runtime_failure - return publish_runtime_failure(exc, lambda: _notify_runtime_error_legacy(exc, route_label=route_label)) - - -def _notify_runtime_error_legacy(exc: Exception, *, route_label: str | None = None) -> bool: - targets = _runtime_error_notification_targets() - if not targets: - print(t("runtime_notification_missing_target"), flush=True) - return False - message = _runtime_error_notification_message(exc, route_label=route_label) - outcomes = [] - for token, chat_id in targets: - outcomes.append(send_telegram_message( - _with_platform_notification_prefix(message), - token=token, - chat_id=chat_id, - requests_module=requests, - )) - return bool(outcomes) and all(outcomes) - - -def _publish_runtime_failure_notification(*, detailed_text: str, compact_text: str, exc: Exception) -> bool: - from notifications.unified_adapter import enabled as unified_enabled, publish_runtime_failure - if unified_enabled(): - return publish_runtime_failure(exc, lambda: publish_notification(detailed_text=detailed_text, compact_text=compact_text)) - try: - if publish_notification(detailed_text=detailed_text, compact_text=compact_text): - return True - return _notify_runtime_error(exc) - except Exception: - print(t("runtime_notification_delivery_failed"), flush=True) - return _notify_runtime_error(exc) - - -def _handle_route_runtime_error(exc: Exception, *, route_label: str | None = None): - print(t("runtime_failure_log"), flush=True) - _notify_runtime_error(exc, route_label=route_label) - return "Error", 500 - - -def _route_with_runtime_error_fallback(handler, *args, route_label: str | None = None, **kwargs): - try: - return handler(*args, **kwargs) - except Exception as exc: - return _handle_route_runtime_error(exc, route_label=route_label) - - -def require_gateway_execution_backend(): - if EXECUTION_BACKEND == EXECUTION_BACKEND_GATEWAY: - return - raise RuntimeError( - f"IBKR execution_backend={EXECUTION_BACKEND!r} is configured; " - "Gateway connection and direct order execution are disabled for this service. " - "Run the QuantConnect deployment/algorithm path for this account group." - ) - - -def connect_ib( - *, - read_only: bool = False, - validate_trading_permissions: bool = True, -): - """Connect to the Gateway with an optional read-only session boundary.""" - require_gateway_execution_backend() - # ib_insync 0.9.86 automatically binds manual orders for client ID 0, - # including readonly=True. Never reach that SDK path from a read probe. - if read_only and (type(IB_CLIENT_ID) is not int or IB_CLIENT_ID <= 0): - raise RuntimeError("read-only Gateway access requires a configured nonzero client ID") - # A probe must not merely skip the local what-if permission check. Its - # Gateway session itself must be read-only, so a future probe change cannot - # accidentally acquire broker write authority. - adapter_dry_run_override = True if read_only else None - adapters = build_broker_adapters( - dry_run_only_override=adapter_dry_run_override, - ) - if read_only: - adapters = replace(adapters, connect_attempts=1) - return adapters.connect_ib( - validate_trading_permissions=validate_trading_permissions, - redact_connection_diagnostics=read_only, - ) - - -def log_runtime_event(log_context, event, **fields): - return build_composer().build_reporting_adapters().log_event(log_context, event, **fields) - - -def build_execution_report(log_context, *, dry_run_only_override: bool | None = None): - return build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().build_report(log_context) - - -def persist_execution_report(report, *, dry_run_only_override: bool | None = None): - adapters = build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters() - diagnostics = report.get("diagnostics") - if isinstance(diagnostics, Mapping) and diagnostics.get("maintenance_cash_observation") is True: - prefix = adapters.report_gcs_prefix_uri - if not prefix: - raise RuntimeError("maintenance observation requires the existing private report destination") - adapters = replace(adapters, - report_base_dir=os.path.join(adapters.report_base_dir, "maintenance-cash") if adapters.report_base_dir else None, - report_gcs_prefix_uri=prefix.rstrip("/") + "/maintenance-cash") - return adapters.persist_execution_report(report) - - -def persist_reconciliation_report(report): - safe_report = build_persistable_reconciliation_report(report) - diagnostics = report.get("diagnostics") - initial = diagnostics.get("initial_enrollment_private") if isinstance(diagnostics, Mapping) else None - if initial is not None: - adapters = build_composer(dry_run_only_override=True).build_reporting_adapters() - if not adapters.report_gcs_prefix_uri: - raise RuntimeError("initial enrollment requires the existing private report destination") - if not isinstance(initial, Mapping) or set(initial) != {"facts", "facts_sha256"}: - raise RuntimeError("initial enrollment private facts are invalid") - from urllib.parse import urlsplit - from google.cloud import storage - prefix = urlsplit(adapters.report_gcs_prefix_uri) - request_id = safe_report["diagnostics"].get("reconciliation_request_id") - if prefix.scheme != "gs" or not prefix.netloc or not prefix.path.strip("/") or prefix.query or prefix.fragment or not request_id: - raise RuntimeError("initial enrollment private destination or request is invalid") - safe_report["diagnostics"]["initial_enrollment_private"] = dict(initial) - name = prefix.path.strip("/") + "/initial-enrollment/" + request_id + ".json" - blob = storage.Client().bucket(prefix.netloc).blob(name) - blob.upload_from_string(json.dumps(safe_report, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False), - content_type="application/json", if_generation_match=0, retry=None) - generation = str(blob.generation or "") - if not generation.isascii() or not generation.isdigit() or int(generation) <= 0: - raise RuntimeError("initial enrollment private generation is unknown") - return f"gs://{prefix.netloc}/{name}#{generation}" - return persist_execution_report(safe_report, dry_run_only_override=True) - - -def build_request_log_context(): - return build_composer().build_reporting_adapters().build_log_context( - trace_header=request.headers.get("X-Cloud-Trace-Context"), - ) - - -def _previous_market_session(run_date: date) -> pd.Timestamp: - import pandas_market_calendars as market_calendars - - sessions = market_calendars.get_calendar(MARKET_CALENDAR).valid_days( - start_date=run_date - timedelta(days=14), - end_date=run_date - timedelta(days=1), - ) - if len(sessions) == 0: - raise RuntimeError("IBKR previous completed market session is unavailable") - return pd.Timestamp(sessions[-1]).tz_localize(None).normalize() - - -def resolve_run_as_of_date() -> pd.Timestamp: - explicit = os.getenv("IBKR_RUN_AS_OF_DATE") - if explicit: - return pd.Timestamp(explicit).normalize() - run_date = datetime.now(NEW_YORK_TZ).date() - if ( - STRATEGY_PROFILE == "tqqq_growth_income" - and RUNTIME_SETTINGS.runtime_target is not None - and RUNTIME_SETTINGS.runtime_target.execution_mode == "live" - ): - return _previous_market_session(run_date) - return pd.Timestamp(run_date) - - -def get_historical_close(ib, symbol, duration="2 Y", bar_size="1 day"): - return build_strategy_adapters().get_historical_close( - ib, - symbol, - duration=duration, - bar_size=bar_size, - ) - - -def get_historical_candles(ib, symbol, duration="2 Y", bar_size="1 day"): - return build_strategy_adapters().get_historical_candles( - ib, - symbol, - duration=duration, - bar_size=bar_size, - ) - - -def compute_signals(ib, current_holdings, *, strategy_plugin_signals=()): - return build_strategy_adapters().compute_signals( - ib, - current_holdings, - strategy_plugin_signals=strategy_plugin_signals, - ) - - -def load_strategy_plugin_signals(): - return build_strategy_adapters().load_strategy_plugin_signals( - getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None) - ) - - -def attach_strategy_plugin_report(report, *, signals, error: str | None = None): - build_strategy_adapters().attach_strategy_plugin_report(report, signals=signals, error=error) - - -def build_strategy_plugin_notification_lines(signals) -> tuple[str, ...]: - return build_strategy_adapters().build_strategy_plugin_notification_lines(signals) - - -def build_strategy_plugin_error_notification_lines(error) -> tuple[str, ...]: - return build_strategy_adapters().build_strategy_plugin_error_notification_lines(error) - - -def build_strategy_plugin_alert_messages(signals): - return build_strategy_adapters().build_strategy_plugin_alert_messages(signals) - - -def build_strategy_plugin_alert_state_settings(): - return StrategyPluginAlertStateSettings.from_env( - project_id=PROJECT_ID, - ) - - -def build_strategy_plugin_alert_context_label() -> str: - return build_alert_context_label( - platform_id="ibkr", - strategy_profile=STRATEGY_PROFILE, - account_scope=ACCOUNT_GROUP, - service_name=SERVICE_NAME, - runtime_target=RUNTIME_SETTINGS.runtime_target, - ) - - -def _has_signal_snapshot_details(snapshot: dict[str, object]) -> bool: - return any( - snapshot.get(field_name) - for field_name in ( - "signal_as_of", - "market_date", - "latest_price_source", - "target_weights", - "target_values", - "indicators", - "signal", - "status", - ) - ) - - -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: - from application.reconciliation_service import account_brake_submission_status - - brake_status = account_brake_submission_status(getattr(cycle_result, "signal_metadata", None)) - orders_submitted_count = _count_orders( - execution_summary.get("orders_submitted"), - reconciliation_record.get("orders_submitted"), - ) - if brake_status is not None: - orders_submitted_count = None - 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 - 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": ( - None if dry_run and orders_previewed_count is None else 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 - ), - } - if brake_status is not None: - summary["account_brake_submission_status"] = brake_status - summary["orders_submitted_coverage"] = "strategy_only_account_brake_details_unavailable" - if _account_facts_match_runtime_target(cycle_result.account_facts): - summary["account_facts"] = cycle_result.account_facts - quote_snapshot = execution_summary.get("quote_snapshot") or reconciliation_record.get("quote_snapshot") - if quote_snapshot: - summary["quote_snapshot"] = quote_snapshot - from notifications.unified_adapter import digest_evidence - summary["notification_digest"] = digest_evidence(execution_summary, dry_run=dry_run) - outcome_keys = ("orders_filled", "orders_pending", "orders_partially_filled", - "option_orders_filled", "option_orders_pending", "option_orders_partially_filled") - if not dry_run and brake_status is None and all(isinstance(execution_summary.get(key), (list, tuple)) for key in outcome_keys): - summary["orders_submitted_count"] = summary["notification_digest"]["order_count"] - return summary - - -def _account_facts_match_runtime_target(account_facts: Mapping[str, object]) -> bool: - """Only report physical account facts bound by the configured selector.""" - target = getattr(RUNTIME_SETTINGS, "runtime_target", None) - selectors = getattr(target, "account_selector", ()) if target is not None else () - account_ids = account_facts.get("account_ids") if isinstance(account_facts, Mapping) else None - if isinstance(selectors, str): - selectors = (selectors,) - if isinstance(account_ids, str): - account_ids = (account_ids,) - normalized_selectors = tuple( - str(value or "").strip() - for value in (selectors or ()) - if str(value or "").strip() - ) - normalized_account_ids = tuple( - str(value or "").strip() - for value in (account_ids or ()) - if str(value or "").strip() - ) - return ( - len(normalized_selectors) == 1 - and normalized_selectors[0].lower() != "default" - and len(normalized_account_ids) == 1 - and normalized_account_ids[0] == normalized_selectors[0] - ) - - -def _build_notification_delivery_log_for_report( - *, - platform: str, - strategy_profile: str, - run_id: str, - dry_run: bool, - orders_previewed_count: int | None, - 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 is None 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 _build_notification_delivery_summary(delivery_events: list[dict]) -> dict: - safe_fields = ( - "sink", - "delivery_status", - "transport_acknowledged", - "error_type", - "compact_text_sha256", - "compact_text_length", - ) - events = [ - {key: event[key] for key in safe_fields if key in event} - for event in (dict(item) for item in delivery_events) - ] - if not events: - return {} - sent_count = sum(event.get("delivery_status") == "sent" for event in events) - failed_count = sum(event.get("delivery_status") == "failed" for event in events) - return { - "attempted_count": len(events), - "sent_count": sent_count, - "failed_count": failed_count, - "all_acknowledged": failed_count == 0 and sent_count == len(events), - "delivery_events": events, - } - - -def publish_strategy_plugin_alerts(signals, *, report=None): - from quant_platform_kit.notifications.digest_routing import immediate_plugin_signals - result = dispatch_strategy_plugin_alerts( - immediate_plugin_signals(signals, mode="daily_digest"), - notification_settings=RUNTIME_SETTINGS, - translator=t, - strategy_label=STRATEGY_PROFILE, - context_label=build_strategy_plugin_alert_context_label(), - state_settings=build_strategy_plugin_alert_state_settings(), - log_message=print, - ) - if report is not None: - result.attach_to_report(report) - return result - - -def build_account_notification_lines() -> tuple[str, ...]: - account_ids = tuple(str(account_id).strip() for account_id in ACCOUNT_IDS if str(account_id).strip()) - if not account_ids: - return () - return (t("account_ids_detail", account_ids=", ".join(account_ids)),) - - -def build_extra_notification_lines( - strategy_plugin_signals=(), - *, - strategy_plugin_error: str | None = None, -) -> tuple[str, ...]: - return ( - t( - "market_scope_detail", - market=MARKET, - currency=MARKET_CURRENCY, - exchange=MARKET_EXCHANGE, - calendar=MARKET_CALENDAR, - ), - *build_account_notification_lines(), - *build_strategy_plugin_notification_lines(strategy_plugin_signals), - *build_strategy_plugin_error_notification_lines(strategy_plugin_error), - ) - - -def get_current_portfolio(ib): - return build_broker_adapters().get_current_portfolio(ib) - - -def build_portfolio_snapshot(ib): - return build_broker_adapters().build_portfolio_snapshot( - ib, - get_current_portfolio_fallback=get_current_portfolio, - ) - - -def _paper_command_consumer_session_date() -> str: - return datetime.now(ZoneInfo(MARKET_TIMEZONE)).date().isoformat() - - -def _paper_command_consumer_runtime_is_isolated() -> bool: - """Require a disabled, paper-only runtime before any Gateway read occurs.""" - - runtime_target = getattr(RUNTIME_SETTINGS, "runtime_target", None) - return bool( - runtime_target is not None - and bool(getattr(RUNTIME_SETTINGS, "dry_run_only", False)) - and not bool(getattr(RUNTIME_SETTINGS, "runtime_target_enabled", True)) - and str(getattr(RUNTIME_SETTINGS, "ib_gateway_mode", "") or "").strip().lower() == "paper" - and str(getattr(runtime_target, "execution_mode", "") or "").strip().lower() == "paper" - and bool(CASH_ONLY_EXECUTION) - ) - - -def run_paper_execution_command_consumer() -> dict[str, object]: - """Explicitly reconcile due commands without creating a broker order. - - This isolated route deliberately avoids the normal rebalance and execution - adapters. It opens the Gateway only after command release and delivery - binding checks pass, then reads portfolio and quote evidence to simulate - what would have been submitted. - """ - - if not _paper_command_consumer_runtime_is_isolated(): - raise RuntimeError( - "paper command consumer requires disabled runtime target, IBKR_DRY_RUN_ONLY=true, " - "paper Gateway mode, paper runtime target, and CASH_ONLY_EXECUTION=true" - ) - if not resolve_paper_execution_command_consumer_enabled( - env_reader=os.getenv, - dry_run_only=bool(RUNTIME_SETTINGS.dry_run_only), - ): - raise RuntimeError("paper command consumer is not enabled") - - runtime_target = RUNTIME_SETTINGS.runtime_target - expected_release = getattr(runtime_target, "strategy_release", None) - expected_binding = { - "platform": "ibkr", - "account_scope": str(getattr(runtime_target, "account_scope", "") or "unknown"), - "strategy_profile": str(getattr(runtime_target, "strategy_profile", "") or "unknown"), - } - store = build_execution_command_store_from_env( - platform_env_prefix="IBKR", - env_reader=os.getenv, - project_id=PROJECT_ID, - ) - if not store.cloud_prefix_uri and not store.local_dir: - raise RuntimeError("IBKR paper command consumer requires an execution command store") - - reporting_adapters = build_composer(dry_run_only_override=True).build_reporting_adapters() - log_context = reporting_adapters.build_log_context( - trace_header=request.headers.get("X-Cloud-Trace-Context"), - ) - report = reporting_adapters.build_report(log_context) - ib = None - quote_cache: dict[str, object] = {} - - def _ib(): - nonlocal ib - if ib is None: - # This uses a dry-run-only adapter; its permission validation does - # not perform the normal live what-if order check. - ib = build_broker_adapters(dry_run_only_override=True).connect_ib() - return ib - - def _load_portfolio(): - return fetch_reconciled_paper_portfolio_snapshot( - _ib(), - account_ids=ACCOUNT_IDS, - currency=MARKET_CURRENCY, - ) - - def _load_quote(symbol: str): - normalized = str(symbol or "").strip().upper() - if normalized not in quote_cache: - quotes = fetch_market_quote_snapshots(_ib(), (normalized,)) - quote = quotes.get(normalized) - if quote is None: - raise IBKRPortfolioSnapshotUnavailableError( - "IBKR paper command reconciliation is missing a current quote." - ) - quote_cache[normalized] = quote - return quote_cache[normalized] - - try: - reporting_adapters.log_event( - log_context, - "paper_execution_command_consumer_started", - message="Starting isolated read-only IBKR paper command consumer", - ) - result = consume_due_paper_execution_commands( - store=store, - as_of_session=_paper_command_consumer_session_date(), - claimant=str(os.getenv("K_SERVICE") or "ibkr-paper-command-consumer"), - portfolio_loader=_load_portfolio, - quote_loader=_load_quote, - managed_symbols=resolve_reporting_managed_symbols(), - runtime_release_receipt=build_runtime_loaded_receipt( - strategy_release=expected_release, - ), - expected_strategy_release=expected_release, - expected_command_binding=expected_binding, - ) - finalize_runtime_report( - report, - status="ok" if result.get("status") == "ok" else "skipped", - summary={"paper_execution_command_consumer": result}, - ) - reporting_adapters.log_event( - log_context, - "paper_execution_command_consumer_completed", - message="IBKR paper command consumer completed", - result_status=result.get("status"), - commands_count=len(tuple(result.get("commands") or ())), - ) - return result - except Exception as exc: - append_runtime_report_error( - report, - stage="paper_execution_command_consumer", - message=str(exc), - error_type=type(exc).__name__, - ) - finalize_runtime_report(report, status="error") - reporting_adapters.log_event( - log_context, - "paper_execution_command_consumer_failed", - message="IBKR paper command consumer failed", - severity="ERROR", - error_type=type(exc).__name__, - ) - raise - finally: - try: - report_path = reporting_adapters.persist_execution_report(report) - print(f"execution_report {report_path}", flush=True) - except Exception as persist_exc: - print(f"failed to persist execution report: {persist_exc}", flush=True) - disconnect_fn = getattr(ib, "disconnect", None) - if callable(disconnect_fn): - disconnect_fn() - - -def get_market_prices(ib, symbols): - return build_broker_adapters().get_market_prices(ib, symbols) - - -def check_order_submitted(report): - return build_broker_adapters().check_order_submitted(report) - - -def execute_rebalance( - ib, - target_weights, - positions, - account_values, - *, - strategy_symbols=None, - signal_metadata=None, - acquire_execution_claim=None, - account_execution_session=None, - dry_run_only_override: bool | None = None, -): - return build_broker_adapters(dry_run_only_override=dry_run_only_override).execute_rebalance( - ib, - target_weights, - positions, - account_values, - strategy_symbols=strategy_symbols, - signal_metadata=signal_metadata, - acquire_execution_claim=acquire_execution_claim, - account_execution_session=account_execution_session, - ) - - -def _format_liquidation_orders(orders) -> str: - return build_broker_adapters().format_liquidation_orders(orders) - - -def run_paper_liquidation_cycle(): - require_gateway_execution_backend() - if RUNTIME_SETTINGS.ib_gateway_mode != "paper": - raise RuntimeError("IBKR_PAPER_LIQUIDATE_ONLY is only allowed when ib_gateway_mode=paper") - return build_broker_adapters().run_paper_liquidation_cycle( - connect_ib_fn=connect_ib, - get_current_portfolio_fn=get_current_portfolio, - publish_notification_fn=publish_notification, - ) - - -def run_strategy_core( - *, - strategy_plugin_signals=(), - strategy_plugin_error: str | None = None, - 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, - ) - 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, - strategy_plugin_error=strategy_plugin_error, - ), - cash_only_execution=CASH_ONLY_EXECUTION, - notification_attention_reason_codes=( - ("strategy_plugin_error",) if strategy_plugin_error else () - ), - ), - ) - - -def attach_signal_data_provenance(report) -> None: - """Attach this run's non-sensitive signal data provenance; never raises.""" - try: - provenance = signal_provenance.snapshot() - if provenance and isinstance(report, dict): - diagnostics = report.get("diagnostics") - if not isinstance(diagnostics, dict): - diagnostics = {} - report["diagnostics"] = diagnostics - diagnostics["signal_data_provenance"] = provenance - except Exception: - return - - -def _handle_request( - *, - dry_run_only_override: bool | None = None, - response_body: str = "OK", - dry_run_label: str = "strategy dry-run", - persist_report: bool = True, - report_collector: list[dict] | None = None, -): - if request.method == "GET": - if dry_run_only_override is None: - return "OK - use POST to execute strategy", 200 - return f"{response_body} - use POST to run {dry_run_label}", 200 - if dry_run_only_override is None and not getattr(RUNTIME_SETTINGS, "runtime_target_enabled", True): - return "Runtime Target Disabled", 200 - - log_context = build_request_log_context() - report = build_execution_report(log_context, dry_run_only_override=dry_run_only_override) - signal_provenance.reset() - if report_collector is not None: - # Register the mutable report before any deadline-bound setup work so a - # timeout while loading strategy plugins still leaves a report to persist. - report_collector.append(report) - execution_window = "dry_run" if dry_run_only_override else "execution" - lock_acquired = False - deadline_exceeded = False - try: - strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals() - attach_strategy_plugin_report( - report, - signals=strategy_plugin_signals, - error=strategy_plugin_error, - ) - if not strategy_plugin_error: - from notifications.unified_adapter import routine_observation_counts_from_signals - report.setdefault("summary", {})["routine_observation_counts"] = routine_observation_counts_from_signals(strategy_plugin_signals) - lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False) - log_runtime_event( - log_context, - "strategy_cycle_received", - message="Received strategy dry-run request" if dry_run_only_override else "Received strategy execution request", - http_method=request.method, - execution_window=execution_window, - ) - if not lock_acquired: - log_runtime_event( - log_context, - "strategy_cycle_already_running", - message="Another strategy execution is already running; skip overlapping request", - severity="WARNING", - ) - finalize_runtime_report( - report, - status="skipped", - diagnostics={"skip_reason": "already_running"}, - ) - return "Already Running", 200 - market_open = is_market_open_now( - calendar_name=MARKET_CALENDAR, - timezone_name=MARKET_TIMEZONE, - logger=lambda message: print(message, flush=True), - ) - force_run = (os.getenv("IBKR_FORCE_RUN") or "").strip().lower() == "true" - if not market_open and not force_run: - log_runtime_event( - log_context, - "market_closed", - message="Market closed; skip strategy execution", - execution_window=execution_window, - market=MARKET, - market_calendar=MARKET_CALENDAR, - market_timezone=MARKET_TIMEZONE, - ) - finalize_runtime_report( - report, - status="skipped", - diagnostics={"skip_reason": "market_closed"}, - ) - return "Market Closed", 200 - if force_run and not market_open: - log_runtime_event( - log_context, - "market_hours_bypassed", - message="Market hours bypassed for strategy execution", - execution_window=execution_window, - market=MARKET, - market_calendar=MARKET_CALENDAR, - market_timezone=MARKET_TIMEZONE, - ) - unsupported_reason = dca_execution_unsupported_reason(STRATEGY_PROFILE) - if unsupported_reason is not None: - unsupported_message = ( - "Strategy requires fractional-share execution; IBKR TWS API does not support " - "fractional equity orders (use desktop TWS for manual DCA or choose another platform)" - if unsupported_reason == IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON - else "Strategy requires fractional-share execution; skip" - ) - log_runtime_event( - log_context, - "strategy_execution_unsupported", - message=unsupported_message, - execution_window=execution_window, - skip_reason=unsupported_reason, - strategy_profile=STRATEGY_PROFILE, - market=MARKET, - market_calendar=MARKET_CALENDAR, - market_timezone=MARKET_TIMEZONE, - ) - finalize_runtime_report( - report, - status="skipped", - diagnostics={"skip_reason": unsupported_reason}, - ) - return "Unsupported Strategy", 200 - log_runtime_event( - log_context, - "strategy_cycle_started", - message="Starting strategy dry-run" if dry_run_only_override else "Starting strategy execution", - execution_window=execution_window, - ) - 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( - with_notification_run( - report, run_strategy_core, - strategy_plugin_signals=strategy_plugin_signals, - strategy_plugin_error=strategy_plugin_error, - dry_run_only_override=dry_run_only_override, - notification_delivery_events=notification_delivery_events, - ) - ) - execution_summary = dict(cycle_result.execution_summary or {}) - reconciliation_record = dict(cycle_result.reconciliation_record or {}) - signal_metadata = dict(cycle_result.signal_metadata or {}) - signal_snapshot = dict(signal_metadata.get("signal_snapshot") or {}) - if not signal_snapshot: - signal_snapshot = build_signal_snapshot( - platform="ibkr", - strategy_profile=signal_metadata.get("strategy_profile") or STRATEGY_PROFILE, - metadata=signal_metadata, - target_weights=cycle_result.target_weights, - ) - if execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"): - signal_snapshot["latest_price_source"] = ( - execution_summary.get("price_source_mode") - or reconciliation_record.get("price_source_mode") - ) - fallback_used = bool( - execution_summary.get("snapshot_price_fallback_used") - or reconciliation_record.get("snapshot_price_fallback_used") - ) - if fallback_used: - signal_snapshot["data_freshness_warning"] = "snapshot_price_fallback_used" - has_signal_snapshot = _has_signal_snapshot_details(signal_snapshot) - if has_signal_snapshot: - log_runtime_event( - log_context, - "strategy_signal_snapshot", - message="Strategy signal snapshot", - execution_window=execution_window, - **signal_snapshot, - ) - from application.settlement_reporting import settlement_enabled - settlement_observation = None - if not report.get("dry_run") and settlement_enabled(): - from application.settlement_route import runtime_settlement_store - from application.order_settlement import settlement_summary - from application.settlement_reporting import ( - publish_settlement_notices, settlement_candidate, settlement_receipt_summary, - ) - from notifications.unified_adapter import event_for, persist_candidate - import sys - settlement_store = runtime_settlement_store(sys.modules[__name__]) - settlement_observation = settlement_summary(settlement_store) - publish_settlement_notices(settlement_store, settlement_observation, - send=send_tg_message, locale=NOTIFY_LANG, - publish_candidate=lambda row: settlement_candidate(row, event_factory=event_for, persist=persist_candidate)) - report_summary = _build_cycle_report_summary( - cycle_result, - execution_summary, - reconciliation_record, - dry_run=bool(report.get("dry_run")), - ) - if settlement_observation is not None: - current = [row for row in settlement_observation["orders_submitted"] - if row["source_run_id"] == report.get("run_id")] - report_summary["settlement_outcomes"] = current - # The original execution state remains unchanged. The receipt can - # consume this separate, broker-confirmed reporting observation. - receipt_observation = {key: [row for row in value if row["source_run_id"] == report.get("run_id")] - for key, value in settlement_observation.items() if isinstance(value, list)} - receipt_summary = settlement_receipt_summary(execution_summary, receipt_observation) - else: - receipt_summary = execution_summary - 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=report_summary.get("orders_previewed_count"), - delivery_events=notification_delivery_events, - ) - if notification_delivery_log: - report_summary["notification_delivery_log"] = notification_delivery_log - notification_delivery_summary = _build_notification_delivery_summary( - notification_delivery_events - ) - if notification_delivery_summary: - report_summary["notification_delivery_summary"] = notification_delivery_summary - execution_status = str(report_summary.get("execution_status") or "").strip().lower() - execution_failed = _is_execution_failure( - execution_status, - report_summary.get("no_op_reason"), - ) - if execution_failed: - append_runtime_report_error( - report, - stage="strategy_execution", - message=( - f"Strategy execution status is {execution_status}; " - f"reason={report_summary.get('no_op_reason') or 'unspecified'}" - ), - error_type="StrategyExecutionBlocked", - failure_category="strategy_execution_blocked", - ) - report_diagnostics = { - "result": cycle_result.result, - "price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"), - "snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols") - or reconciliation_record.get("snapshot_price_fallback_symbols") - or [], - **({"signal_snapshot": signal_snapshot} if has_signal_snapshot else {}), - } - if execution_failed: - report_diagnostics["failure_category"] = "strategy_execution_blocked" - attach_cycle_execution_receipt( - report, - receipt_summary, - reconciliation_record, - execution_failed=execution_failed, - ) - finalize_runtime_report( - report, - status="error" if execution_failed else "ok", - summary=report_summary, - diagnostics=report_diagnostics, - artifacts={ - "reconciliation_record_path": cycle_result.reconciliation_record_path, - }, - ) - log_runtime_event( - log_context, - "strategy_cycle_blocked" if execution_failed else "strategy_cycle_completed", - message=( - "Strategy execution blocked" - if execution_failed - else "Strategy dry-run completed" - if dry_run_only_override - else "Strategy execution completed" - ), - severity="ERROR" if execution_failed else "INFO", - execution_window=execution_window, - result=cycle_result.result, - execution_status=report_summary.get("execution_status"), - no_op_reason=report_summary.get("no_op_reason"), - ) - return (response_body if dry_run_only_override else cycle_result.result), 200 - except RuntimeDeadlineExceeded as exc: - deadline_exceeded = True - append_runtime_report_error( - report, - stage="strategy_deadline", - message=str(exc), - error_type=type(exc).__name__, - ) - finalize_runtime_report( - report, - status="error", - diagnostics={ - "failure_category": "strategy_deadline", - "timeout_seconds": IBKR_DRY_RUN_DEADLINE_SECONDS, - }, - ) - raise - except IBKRGatewayUnavailableError as exc: - append_runtime_report_error( - report, - stage="ibkr_connect", - message=str(exc), - error_type=type(exc).__name__, - failure_category="ibkr_gateway_unavailable", - ) - finalize_runtime_report( - report, - status="error", - diagnostics={"failure_category": "ibkr_gateway_unavailable"}, - ) - log_runtime_event( - log_context, - "ibkr_gateway_connect_failed", - message="IBKR gateway connection attempts exhausted", - severity="ERROR", - error_type=type(exc).__name__, - error_message=str(exc), - failure_category="ibkr_gateway_unavailable", - ) - error_msg = f"🚨 【IBKR 连接异常】\n{str(exc)}" - _publish_runtime_failure_notification( - detailed_text=error_msg, - compact_text=error_msg, - exc=exc, - ) - return "Error", 503 - except (IBKRTradingPermissionError, IBKRPortfolioSnapshotUnavailableError) as exc: - failure_category = ( - "ibkr_trading_permission" - if isinstance(exc, IBKRTradingPermissionError) - else "ibkr_portfolio_snapshot" - ) - append_runtime_report_error( - report, - stage=failure_category, - message=str(exc), - error_type=type(exc).__name__, - failure_category=failure_category, - ) - finalize_runtime_report( - report, - status="error", - diagnostics={"failure_category": failure_category}, - ) - log_runtime_event( - log_context, - f"{failure_category}_failed", - message="IBKR execution readiness validation failed", - severity="ERROR", - error_type=type(exc).__name__, - error_message=str(exc), - failure_category=failure_category, - ) - error_msg = f"🚨 【IBKR 执行条件异常】\n{str(exc)}" - _publish_runtime_failure_notification( - detailed_text=error_msg, - compact_text=error_msg, - exc=exc, - ) - return "Error", 503 - except Exception as exc: - append_runtime_report_error( - report, - stage="strategy_cycle", - message="strategy_cycle_failed", - error_type=type(exc).__name__, - ) - finalize_runtime_report(report, status="error") - log_runtime_event( - log_context, - "strategy_cycle_failed", - message=t("runtime_failure_log"), - severity="ERROR", - error_type=type(exc).__name__, - error_message="strategy_cycle_failed", - ) - error_msg = _runtime_error_notification_message(exc) - _publish_runtime_failure_notification( - detailed_text=error_msg, - compact_text=error_msg, - exc=exc, - ) - return "Error", 500 - finally: - if lock_acquired: - STRATEGY_RUN_LOCK.release() - attach_signal_data_provenance(report) - if "execution_receipt" not in report: - try: - attach_terminal_fallback_execution_receipt(report) - except ValueError: - # A legacy runtime target remains visible as evidence-missing; - # reporting metadata must never change trading behavior. - pass - if not deadline_exceeded and persist_report: - try: - if dry_run_only_override is None: - report_path = persist_execution_report(report) - else: - report_path = persist_execution_report(report, dry_run_only_override=dry_run_only_override) - print(f"execution_report {report_path}", flush=True) - from application.runtime_reporting_adapters import confirmed_gcs_report_uri - if not confirmed_gcs_report_uri(report_path): - raise RuntimeError("durable_execution_report_unconfirmed") - except Exception as persist_exc: - print(f"failed to persist execution report: {persist_exc}", flush=True) - if report.get("status") != "error": - from quant_platform_kit.common.operational_notification_localization import resolve_operational_notification_locale - zh = resolve_operational_notification_locale(NOTIFY_LANG) == "zh" - message = ("⚠️ 运行报告写入未确认;汇总观察可能缺失,请检查运行记录。" if zh else - "⚠️ Runtime report write unconfirmed; digest observations may be missing. Check run records.") - try: - if not send_tg_message(message): - print("notification_report_coverage_alert_failed", flush=True) - except Exception: - print("notification_report_coverage_alert_failed", flush=True) - - -@app.route("/run", methods=["POST"]) -def handle_request(): - if request.method != "POST": - return "Method Not Allowed", 405 - return _route_with_runtime_error_fallback(_handle_request) - - -def _handle_dry_run_with_deadline( - *, - recycle_on_timeout: bool = True, - timeout_state: dict[str, bool] | None = None, -): - reports: list[dict] = [] - try: - with enforce_runtime_deadline( - IBKR_DRY_RUN_DEADLINE_SECONDS, - operation="IBKR strategy dry-run request", - ): - response = _route_with_runtime_error_fallback( - _handle_request, - dry_run_only_override=True, - response_body="Dry Run OK", - dry_run_label="strategy dry-run", - persist_report=False, - report_collector=reports, - ) - except RuntimeDeadlineExceeded as exc: - timeout_stage = classify_runtime_deadline_stage(exc) - qualification_caller = classify_runtime_deadline_qualification_caller(exc) - if reports: - diagnostics = reports[-1].setdefault("diagnostics", {}) - if isinstance(diagnostics, dict): - diagnostics["timeout_stage"] = timeout_stage - diagnostics["timeout_qualification_caller"] = qualification_caller - _persist_dry_run_report_with_grace(reports, timeout_context=True) - deadline_event = { - "severity": "ERROR", - "event": "strategy_cycle_deadline_exceeded", - "message": "IBKR strategy dry-run exceeded its application deadline", - "service_name": SERVICE_NAME or os.getenv("K_SERVICE"), - "strategy_profile": STRATEGY_PROFILE, - "timeout_seconds": IBKR_DRY_RUN_DEADLINE_SECONDS, - "timeout_stage": timeout_stage, - "timeout_qualification_caller": qualification_caller, - "worker_recycle_required": True, - "worker_recycle_scheduled": recycle_on_timeout, - } - if reports and reports[-1].get("run_id"): - deadline_event["run_id"] = reports[-1]["run_id"] - trace_id = request.headers.get("X-Cloud-Trace-Context", "").partition("/")[0] - if PROJECT_ID and re.fullmatch(r"[0-9a-f]{32}", trace_id): - deadline_event["logging.googleapis.com/trace"] = f"projects/{PROJECT_ID}/traces/{trace_id}" - print( - json.dumps( - deadline_event, - ensure_ascii=False, - separators=(",", ":"), - ), - flush=True, - ) - if timeout_state is not None: - timeout_state["worker_recycle_required"] = True - if recycle_on_timeout: - recycle_current_process_after_response() - return "Error", 503 - else: - _persist_dry_run_report_with_grace(reports, timeout_context=False) - return response - - -def _persist_dry_run_report_with_grace( - reports: list[dict], - *, - timeout_context: bool, -) -> None: - if not reports: - return - operation = "IBKR timeout report persistence" if timeout_context else "IBKR dry-run report persistence" - try: - with enforce_runtime_deadline( - IBKR_DEADLINE_REPORT_GRACE_SECONDS, - operation=operation, - ): - report_path = persist_execution_report( - reports[-1], - dry_run_only_override=True, - ) - print(f"execution_report {report_path}", flush=True) - except RuntimeDeadlineExceeded as persist_exc: - print(f"dry-run report persistence timed out: {persist_exc}", flush=True) - except Exception as persist_exc: - print(f"failed to persist dry-run report: {persist_exc}", flush=True) - - -@app.route("/dry-run", methods=["POST", "GET"]) -def handle_dry_run(): - return _handle_dry_run_with_deadline() - - -@app.route("/paper-command-consumer", methods=["POST"]) -def handle_paper_execution_command_consumer(): - """Manual-only endpoint for paper command reconciliation evidence.""" - - try: - result = run_paper_execution_command_consumer() - except Exception as exc: - return _handle_route_runtime_error(exc, route_label="POST /paper-command-consumer") - return json.dumps(result, ensure_ascii=False), 200, {"Content-Type": "application/json"} - - -@app.route("/probe", methods=["POST"]) -def handle_probe(): - if request.method != "POST": - return "Method Not Allowed", 405 - return _route_with_runtime_error_fallback(_handle_probe) - - -@app.route("/settle", methods=["POST"]) -def handle_order_settlement(): - from application.settlement_route import handle_settle - import sys - return handle_settle(sys.modules[__name__]) - - -@app.route("/reconcile", methods=["POST"]) -def handle_reconciliation(): - return _route_with_runtime_error_fallback( - _handle_reconciliation, - route_label="broker-reconciliation", - ) - - -@app.route("/live-permission-probe", methods=["POST"]) -def handle_live_permission_probe(): - return _handle_live_permission_probe() - - -@app.route("/monitor-dispatch", methods=["POST", "GET"]) -def handle_monitor_dispatch(): - return _route_with_runtime_error_fallback( - _handle_monitor_dispatch, - route_label="monitor-dispatch", - ) - - -@app.route("/healthz", methods=["GET"]) -@app.route("/health", methods=["GET"]) -def health(): - return health_impl() - +__all__ = [ + 'EXECUTION_BACKEND_GATEWAY', + 'Flask', + 'IBKRGatewayUnavailableError', + 'IBKRPortfolioSnapshotUnavailableError', + 'IBKRReconciliationReadError', + 'IBKRTradingPermissionError', + 'IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON', + 'Mapping', + 'RuntimeDeadlineExceeded', + 'StrategyPluginAlertStateSettings', + 'ZoneInfo', + '_SettledCashDeadlineIB', + '_dispatch_local_monitor', + '_handle_live_permission_probe', + '_handle_monitor_dispatch', + '_handle_probe', + '_handle_reconciliation', + 'append_runtime_report_error', + 'application_check_order_submitted', + 'application_execute_rebalance', + 'application_get_market_prices', + 'attach_cycle_execution_receipt', + 'attach_terminal_fallback_execution_receipt', + 'build_alert_context_label', + 'build_execution_command_store_from_env', + 'build_persistable_reconciliation_report', + 'build_reconciliation_candidate', + 'build_run_id', + 'build_runtime_assembly', + 'build_runtime_broker_adapters', + 'build_runtime_composer', + 'build_runtime_loaded_receipt', + 'build_runtime_report_base', + 'build_runtime_strategy_adapters', + 'build_signal_snapshot', + 'build_strategy_display_name', + 'build_strategy_plugin_report_payload', + 'build_translator', + 'classify_runtime_deadline_qualification_caller', + 'classify_runtime_deadline_stage', + 'coerce_strategy_cycle_result', + 'collect_read_only_reconciliation_observations', + 'consume_due_paper_execution_commands', + 'date', + 'datetime', + 'dca_execution_unsupported_reason', + 'dispatch_due_monitor_targets', + 'dispatch_strategy_plugin_alerts', + 'emit_runtime_log', + 'enforce_runtime_deadline', + 'execute_paper_liquidation', + 'extract_cloud_trace', + 'fetch_historical_price_candles', + 'fetch_historical_price_series', + 'fetch_portfolio_snapshot', + 'fetch_quote_snapshots_with_expected_error_summary', + 'fetch_reconciled_paper_portfolio_snapshot', + 'fetch_yfinance_historical_candles', + 'finalize_runtime_report', + 'get_compute_discovery', + 'hashlib', + 'health_impl', + 'ibkr_connect_ib', + 'ibkr_ensure_event_loop', + 'importlib', + 'is_market_open_now', + 'json', + 'load_configured_strategy_plugin_signals', + 'load_monitor_targets', + 'load_platform_runtime_settings', + 'load_strategy_runtime', + 'lookback_minutes_from_env', + 'map_strategy_decision', + 'max_workers_from_env', + 'normalize_reconciliation_request_id', + 'normalize_reconciliation_scheduler_job_sha256', + 'os', + 'parse_strategy_plugin_mounts', + 'pd', + 'persist_runtime_report', + 'probe_order_write_access', + 'qpk_fetch_quote_snapshots', + 're', + 'recycle_current_process_after_response', + 'replace', + 'request', + 'requests', + 'resolve_cycle_channel_and_url', + 'resolve_ib_gateway_ip_mode', + 'resolve_paper_execution_admission_enabled', + 'resolve_paper_execution_command_consumer_enabled', + 'run_rebalance_cycle', + 'send_telegram_message', + 'signal_provenance', + 'submit_order_intent', + 'threading', + 'time', + 'timedelta', + 'timeout_seconds_from_env', + 'with_notification_run', + 'EXPECTED_EXECUTION_GUARD_REASON_PREFIXES', + 'get_project_id', + 'get_ib_gateway_ip_mode', + 'resolve_gce_instance_ip', + 'get_ib_host', + 'refresh_ib_host', + 'get_ib_gateway_mode', + 'get_ib_port', + 'get_ib_connect_timeout_seconds', + 'get_positive_int_env', + 'get_non_negative_float_env', + '_env_flag', + 'RUNTIME_SETTINGS', + 'IB_HOST', + 'IB_PORT', + 'IB_CLIENT_ID', + 'IB_CONNECT_TIMEOUT_SECONDS', + 'IB_CONNECT_ATTEMPTS', + 'IB_CONNECT_RETRY_DELAY_SECONDS', + 'IB_CLIENT_ID_RETRY_OFFSET', + 'IBKR_DRY_RUN_DEADLINE_SECONDS', + 'IBKR_DEADLINE_REPORT_GRACE_SECONDS', + 'STRATEGY_PROFILE', + 'STRATEGY_DISPLAY_NAME', + '_normalize_plugin_mounts_strategy', + 'ACCOUNT_GROUP', + 'SERVICE_NAME', + 'ACCOUNT_IDS', + 'PROJECT_ID', + 'EXECUTION_BACKEND', + 'QUANTCONNECT_PROJECT_ID', + 'QUANTCONNECT_NODE_ID', + 'MARKET', + 'MARKET_CALENDAR', + 'MARKET_CURRENCY', + 'MARKET_DATA_SYMBOL_SUFFIX', + 'MARKET_EXCHANGE', + 'MARKET_TIMEZONE', + 'STRATEGY_RUNTIME', + 'STRATEGY_ENTRYPOINT', + 'STRATEGY_SIGNAL_SOURCE', + 'STRATEGY_STATUS_ICON', + 'SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS', + 'FEATURE_RUNTIME_PARAMETERS', + 'STRATEGY_RUNTIME_CONFIG', + 'SAFE_HAVEN', + 'RANKING_POOL', + 'CANARY_ASSETS', + 'TOP_N', + 'SMA_PERIOD', + 'CANARY_BAD_THRESHOLD', + 'REBALANCE_MONTHS', + 'FEATURE_SNAPSHOT_PATH', + 'FEATURE_SNAPSHOT_MANIFEST_PATH', + 'FEATURE_RUNTIME_CONFIG_PATH', + 'FEATURE_RUNTIME_CONFIG_SOURCE', + 'RECONCILIATION_OUTPUT_PATH', + 'PAPER_LIQUIDATE_ONLY', + 'TG_TOKEN', + 'TG_CHAT_ID', + 'NOTIFY_LANG', + '_NOTIFICATION_CHANNEL', + '_NOTIFICATION_WEBHOOK_URL', + 'CASH_RESERVE_RATIO', + 'CASH_ONLY_EXECUTION', + 'CASH_RESERVE_FLOOR_USD', + 'REBALANCE_THRESHOLD_RATIO', + 'LIMIT_BUY_PREMIUM', + 'DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL', + 'SELL_SETTLE_DELAY_SEC', + 'HIST_DATA_PACING_SEC', + 'SEPARATOR', + '_load_limit_buy_premium_by_symbol', + 'LIMIT_BUY_PREMIUM_BY_SYMBOL', + 't', + 'strategy_display_name', + 'RUNTIME_LOG_CONTEXT', + 'resolve_reporting_managed_symbols', + 'build_strategy_adapters', + 'format_market_data_symbol', + 'fetch_market_historical_price_series', + 'fetch_market_historical_price_candles', + 'fetch_market_fallback_historical_candles', + 'fetch_market_quote_snapshots', + 'submit_market_order_intent', + 'probe_market_order_write_access', + 'fetch_market_portfolio_snapshot', + 'build_broker_adapters', + 'build_composer', + 'require_gateway_execution_backend', + 'connect_ib', + 'resolve_run_as_of_date', + 'get_historical_close', + 'get_historical_candles', + '_build_cycle_report_summary', + '_build_notification_delivery_log_for_report', + '_build_notification_delivery_summary', + 'build_account_notification_lines', + 'build_extra_notification_lines', + '_split_env_list', + 'send_tg_message', + '_platform_notification_prefix', + '_normalize_account_prefix_label', + '_with_platform_notification_prefix', + 'publish_notification', + '_runtime_error_notification_targets', + '_runtime_error_notification_message', + '_notify_runtime_error', + '_notify_runtime_error_legacy', + '_publish_runtime_failure_notification', + '_handle_route_runtime_error', + '_route_with_runtime_error_fallback', + 'load_strategy_plugin_signals', + 'attach_strategy_plugin_report', + 'build_strategy_plugin_notification_lines', + 'build_strategy_plugin_error_notification_lines', + 'build_strategy_plugin_alert_messages', + 'build_strategy_plugin_alert_state_settings', + 'build_strategy_plugin_alert_context_label', + 'publish_strategy_plugin_alerts', + 'app', + 'ensure_event_loop', + 'NEW_YORK_TZ', + 'STRATEGY_RUN_LOCK', + '_is_execution_failure', + 'log_runtime_event', + 'build_execution_report', + 'persist_execution_report', + 'persist_reconciliation_report', + 'build_request_log_context', + '_previous_market_session', + 'compute_signals', + '_has_signal_snapshot_details', + '_count_orders', + '_account_facts_match_runtime_target', + 'get_current_portfolio', + 'build_portfolio_snapshot', + '_paper_command_consumer_session_date', + '_paper_command_consumer_runtime_is_isolated', + 'run_paper_execution_command_consumer', + 'get_market_prices', + 'check_order_submitted', + 'execute_rebalance', + '_format_liquidation_orders', + 'run_paper_liquidation_cycle', + 'run_strategy_core', + 'attach_signal_data_provenance', + '_handle_request', + 'handle_request', + '_handle_dry_run_with_deadline', + '_persist_dry_run_report_with_grace', + 'handle_dry_run', + 'handle_paper_execution_command_consumer', + 'handle_probe', + 'handle_order_settlement', + 'handle_reconciliation', + 'handle_live_permission_probe', + 'handle_monitor_dispatch', + 'health', +] if __name__ == "__main__": - app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080))) + import os + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080))) diff --git a/notifications_runtime.py b/notifications_runtime.py new file mode 100644 index 00000000..95464e69 --- /dev/null +++ b/notifications_runtime.py @@ -0,0 +1,247 @@ +"""Runtime error / notification helpers for HTTP routes.""" + +from __future__ import annotations + + +import sys + +_FALLBACK_MODULE_NAME = __name__ + + +class _MainModuleProxy: + """Prefer ``main.`` after re-exports; fall back during module import.""" + + def __getattr__(self, name): + main_mod = sys.modules.get("main") + if main_mod is not None and name in getattr(main_mod, "__dict__", {}): + return main_mod.__dict__[name] + return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) + + +main = _MainModuleProxy() + +import hashlib +import json +import importlib +import os +import re +import threading +import time +from collections.abc import Mapping +from dataclasses import replace +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pandas as pd +import requests +from flask import Flask, request + +try: + from quant_platform_kit.cloud import get_compute_discovery +except ImportError: + get_compute_discovery = None + +from application.cycle_result import coerce_strategy_cycle_result +from application.execution_receipt_adapter import ( + attach_cycle_execution_receipt, + attach_terminal_fallback_execution_receipt, +) +from application.broker_reconciliation import ( + IBKRReconciliationReadError, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, +) +from application.reconciliation_reporting import ( + build_persistable_reconciliation_report, + normalize_reconciliation_request_id, + normalize_reconciliation_scheduler_job_sha256, +) +from application.runtime_broker_adapters import ( + IBKRGatewayUnavailableError, + IBKRTradingPermissionError, + build_runtime_broker_adapters, +) +from application.runtime_composer import build_runtime_composer +from application.runtime_deadline import ( + RuntimeDeadlineExceeded, + classify_runtime_deadline_qualification_caller, + classify_runtime_deadline_stage, + enforce_runtime_deadline, + recycle_current_process_after_response, +) +from application import signal_provenance +from application.runtime_strategy_adapters import ( + build_runtime_strategy_adapters, + fetch_yfinance_historical_candles, +) +from application.rebalance_service import run_strategy_core as run_rebalance_cycle +from application.signal_snapshot import build_signal_snapshot +from decision_mapper import map_strategy_decision +from entrypoints.cloud_run import is_market_open_now +from runtime_execution_policy import ( + IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON, + dca_execution_unsupported_reason, +) +from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message +from notifications.unified_adapter import with_run as with_notification_run +from quant_platform_kit.notifications.strategy_plugin_alerts import ( + StrategyPluginAlertStateSettings, + build_strategy_plugin_alert_context_label as build_alert_context_label, + publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, +) +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.execution_commands import build_execution_command_store_from_env +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt +from quant_platform_kit.common.runtime_reports import ( + append_runtime_report_error, + build_runtime_report_base, + finalize_runtime_report, + persist_runtime_report, +) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) +from quant_platform_kit.ibkr import ( + connect_ib as ibkr_connect_ib, + ensure_event_loop as ibkr_ensure_event_loop, + fetch_historical_price_candles, + fetch_historical_price_series, + fetch_quote_snapshots as qpk_fetch_quote_snapshots, +) +from application.ibkr_market_data import fetch_quote_snapshots_with_expected_error_summary +from application.ibkr_order_execution import probe_order_write_access, submit_order_intent +from application.monitor_dispatcher import ( + dispatch_due_monitor_targets, + load_monitor_targets, + lookback_minutes_from_env, + max_workers_from_env, + timeout_seconds_from_env, +) +from application.ibkr_portfolio import ( + IBKRPortfolioSnapshotUnavailableError, + fetch_reconciled_paper_portfolio_snapshot, + fetch_portfolio_snapshot, +) +from application.execution_service import ( + _SettledCashDeadlineIB, + check_order_submitted as application_check_order_submitted, + execute_rebalance as application_execute_rebalance, + get_market_prices as application_get_market_prices, +) +from application.paper_liquidation_service import execute_paper_liquidation +from application.paper_execution_admission import resolve_paper_execution_admission_enabled +from application.paper_execution_command_consumer import ( + consume_due_paper_execution_commands, + resolve_paper_execution_command_consumer_enabled, +) +from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace +from runtime_config_support import ( + EXECUTION_BACKEND_GATEWAY, + load_platform_runtime_settings, + resolve_ib_gateway_ip_mode, +) +from strategy_runtime import load_strategy_runtime +from application.http_routes import ( + _dispatch_local_monitor, + _handle_monitor_dispatch, + _handle_probe, + _handle_reconciliation, + _handle_live_permission_probe, + health_impl, +) + +def _split_env_list(value: str | None) -> tuple[str, ...]: + return tuple((item.strip() for item in str(value or '').replace(';', ',').split(',') if item.strip())) + +def send_tg_message(message): + from quant_platform_kit.notifications.cycle_channel import build_cycle_sender + sender = build_cycle_sender(channel=main._NOTIFICATION_CHANNEL, telegram_token=main.TG_TOKEN, telegram_chat_id=main.TG_CHAT_ID, webhook_url=main._NOTIFICATION_WEBHOOK_URL) + return sender(main._with_platform_notification_prefix(message)) + +def _platform_notification_prefix() -> str: + runtime_target = getattr(main.RUNTIME_SETTINGS, 'runtime_target', None) + account_selector = getattr(runtime_target, 'account_selector', None) or () + for selector in account_selector: + label = main._normalize_account_prefix_label(selector) + if label: + return f'[{label}]' + raw_scope = getattr(runtime_target, 'account_scope', None) or main.ACCOUNT_GROUP + label = main._normalize_account_prefix_label(raw_scope) + if not label: + return '' + return f'[{label}]' + +def _normalize_account_prefix_label(value: object) -> str: + label = str(value or '').strip().upper() + if not label or label in {'DEFAULT', 'LIVE', 'US'}: + return '' + if label.startswith('LIVE-U') and len(label) > len('LIVE-U'): + return label.removeprefix('LIVE-') + return label + +def _with_platform_notification_prefix(message: str) -> str: + prefix = main._platform_notification_prefix() + text = str(message or '') + if not prefix or text.lstrip().startswith(prefix): + return text + return f'{prefix} {text}' + +def publish_notification(*, detailed_text, compact_text): + return main.build_composer().build_notification_adapters().publish_cycle_notification(detailed_text=detailed_text, compact_text=compact_text) + +def _runtime_error_notification_targets() -> tuple[tuple[str, str], ...]: + targets: list[tuple[str, str]] = [] + if main.TG_TOKEN and main.TG_CHAT_ID: + targets.append((main.TG_TOKEN, main.TG_CHAT_ID)) + seen: set[tuple[str, str]] = set() + unique_targets: list[tuple[str, str]] = [] + for target in targets: + if target in seen: + continue + seen.add(target) + unique_targets.append(target) + return tuple(unique_targets) + +def _runtime_error_notification_message(exc: Exception, *, route_label: str | None=None) -> str: + title = 'runtime_failure_title' + return '\n'.join((main.t(title), main.t('strategy_label', name=main.strategy_display_name or main.STRATEGY_PROFILE), main.t('runtime_failure_context', context=main.ACCOUNT_GROUP), main.t('runtime_failure_result'), main.t('runtime_failure_action'))) + +def _notify_runtime_error(exc: Exception, *, route_label: str | None=None) -> bool: + from notifications.unified_adapter import publish_runtime_failure + return publish_runtime_failure(exc, lambda: main._notify_runtime_error_legacy(exc, route_label=route_label)) + +def _notify_runtime_error_legacy(exc: Exception, *, route_label: str | None=None) -> bool: + targets = main._runtime_error_notification_targets() + if not targets: + print(main.t('runtime_notification_missing_target'), flush=True) + return False + message = main._runtime_error_notification_message(exc, route_label=route_label) + outcomes = [] + for token, chat_id in targets: + outcomes.append(main.send_telegram_message(main._with_platform_notification_prefix(message), token=token, chat_id=chat_id, requests_module=main.requests)) + return bool(outcomes) and all(outcomes) + +def _publish_runtime_failure_notification(*, detailed_text: str, compact_text: str, exc: Exception) -> bool: + from notifications.unified_adapter import enabled as unified_enabled, publish_runtime_failure + if unified_enabled(): + return publish_runtime_failure(exc, lambda: main.publish_notification(detailed_text=detailed_text, compact_text=compact_text)) + try: + if main.publish_notification(detailed_text=detailed_text, compact_text=compact_text): + return True + return main._notify_runtime_error(exc) + except Exception: + print(main.t('runtime_notification_delivery_failed'), flush=True) + return main._notify_runtime_error(exc) + +def _handle_route_runtime_error(exc: Exception, *, route_label: str | None=None): + print(main.t('runtime_failure_log'), flush=True) + main._notify_runtime_error(exc, route_label=route_label) + return ('Error', 500) + +def _route_with_runtime_error_fallback(handler, *args, route_label: str | None=None, **kwargs): + try: + return handler(*args, **kwargs) + except Exception as exc: + return main._handle_route_runtime_error(exc, route_label=route_label) diff --git a/runtime_wiring.py b/runtime_wiring.py new file mode 100644 index 00000000..24919b72 --- /dev/null +++ b/runtime_wiring.py @@ -0,0 +1,579 @@ +"""Runtime configuration, adapters, and composer wiring.""" + +from __future__ import annotations + + +import sys + +_FALLBACK_MODULE_NAME = __name__ + + +class _MainModuleProxy: + """Prefer ``main.`` after re-exports; fall back during module import.""" + + def __getattr__(self, name): + main_mod = sys.modules.get("main") + if main_mod is not None and name in getattr(main_mod, "__dict__", {}): + return main_mod.__dict__[name] + return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) + + +main = _MainModuleProxy() + +import hashlib +import json +import importlib +import os +import re +import threading +import time +from collections.abc import Mapping +from dataclasses import replace +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pandas as pd +import requests +from flask import Flask, request + +try: + from quant_platform_kit.cloud import get_compute_discovery +except ImportError: + get_compute_discovery = None + +from application.cycle_result import coerce_strategy_cycle_result +from application.execution_receipt_adapter import ( + attach_cycle_execution_receipt, + attach_terminal_fallback_execution_receipt, +) +from application.broker_reconciliation import ( + IBKRReconciliationReadError, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, +) +from application.reconciliation_reporting import ( + build_persistable_reconciliation_report, + normalize_reconciliation_request_id, + normalize_reconciliation_scheduler_job_sha256, +) +from application.runtime_broker_adapters import ( + IBKRGatewayUnavailableError, + IBKRTradingPermissionError, + build_runtime_broker_adapters, +) +from application.runtime_composer import build_runtime_composer +from application.runtime_deadline import ( + RuntimeDeadlineExceeded, + classify_runtime_deadline_qualification_caller, + classify_runtime_deadline_stage, + enforce_runtime_deadline, + recycle_current_process_after_response, +) +from application import signal_provenance +from application.runtime_strategy_adapters import ( + build_runtime_strategy_adapters, + fetch_yfinance_historical_candles, +) +from application.rebalance_service import run_strategy_core as run_rebalance_cycle +from application.signal_snapshot import build_signal_snapshot +from decision_mapper import map_strategy_decision +from entrypoints.cloud_run import is_market_open_now +from runtime_execution_policy import ( + IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON, + dca_execution_unsupported_reason, +) +from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message +from notifications.unified_adapter import with_run as with_notification_run +from quant_platform_kit.notifications.strategy_plugin_alerts import ( + StrategyPluginAlertStateSettings, + build_strategy_plugin_alert_context_label as build_alert_context_label, + publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, +) +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.execution_commands import build_execution_command_store_from_env +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt +from quant_platform_kit.common.runtime_reports import ( + append_runtime_report_error, + build_runtime_report_base, + finalize_runtime_report, + persist_runtime_report, +) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) +from quant_platform_kit.ibkr import ( + connect_ib as ibkr_connect_ib, + ensure_event_loop as ibkr_ensure_event_loop, + fetch_historical_price_candles, + fetch_historical_price_series, + fetch_quote_snapshots as qpk_fetch_quote_snapshots, +) +from application.ibkr_market_data import fetch_quote_snapshots_with_expected_error_summary +from application.ibkr_order_execution import probe_order_write_access, submit_order_intent +from application.monitor_dispatcher import ( + dispatch_due_monitor_targets, + load_monitor_targets, + lookback_minutes_from_env, + max_workers_from_env, + timeout_seconds_from_env, +) +from application.ibkr_portfolio import ( + IBKRPortfolioSnapshotUnavailableError, + fetch_reconciled_paper_portfolio_snapshot, + fetch_portfolio_snapshot, +) +from application.execution_service import ( + _SettledCashDeadlineIB, + check_order_submitted as application_check_order_submitted, + execute_rebalance as application_execute_rebalance, + get_market_prices as application_get_market_prices, +) +from application.paper_liquidation_service import execute_paper_liquidation +from application.paper_execution_admission import resolve_paper_execution_admission_enabled +from application.paper_execution_command_consumer import ( + consume_due_paper_execution_commands, + resolve_paper_execution_command_consumer_enabled, +) +from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace +from runtime_config_support import ( + EXECUTION_BACKEND_GATEWAY, + load_platform_runtime_settings, + resolve_ib_gateway_ip_mode, +) +from strategy_runtime import load_strategy_runtime +from application.http_routes import ( + _dispatch_local_monitor, + _handle_monitor_dispatch, + _handle_probe, + _handle_reconciliation, + _handle_live_permission_probe, + health_impl, +) + +EXPECTED_EXECUTION_GUARD_REASON_PREFIXES = ('pending_orders_detected:', 'same_day_fills_detected:', 'same_day_execution_locked:') + +def get_project_id(): + try: + from quant_platform_kit.cloud import get_deployment_context + return get_deployment_context().project_id + except Exception: + return main.os.getenv('GOOGLE_CLOUD_PROJECT') + +def get_ib_gateway_ip_mode(): + raw_value = main.os.getenv('IB_GATEWAY_IP_MODE') + if raw_value is None and 'RUNTIME_SETTINGS' in globals(): + raw_value = main.RUNTIME_SETTINGS.ib_gateway_ip_mode + return main.resolve_ib_gateway_ip_mode(raw_value, logger=lambda message: print(message, flush=True)) + +def resolve_gce_instance_ip(instance_name, zone): + gateway_project = getattr(main.RUNTIME_SETTINGS, 'ib_gateway_project_id', None) + if not main.get_compute_discovery: + if gateway_project: + raise main.IBKRGatewayUnavailableError(f'GCE Gateway discovery unavailable for configured project {gateway_project}') + print(f'quant_platform_kit.cloud not installed, using {instance_name} as host directly', flush=True) + return instance_name + try: + ip_mode = main.get_ib_gateway_ip_mode() + project = gateway_project or main.get_project_id() + host = main.get_compute_discovery().resolve_instance_ip(instance_name, zone, project_id=project, prefer_internal=ip_mode == 'internal') + if gateway_project and (not host or host == instance_name): + raise ValueError('GCE Gateway discovery returned no address') + return host + except Exception as exc: + if gateway_project: + raise main.IBKRGatewayUnavailableError(f'GCE Gateway address unavailable in configured project {gateway_project}') from None + print(f'GCE resolve failed for {instance_name}: {exc}, using as hostname', flush=True) + return instance_name + +def get_ib_host(): + global IB_HOST + if IB_HOST: + return IB_HOST + host = main.RUNTIME_SETTINGS.ib_gateway_instance_name + zone = main.RUNTIME_SETTINGS.ib_gateway_zone + if getattr(main.RUNTIME_SETTINGS, 'ib_gateway_project_id', None) and (not zone): + raise main.IBKRGatewayUnavailableError('GCE Gateway zone is required when a Gateway project is configured') + if zone: + host = main.resolve_gce_instance_ip(host, zone) + IB_HOST = host + return host + +def refresh_ib_host(): + """Refresh a zoned gateway address while retaining last-known-good state.""" + global IB_HOST + instance_name = main.RUNTIME_SETTINGS.ib_gateway_instance_name + zone = main.RUNTIME_SETTINGS.ib_gateway_zone + if not zone: + return main.get_ib_host() + cached_host = IB_HOST + try: + resolved_host = main.resolve_gce_instance_ip(instance_name, zone) + except main.IBKRGatewayUnavailableError: + IB_HOST = None + raise + except Exception as exc: + print(f'GCE host refresh failed for {instance_name}: {exc}', flush=True) + resolved_host = cached_host or instance_name + if cached_host and resolved_host == instance_name: + resolved_host = cached_host + IB_HOST = resolved_host + return resolved_host + +def get_ib_gateway_mode(): + return main.RUNTIME_SETTINGS.ib_gateway_mode + +def get_ib_port(): + configured_port = getattr(main.RUNTIME_SETTINGS, 'ib_gateway_port', None) + if configured_port is not None: + return configured_port + return 4002 if main.get_ib_gateway_mode() == 'paper' else 4001 + +def get_ib_connect_timeout_seconds(): + raw_value = main.os.getenv('IBKR_CONNECT_TIMEOUT_SECONDS', '60') + try: + timeout_seconds = int(raw_value) + except (TypeError, ValueError): + print(f'Invalid IBKR_CONNECT_TIMEOUT_SECONDS={raw_value!r}; using 60', flush=True) + return 60 + if timeout_seconds <= 0: + print(f'Invalid IBKR_CONNECT_TIMEOUT_SECONDS={raw_value!r}; using 60', flush=True) + return 60 + return timeout_seconds + +def get_positive_int_env(name, default): + raw_value = main.os.getenv(name, str(default)) + try: + parsed = int(raw_value) + except (TypeError, ValueError): + print(f'Invalid {name}={raw_value!r}; using {default}', flush=True) + return default + if parsed <= 0: + print(f'Invalid {name}={raw_value!r}; using {default}', flush=True) + return default + return parsed + +def get_non_negative_float_env(name, default): + raw_value = main.os.getenv(name, str(default)) + try: + parsed = float(raw_value) + except (TypeError, ValueError): + print(f'Invalid {name}={raw_value!r}; using {default}', flush=True) + return default + if parsed < 0: + print(f'Invalid {name}={raw_value!r}; using {default}', flush=True) + return default + return parsed + +def _env_flag(name: str) -> bool: + return str(main.os.getenv(name) or '').strip().lower() in {'1', 'true', 'yes', 'on'} +RUNTIME_SETTINGS = load_platform_runtime_settings(project_id_resolver=get_project_id) +IB_HOST = None +IB_PORT = get_ib_port() +IB_CLIENT_ID = RUNTIME_SETTINGS.ib_client_id +IB_CONNECT_TIMEOUT_SECONDS = get_ib_connect_timeout_seconds() +IB_CONNECT_ATTEMPTS = get_positive_int_env('IBKR_CONNECT_ATTEMPTS', 3) +IB_CONNECT_RETRY_DELAY_SECONDS = get_non_negative_float_env('IBKR_CONNECT_RETRY_DELAY_SECONDS', 5.0) +IB_CLIENT_ID_RETRY_OFFSET = get_positive_int_env('IBKR_CLIENT_ID_RETRY_OFFSET', 100) +IBKR_DRY_RUN_DEADLINE_SECONDS = get_positive_int_env('IBKR_DRY_RUN_DEADLINE_SECONDS', 105) +IBKR_DEADLINE_REPORT_GRACE_SECONDS = get_positive_int_env('IBKR_DEADLINE_REPORT_GRACE_SECONDS', 5) +STRATEGY_PROFILE = RUNTIME_SETTINGS.strategy_profile +STRATEGY_DISPLAY_NAME = RUNTIME_SETTINGS.strategy_display_name + +def _normalize_plugin_mounts_strategy(raw_mounts_json: str | None) -> str | None: + if not raw_mounts_json: + return raw_mounts_json + try: + mounts = main.json.loads(raw_mounts_json) + except main.json.JSONDecodeError: + return raw_mounts_json + if not isinstance(mounts, dict): + return raw_mounts_json + plugins = mounts.get('strategy_plugins') + if not isinstance(plugins, list): + return raw_mounts_json + changed = False + for entry in plugins: + if not isinstance(entry, dict): + continue + old = entry.get('strategy') + if old and old != main.STRATEGY_PROFILE: + entry['strategy'] = main.STRATEGY_PROFILE + changed = True + print(f'[config-sync] Plugin mount strategy corrected: {old} → {main.STRATEGY_PROFILE}', flush=True) + return main.json.dumps(mounts, ensure_ascii=False) if changed else raw_mounts_json +if hasattr(RUNTIME_SETTINGS, 'strategy_plugin_mounts_json'): + normalized = _normalize_plugin_mounts_strategy(RUNTIME_SETTINGS.strategy_plugin_mounts_json) + if normalized != RUNTIME_SETTINGS.strategy_plugin_mounts_json: + object.__setattr__(RUNTIME_SETTINGS, 'strategy_plugin_mounts_json', normalized) +for _env_name in ('MONITOR_DISPATCH_TARGETS_JSON', 'IBKR_MONITOR_DISPATCH_TARGETS_JSON'): + _raw = os.environ.get(_env_name) + if _raw: + try: + _targets = json.loads(_raw) + except json.JSONDecodeError: + continue + if not isinstance(_targets, dict): + continue + _entries = _targets.get('targets') + if not isinstance(_entries, list): + continue + _changed = False + for _entry in _entries: + if isinstance(_entry, dict): + _old = _entry.get('strategy_profile') + if _old and _old != STRATEGY_PROFILE: + _entry['strategy_profile'] = STRATEGY_PROFILE + _changed = True + print(f'[config-sync] {_env_name} strategy_profile corrected: {_old} → {STRATEGY_PROFILE}', flush=True) + if _changed: + os.environ[_env_name] = json.dumps(_targets, ensure_ascii=False) +ACCOUNT_GROUP = RUNTIME_SETTINGS.account_group +SERVICE_NAME = RUNTIME_SETTINGS.service_name +ACCOUNT_IDS = RUNTIME_SETTINGS.account_ids +PROJECT_ID = RUNTIME_SETTINGS.project_id +EXECUTION_BACKEND = RUNTIME_SETTINGS.execution_backend +QUANTCONNECT_PROJECT_ID = getattr(RUNTIME_SETTINGS, 'quantconnect_project_id', None) +QUANTCONNECT_NODE_ID = getattr(RUNTIME_SETTINGS, 'quantconnect_node_id', None) +MARKET = RUNTIME_SETTINGS.market +MARKET_CALENDAR = RUNTIME_SETTINGS.market_calendar +MARKET_CURRENCY = RUNTIME_SETTINGS.market_currency +MARKET_DATA_SYMBOL_SUFFIX = RUNTIME_SETTINGS.market_data_symbol_suffix +MARKET_EXCHANGE = RUNTIME_SETTINGS.market_exchange +MARKET_TIMEZONE = RUNTIME_SETTINGS.market_timezone +STRATEGY_RUNTIME = load_strategy_runtime(STRATEGY_PROFILE, runtime_settings=RUNTIME_SETTINGS, logger=lambda message: print(message, flush=True)) +STRATEGY_ENTRYPOINT = STRATEGY_RUNTIME.entrypoint +STRATEGY_SIGNAL_SOURCE = 'feature_snapshot' if 'feature_snapshot' in STRATEGY_RUNTIME.required_inputs else 'market_data' +STRATEGY_STATUS_ICON = STRATEGY_RUNTIME.status_icon +SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS = getattr(getattr(STRATEGY_RUNTIME.runtime_adapter, 'runtime_policy', None), 'signal_effective_after_trading_days', None) +FEATURE_RUNTIME_PARAMETERS = dict(STRATEGY_RUNTIME.runtime_config) +STRATEGY_RUNTIME_CONFIG = dict(STRATEGY_RUNTIME.merged_runtime_config) +SAFE_HAVEN = str(STRATEGY_RUNTIME_CONFIG.get('safe_haven') or 'BIL') +RANKING_POOL = list(STRATEGY_RUNTIME_CONFIG.get('ranking_pool', ())) +CANARY_ASSETS = list(STRATEGY_RUNTIME_CONFIG.get('canary_assets', ())) +TOP_N = STRATEGY_RUNTIME_CONFIG.get('top_n') +SMA_PERIOD = int(STRATEGY_RUNTIME_CONFIG.get('sma_period', 200)) +CANARY_BAD_THRESHOLD = STRATEGY_RUNTIME_CONFIG.get('canary_bad_threshold') +REBALANCE_MONTHS = STRATEGY_RUNTIME_CONFIG.get('rebalance_months') +FEATURE_SNAPSHOT_PATH = RUNTIME_SETTINGS.feature_snapshot_path +FEATURE_SNAPSHOT_MANIFEST_PATH = RUNTIME_SETTINGS.feature_snapshot_manifest_path +FEATURE_RUNTIME_CONFIG_PATH = STRATEGY_RUNTIME_CONFIG.get('runtime_config_path') or RUNTIME_SETTINGS.strategy_config_path +FEATURE_RUNTIME_CONFIG_SOURCE = STRATEGY_RUNTIME_CONFIG.get('runtime_config_source') or RUNTIME_SETTINGS.strategy_config_source +RECONCILIATION_OUTPUT_PATH = RUNTIME_SETTINGS.reconciliation_output_path +PAPER_LIQUIDATE_ONLY = _env_flag('IBKR_PAPER_LIQUIDATE_ONLY') +TG_TOKEN = RUNTIME_SETTINGS.tg_token +TG_CHAT_ID = RUNTIME_SETTINGS.tg_chat_id +from quant_platform_kit.notifications.cycle_channel import resolve_cycle_channel_and_url +NOTIFY_LANG = RUNTIME_SETTINGS.notify_lang +_NOTIFICATION_CHANNEL, _NOTIFICATION_WEBHOOK_URL = resolve_cycle_channel_and_url(explicit_channel=RUNTIME_SETTINGS.notification_channel, telegram_token=TG_TOKEN, telegram_chat_id=TG_CHAT_ID, wecom_url=RUNTIME_SETTINGS.wecom_webhook_url, dingtalk_url=RUNTIME_SETTINGS.dingtalk_webhook_url, feishu_url=RUNTIME_SETTINGS.feishu_webhook_url, serverchan_url=RUNTIME_SETTINGS.serverchan_webhook_url) +CASH_RESERVE_RATIO = STRATEGY_RUNTIME.cash_reserve_ratio +CASH_ONLY_EXECUTION = getattr(RUNTIME_SETTINGS, 'cash_only_execution', True) +CASH_RESERVE_FLOOR_USD = getattr(STRATEGY_RUNTIME, 'cash_reserve_floor_usd', 0.0) +REBALANCE_THRESHOLD_RATIO = STRATEGY_RUNTIME.rebalance_threshold_ratio +LIMIT_BUY_PREMIUM = 1.005 +DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {'SOXL': 1.015, 'TQQQ': 1.01} +SELL_SETTLE_DELAY_SEC = 3 +HIST_DATA_PACING_SEC = 0.5 +SEPARATOR = '━━━━━━━━━━━━━━━━━━' + +def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]: + raw_value = '' + for env_name in env_names: + value = main.os.getenv(env_name) + if value and value.strip(): + raw_value = value.strip() + break + if not raw_value: + return dict(main.DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL) + try: + payload = main.json.loads(raw_value) + except main.json.JSONDecodeError as exc: + raise ValueError(f'Invalid limit buy premium map JSON: {raw_value!r}') from exc + if not isinstance(payload, dict): + raise ValueError('Limit buy premium map must be a JSON object keyed by symbol.') + parsed: dict[str, float] = {} + for symbol, premium in payload.items(): + symbol_text = str(symbol or '').strip().upper() + if not symbol_text: + continue + premium_value = float(premium) + if premium_value <= 0.0: + raise ValueError(f'Limit buy premium for {symbol_text} must be positive.') + parsed[symbol_text] = premium_value + return parsed +LIMIT_BUY_PREMIUM_BY_SYMBOL = _load_limit_buy_premium_by_symbol('IBKR_LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON', 'LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON') + +def t(key, **kwargs): + return main.build_translator(main.NOTIFY_LANG)(key, **kwargs) +strategy_display_name = build_strategy_display_name(t, NOTIFY_LANG)(STRATEGY_PROFILE, fallback_name=STRATEGY_DISPLAY_NAME, metadata=RUNTIME_SETTINGS.strategy_metadata) +RUNTIME_LOG_CONTEXT = build_runtime_assembly(platform='interactive_brokers', deploy_target='cloud_run', service_name=SERVICE_NAME or os.getenv('K_SERVICE', 'interactive-brokers-platform'), strategy_profile=STRATEGY_PROFILE, runtime_target=RUNTIME_SETTINGS.runtime_target, account_scope=ACCOUNT_GROUP, account_group=ACCOUNT_GROUP, project_id=PROJECT_ID, instance_name=RUNTIME_SETTINGS.ib_gateway_instance_name, extra_context_fields={'account_ids': list(ACCOUNT_IDS), 'strategy_target_mode': RUNTIME_SETTINGS.strategy_target_mode, 'strategy_artifact_dir': RUNTIME_SETTINGS.strategy_artifact_dir, 'strategy_display_name': STRATEGY_DISPLAY_NAME, 'strategy_display_name_localized': strategy_display_name, 'execution_backend': EXECUTION_BACKEND, 'ib_connect_attempts': IB_CONNECT_ATTEMPTS, 'ib_client_id_retry_offset': IB_CLIENT_ID_RETRY_OFFSET, 'quantconnect_project_id': QUANTCONNECT_PROJECT_ID, 'quantconnect_node_id': QUANTCONNECT_NODE_ID, 'market': MARKET, 'market_calendar': MARKET_CALENDAR, 'market_currency': MARKET_CURRENCY, 'market_data_symbol_suffix': MARKET_DATA_SYMBOL_SUFFIX, 'market_exchange': MARKET_EXCHANGE, 'market_timezone': MARKET_TIMEZONE}).build_log_context(run_id='') + +def resolve_reporting_managed_symbols() -> tuple[str, ...]: + configured_managed_symbols = main.STRATEGY_RUNTIME_CONFIG.get('managed_symbols') + fallback_managed_symbols = tuple(dict.fromkeys([*main.RANKING_POOL, main.SAFE_HAVEN])) if main.RANKING_POOL else (main.SAFE_HAVEN,) + return tuple((str(symbol) for symbol in configured_managed_symbols or fallback_managed_symbols if str(symbol or '').strip())) + +def build_strategy_adapters(): + return main.build_runtime_strategy_adapters(strategy_runtime=main.STRATEGY_RUNTIME, strategy_profile=main.STRATEGY_PROFILE, translator=main.t, pacing_sec=main.HIST_DATA_PACING_SEC, resolve_run_as_of_date_fn=main.resolve_run_as_of_date, fetch_historical_price_series_fn=main.fetch_market_historical_price_series, fetch_historical_price_candles_fn=main.fetch_market_historical_price_candles, map_strategy_decision_fn=main.map_strategy_decision, fallback_historical_candles_fn=main.fetch_market_fallback_historical_candles, build_strategy_plugin_report_payload_fn=main.build_strategy_plugin_report_payload, load_configured_strategy_plugin_signals_fn=main.load_configured_strategy_plugin_signals, parse_strategy_plugin_mounts_fn=main.parse_strategy_plugin_mounts) + +def format_market_data_symbol(symbol: str) -> str: + value = str(symbol or '').strip().upper() + if not value or not main.MARKET_DATA_SYMBOL_SUFFIX or '.' in value: + return value + return f'{value}{main.MARKET_DATA_SYMBOL_SUFFIX}' + +def fetch_market_historical_price_series(ib, symbol, **kwargs): + return main.fetch_historical_price_series(ib, str(symbol).strip().upper(), exchange=main.MARKET_EXCHANGE, currency=main.MARKET_CURRENCY, **kwargs) + +def fetch_market_historical_price_candles(ib, symbol, **kwargs): + return main.fetch_historical_price_candles(ib, str(symbol).strip().upper(), exchange=main.MARKET_EXCHANGE, currency=main.MARKET_CURRENCY, **kwargs) + +def fetch_market_fallback_historical_candles(symbol, **kwargs): + return main.fetch_yfinance_historical_candles(main.format_market_data_symbol(symbol), **kwargs) + +def fetch_market_quote_snapshots(ib, symbols, **kwargs): + return main.fetch_quote_snapshots_with_expected_error_summary(ib, symbols, fetch_quote_snapshots=main.qpk_fetch_quote_snapshots, exchange=main.MARKET_EXCHANGE, currency=main.MARKET_CURRENCY, **kwargs) + +def submit_market_order_intent(ib, order_intent, *, account_execution_session=None, execution_grant=None, **kwargs): + from quant_platform_kit.common import execution_state as contract + session_type = getattr(contract, 'AccountSubmissionSession', ()) + if not isinstance(account_execution_session, session_type) or account_execution_session.broker != 'ibkr' or account_execution_session.contract_version != 'account_submission.v1': + raise RuntimeError('IBKR account submission session/contract required') + if isinstance(ib, main._SettledCashDeadlineIB) and execution_grant is None: + prepared = ib.prepare_intent(order_intent, exchange=main.MARKET_EXCHANGE, currency=main.MARKET_CURRENCY) + if prepared is None: + from quant_platform_kit.common.models import ExecutionReport + return ExecutionReport(symbol=order_intent.symbol, side=order_intent.side, quantity=order_intent.quantity, status='rejected', raw_payload={'detail': 'settled_cash_changed_before_reservation'}) + order_intent = prepared + execution_grant = account_execution_session.reserve(order_intent, new_risk=True) + + def submit_with_observation(): + from application.settlement_reporting import settlement_enabled + + def submit(broker): + return main.submit_order_intent(broker, order_intent, stock_exchange=main.MARKET_EXCHANGE, stock_currency=main.MARKET_CURRENCY, **kwargs) + if not settlement_enabled(): + return submit(ib) + from application.order_settlement import SettlementStore, track_submission + from notifications.unified_adapter import _RUN + source = _RUN.get() or {} + wait = float(main.os.getenv('IBKR_ORDER_SETTLEMENT_WAIT_SECONDS', '5')) + if not 0 <= wait <= 30: + raise ValueError('settlement_wait_invalid') + return track_submission(ib, order_intent, submit=submit, store=SettlementStore(account_execution_session.store, account_id=account_execution_session.account_id, profile=account_execution_session.profile), attempt_id=execution_grant.attempt_id, source_run_id=source.get('run_id'), wait_seconds=wait, observation_ib=ib._ib if isinstance(ib, main._SettledCashDeadlineIB) else ib, source_started_at=source.get('started_at')) + return account_execution_session.dispatch(execution_grant, order_intent, submit_with_observation) + +def probe_market_order_write_access(ib): + """Probe order-write access with a low-notional safe-haven what-if buy. + + Do not use the first managed growth symbol (e.g. SOXL): one share can exceed + small-account initial margin and emit Error 201 even though write access is OK. + """ + if len(main.ACCOUNT_IDS) != 1: + raise RuntimeError('IBKR what-if permission probe requires exactly one account_id') + probe_symbol = str(main.SAFE_HAVEN or 'BIL').strip().upper() or 'BIL' + return main.probe_order_write_access(ib, symbol=probe_symbol, account_id=main.ACCOUNT_IDS[0], stock_exchange=main.MARKET_EXCHANGE, stock_currency=main.MARKET_CURRENCY) + +def fetch_market_portfolio_snapshot(ib, **kwargs): + return main.fetch_portfolio_snapshot(ib, currency=main.MARKET_CURRENCY, cash_only_execution=main.CASH_ONLY_EXECUTION, **kwargs) + +def build_broker_adapters(*, dry_run_only_override: bool | None=None): + effective_dry_run_only = main.RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) + effective_execution_mode = 'dry_run' if effective_dry_run_only else main.RUNTIME_SETTINGS.ib_gateway_mode + expected_strategy_release = main.RUNTIME_SETTINGS.runtime_target.strategy_release if main.RUNTIME_SETTINGS.runtime_target is not None else None + + def connect_runtime_ib(host, port, client_id, *, timeout): + return main.ibkr_connect_ib(host, port, client_id, timeout=timeout, readonly=effective_dry_run_only) + return main.build_runtime_broker_adapters(host_resolver=main.get_ib_host, refresh_host_fn=main.refresh_ib_host, trading_permission_probe_fn=main.probe_market_order_write_access, ib_port=main.IB_PORT, ib_client_id=main.IB_CLIENT_ID, connect_timeout_seconds=main.IB_CONNECT_TIMEOUT_SECONDS, connect_attempts=main.IB_CONNECT_ATTEMPTS, connect_retry_delay_seconds=main.IB_CONNECT_RETRY_DELAY_SECONDS, client_id_retry_offset=main.IB_CLIENT_ID_RETRY_OFFSET, ensure_event_loop_fn=main.ensure_event_loop, connect_ib_fn=connect_runtime_ib, fetch_portfolio_snapshot_fn=main.fetch_market_portfolio_snapshot, fetch_quote_snapshots_fn=main.fetch_market_quote_snapshots, submit_order_intent_fn=main.submit_market_order_intent, application_get_market_prices_fn=main.application_get_market_prices, application_check_order_submitted_fn=main.application_check_order_submitted, application_execute_rebalance_fn=main.application_execute_rebalance, execute_paper_liquidation_fn=main.execute_paper_liquidation, translator=main.t, strategy_profile=main.STRATEGY_PROFILE, account_group=main.ACCOUNT_GROUP, service_name=main.SERVICE_NAME, account_ids=tuple(main.ACCOUNT_IDS), dry_run_only=effective_dry_run_only, cash_only_execution=main.CASH_ONLY_EXECUTION, cash_reserve_ratio=main.CASH_RESERVE_RATIO, cash_reserve_floor_usd=main.CASH_RESERVE_FLOOR_USD, rebalance_threshold_ratio=main.REBALANCE_THRESHOLD_RATIO, limit_buy_premium=main.LIMIT_BUY_PREMIUM, limit_buy_premium_by_symbol=main.LIMIT_BUY_PREMIUM_BY_SYMBOL, quantity_step=main.RUNTIME_SETTINGS.quantity_step, min_order_notional=main.RUNTIME_SETTINGS.min_order_notional, safe_haven_cash_substitute_threshold_usd=main.RUNTIME_SETTINGS.safe_haven_cash_substitute_threshold_usd, sell_settle_delay_sec=main.SELL_SETTLE_DELAY_SEC, separator=main.SEPARATOR, strategy_display_name=main.strategy_display_name, sleep_fn=main.time.sleep, market_currency=main.MARKET_CURRENCY, execution_mode=effective_execution_mode, paper_execution_admission_enabled=main.resolve_paper_execution_admission_enabled(env_reader=main.os.getenv, dry_run_only=effective_dry_run_only, execution_mode=effective_execution_mode, execution_environment=main.RUNTIME_SETTINGS.runtime_target.execution_environment if main.RUNTIME_SETTINGS.runtime_target is not None else None), runtime_release_receipt=main.build_runtime_loaded_receipt(strategy_release=expected_strategy_release), expected_strategy_release=expected_strategy_release, printer=print) + +def build_composer(*, dry_run_only_override: bool | None=None, strategy_plugin_signals=()): + effective_dry_run_only = main.RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) + + def compute_signals_fn(ib, current_holdings): + if strategy_plugin_signals: + return main.compute_signals(ib, current_holdings, strategy_plugin_signals=strategy_plugin_signals) + return main.compute_signals(ib, current_holdings) + + def connect_ib_for_cycle(): + return main.connect_ib(read_only=bool(effective_dry_run_only), validate_trading_permissions=not bool(effective_dry_run_only)) + return main.build_runtime_composer(service_name=main.SERVICE_NAME or main.os.getenv('K_SERVICE', 'interactive-brokers-platform'), strategy_profile=main.STRATEGY_PROFILE, strategy_domain=main.RUNTIME_SETTINGS.strategy_domain, account_group=main.ACCOUNT_GROUP, project_id=main.PROJECT_ID, instance_name=main.RUNTIME_SETTINGS.ib_gateway_instance_name, account_ids=tuple(main.ACCOUNT_IDS), strategy_target_mode=main.RUNTIME_SETTINGS.strategy_target_mode, strategy_artifact_dir=main.RUNTIME_SETTINGS.strategy_artifact_dir, strategy_display_name=main.STRATEGY_DISPLAY_NAME, strategy_display_name_localized=main.strategy_display_name, managed_symbols=main.resolve_reporting_managed_symbols(), signal_effective_after_trading_days=main.SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, signal_source=main.STRATEGY_SIGNAL_SOURCE, status_icon=main.STRATEGY_STATUS_ICON, safe_haven=main.SAFE_HAVEN, dry_run_only=effective_dry_run_only, strategy_config_source=main.FEATURE_RUNTIME_CONFIG_SOURCE, ib_gateway_host_resolver=main.get_ib_host, ib_gateway_port=main.IB_PORT, ib_gateway_mode=main.RUNTIME_SETTINGS.ib_gateway_mode, ib_gateway_ip_mode=main.RUNTIME_SETTINGS.ib_gateway_ip_mode, ib_client_id=main.IB_CLIENT_ID, ib_connect_timeout_seconds=main.IB_CONNECT_TIMEOUT_SECONDS, feature_snapshot_path=main.FEATURE_SNAPSHOT_PATH, feature_snapshot_manifest_path=main.FEATURE_SNAPSHOT_MANIFEST_PATH, strategy_config_path=main.FEATURE_RUNTIME_CONFIG_PATH, reconciliation_output_path=main.RECONCILIATION_OUTPUT_PATH, translator=main.t, separator=main.SEPARATOR, send_message=main.send_tg_message, notification_channel=main._NOTIFICATION_CHANNEL, connect_ib_fn=connect_ib_for_cycle, build_portfolio_snapshot_fn=main.build_portfolio_snapshot, compute_signals_fn=compute_signals_fn, execute_rebalance_fn=lambda ib, target_weights, positions, account_values, **kwargs: main.execute_rebalance(ib, target_weights, positions, account_values, dry_run_only_override=effective_dry_run_only, **kwargs), run_id_builder=main.build_run_id, event_logger=main.emit_runtime_log, report_builder=main.build_runtime_report_base, report_persister=main.persist_runtime_report, trace_extractor=main.extract_cloud_trace, env_reader=main.os.getenv, printer=print, runtime_target=main.RUNTIME_SETTINGS.runtime_target, extra_reporting_fields={'execution_backend': main.EXECUTION_BACKEND, 'quantconnect_project_id': main.QUANTCONNECT_PROJECT_ID, 'quantconnect_node_id': main.QUANTCONNECT_NODE_ID, 'market': main.MARKET, 'market_calendar': main.MARKET_CALENDAR, 'market_currency': main.MARKET_CURRENCY, 'market_data_symbol_suffix': main.MARKET_DATA_SYMBOL_SUFFIX, 'market_exchange': main.MARKET_EXCHANGE, 'market_timezone': main.MARKET_TIMEZONE}) + +def require_gateway_execution_backend(): + if main.EXECUTION_BACKEND == main.EXECUTION_BACKEND_GATEWAY: + return + raise RuntimeError(f'IBKR execution_backend={main.EXECUTION_BACKEND!r} is configured; Gateway connection and direct order execution are disabled for this service. Run the QuantConnect deployment/algorithm path for this account group.') + +def connect_ib(*, read_only: bool=False, validate_trading_permissions: bool=True): + """Connect to the Gateway with an optional read-only session boundary.""" + main.require_gateway_execution_backend() + if read_only and (type(main.IB_CLIENT_ID) is not int or main.IB_CLIENT_ID <= 0): + raise RuntimeError('read-only Gateway access requires a configured nonzero client ID') + adapter_dry_run_override = True if read_only else None + adapters = main.build_broker_adapters(dry_run_only_override=adapter_dry_run_override) + if read_only: + adapters = main.replace(adapters, connect_attempts=1) + return adapters.connect_ib(validate_trading_permissions=validate_trading_permissions, redact_connection_diagnostics=read_only) + +def resolve_run_as_of_date() -> pd.Timestamp: + explicit = main.os.getenv('IBKR_RUN_AS_OF_DATE') + if explicit: + return main.pd.Timestamp(explicit).normalize() + run_date = main.datetime.now(main.NEW_YORK_TZ).date() + if main.STRATEGY_PROFILE == 'tqqq_growth_income' and main.RUNTIME_SETTINGS.runtime_target is not None and (main.RUNTIME_SETTINGS.runtime_target.execution_mode == 'live'): + return main._previous_market_session(run_date) + return main.pd.Timestamp(run_date) + +def get_historical_close(ib, symbol, duration='2 Y', bar_size='1 day'): + return main.build_strategy_adapters().get_historical_close(ib, symbol, duration=duration, bar_size=bar_size) + +def get_historical_candles(ib, symbol, duration='2 Y', bar_size='1 day'): + return main.build_strategy_adapters().get_historical_candles(ib, symbol, duration=duration, bar_size=bar_size) + +def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, *, dry_run: bool) -> dict: + from application.reconciliation_service import account_brake_submission_status + brake_status = account_brake_submission_status(getattr(cycle_result, 'signal_metadata', None)) + orders_submitted_count = main._count_orders(execution_summary.get('orders_submitted'), reconciliation_record.get('orders_submitted')) + if brake_status is not None: + orders_submitted_count = None + orders_skipped_count = main._count_orders(execution_summary.get('orders_skipped'), reconciliation_record.get('orders_skipped')) + orders_previewed_count = orders_submitted_count if dry_run else 0 + 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': None if dry_run and orders_previewed_count is None else 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)} + if brake_status is not None: + summary['account_brake_submission_status'] = brake_status + summary['orders_submitted_coverage'] = 'strategy_only_account_brake_details_unavailable' + if main._account_facts_match_runtime_target(cycle_result.account_facts): + summary['account_facts'] = cycle_result.account_facts + quote_snapshot = execution_summary.get('quote_snapshot') or reconciliation_record.get('quote_snapshot') + if quote_snapshot: + summary['quote_snapshot'] = quote_snapshot + from notifications.unified_adapter import digest_evidence + summary['notification_digest'] = digest_evidence(execution_summary, dry_run=dry_run) + outcome_keys = ('orders_filled', 'orders_pending', 'orders_partially_filled', 'option_orders_filled', 'option_orders_pending', 'option_orders_partially_filled') + if not dry_run and brake_status is None and all((isinstance(execution_summary.get(key), (list, tuple)) for key in outcome_keys)): + summary['orders_submitted_count'] = summary['notification_digest']['order_count'] + return summary + +def _build_notification_delivery_log_for_report(*, platform: str, strategy_profile: str, run_id: str, dry_run: bool, orders_previewed_count: int | None, 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 is None 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 _build_notification_delivery_summary(delivery_events: list[dict]) -> dict: + safe_fields = ('sink', 'delivery_status', 'transport_acknowledged', 'error_type', 'compact_text_sha256', 'compact_text_length') + events = [{key: event[key] for key in safe_fields if key in event} for event in (dict(item) for item in delivery_events)] + if not events: + return {} + sent_count = sum((event.get('delivery_status') == 'sent' for event in events)) + failed_count = sum((event.get('delivery_status') == 'failed' for event in events)) + return {'attempted_count': len(events), 'sent_count': sent_count, 'failed_count': failed_count, 'all_acknowledged': failed_count == 0 and sent_count == len(events), 'delivery_events': events} + +def build_account_notification_lines() -> tuple[str, ...]: + account_ids = tuple((str(account_id).strip() for account_id in main.ACCOUNT_IDS if str(account_id).strip())) + if not account_ids: + return () + return (main.t('account_ids_detail', account_ids=', '.join(account_ids)),) + +def build_extra_notification_lines(strategy_plugin_signals=(), *, strategy_plugin_error: str | None=None) -> tuple[str, ...]: + return (main.t('market_scope_detail', market=main.MARKET, currency=main.MARKET_CURRENCY, exchange=main.MARKET_EXCHANGE, calendar=main.MARKET_CALENDAR), *main.build_account_notification_lines(), *main.build_strategy_plugin_notification_lines(strategy_plugin_signals), *main.build_strategy_plugin_error_notification_lines(strategy_plugin_error)) diff --git a/strategy_plugins_runtime.py b/strategy_plugins_runtime.py new file mode 100644 index 00000000..321bdb19 --- /dev/null +++ b/strategy_plugins_runtime.py @@ -0,0 +1,181 @@ +"""Strategy plugin signal presentation and alert dispatch.""" + +from __future__ import annotations + + +import sys + +_FALLBACK_MODULE_NAME = __name__ + + +class _MainModuleProxy: + """Prefer ``main.`` after re-exports; fall back during module import.""" + + def __getattr__(self, name): + main_mod = sys.modules.get("main") + if main_mod is not None and name in getattr(main_mod, "__dict__", {}): + return main_mod.__dict__[name] + return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) + + +main = _MainModuleProxy() + +import hashlib +import json +import importlib +import os +import re +import threading +import time +from collections.abc import Mapping +from dataclasses import replace +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pandas as pd +import requests +from flask import Flask, request + +try: + from quant_platform_kit.cloud import get_compute_discovery +except ImportError: + get_compute_discovery = None + +from application.cycle_result import coerce_strategy_cycle_result +from application.execution_receipt_adapter import ( + attach_cycle_execution_receipt, + attach_terminal_fallback_execution_receipt, +) +from application.broker_reconciliation import ( + IBKRReconciliationReadError, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, +) +from application.reconciliation_reporting import ( + build_persistable_reconciliation_report, + normalize_reconciliation_request_id, + normalize_reconciliation_scheduler_job_sha256, +) +from application.runtime_broker_adapters import ( + IBKRGatewayUnavailableError, + IBKRTradingPermissionError, + build_runtime_broker_adapters, +) +from application.runtime_composer import build_runtime_composer +from application.runtime_deadline import ( + RuntimeDeadlineExceeded, + classify_runtime_deadline_qualification_caller, + classify_runtime_deadline_stage, + enforce_runtime_deadline, + recycle_current_process_after_response, +) +from application import signal_provenance +from application.runtime_strategy_adapters import ( + build_runtime_strategy_adapters, + fetch_yfinance_historical_candles, +) +from application.rebalance_service import run_strategy_core as run_rebalance_cycle +from application.signal_snapshot import build_signal_snapshot +from decision_mapper import map_strategy_decision +from entrypoints.cloud_run import is_market_open_now +from runtime_execution_policy import ( + IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON, + dca_execution_unsupported_reason, +) +from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message +from notifications.unified_adapter import with_run as with_notification_run +from quant_platform_kit.notifications.strategy_plugin_alerts import ( + StrategyPluginAlertStateSettings, + build_strategy_plugin_alert_context_label as build_alert_context_label, + publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, +) +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.execution_commands import build_execution_command_store_from_env +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt +from quant_platform_kit.common.runtime_reports import ( + append_runtime_report_error, + build_runtime_report_base, + finalize_runtime_report, + persist_runtime_report, +) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) +from quant_platform_kit.ibkr import ( + connect_ib as ibkr_connect_ib, + ensure_event_loop as ibkr_ensure_event_loop, + fetch_historical_price_candles, + fetch_historical_price_series, + fetch_quote_snapshots as qpk_fetch_quote_snapshots, +) +from application.ibkr_market_data import fetch_quote_snapshots_with_expected_error_summary +from application.ibkr_order_execution import probe_order_write_access, submit_order_intent +from application.monitor_dispatcher import ( + dispatch_due_monitor_targets, + load_monitor_targets, + lookback_minutes_from_env, + max_workers_from_env, + timeout_seconds_from_env, +) +from application.ibkr_portfolio import ( + IBKRPortfolioSnapshotUnavailableError, + fetch_reconciled_paper_portfolio_snapshot, + fetch_portfolio_snapshot, +) +from application.execution_service import ( + _SettledCashDeadlineIB, + check_order_submitted as application_check_order_submitted, + execute_rebalance as application_execute_rebalance, + get_market_prices as application_get_market_prices, +) +from application.paper_liquidation_service import execute_paper_liquidation +from application.paper_execution_admission import resolve_paper_execution_admission_enabled +from application.paper_execution_command_consumer import ( + consume_due_paper_execution_commands, + resolve_paper_execution_command_consumer_enabled, +) +from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace +from runtime_config_support import ( + EXECUTION_BACKEND_GATEWAY, + load_platform_runtime_settings, + resolve_ib_gateway_ip_mode, +) +from strategy_runtime import load_strategy_runtime +from application.http_routes import ( + _dispatch_local_monitor, + _handle_monitor_dispatch, + _handle_probe, + _handle_reconciliation, + _handle_live_permission_probe, + health_impl, +) + +def load_strategy_plugin_signals(): + return main.build_strategy_adapters().load_strategy_plugin_signals(getattr(main.RUNTIME_SETTINGS, 'strategy_plugin_mounts_json', None)) + +def attach_strategy_plugin_report(report, *, signals, error: str | None=None): + main.build_strategy_adapters().attach_strategy_plugin_report(report, signals=signals, error=error) + +def build_strategy_plugin_notification_lines(signals) -> tuple[str, ...]: + return main.build_strategy_adapters().build_strategy_plugin_notification_lines(signals) + +def build_strategy_plugin_error_notification_lines(error) -> tuple[str, ...]: + return main.build_strategy_adapters().build_strategy_plugin_error_notification_lines(error) + +def build_strategy_plugin_alert_messages(signals): + return main.build_strategy_adapters().build_strategy_plugin_alert_messages(signals) + +def build_strategy_plugin_alert_state_settings(): + return main.StrategyPluginAlertStateSettings.from_env(project_id=main.PROJECT_ID) + +def build_strategy_plugin_alert_context_label() -> str: + return main.build_alert_context_label(platform_id='ibkr', strategy_profile=main.STRATEGY_PROFILE, account_scope=main.ACCOUNT_GROUP, service_name=main.SERVICE_NAME, runtime_target=main.RUNTIME_SETTINGS.runtime_target) + +def publish_strategy_plugin_alerts(signals, *, report=None): + from quant_platform_kit.notifications.digest_routing import immediate_plugin_signals + result = main.dispatch_strategy_plugin_alerts(immediate_plugin_signals(signals, mode='daily_digest'), notification_settings=main.RUNTIME_SETTINGS, translator=main.t, strategy_label=main.STRATEGY_PROFILE, context_label=main.build_strategy_plugin_alert_context_label(), state_settings=main.build_strategy_plugin_alert_state_settings(), log_message=print) + if report is not None: + result.attach_to_report(report) + return result From 5ff405f2d8ca745d1f27ca4fd32bfdb373ee742d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:33:50 +0800 Subject: [PATCH 2/8] fix: thin main facade and update notification source scans Align IBKR entry with explicit re-export facade and point i18n/handler extract tests at notifications_runtime and http_routes after the split. Co-authored-by: Cursor --- http_routes.py | 10 +- main.py | 383 ++++++------------------ notifications_runtime.py | 10 +- runtime_wiring.py | 10 +- strategy_plugins_runtime.py | 10 +- tests/test_runtime_notification_i18n.py | 15 +- 6 files changed, 139 insertions(+), 299 deletions(-) diff --git a/http_routes.py b/http_routes.py index 3c43ddfe..179cc4ac 100644 --- a/http_routes.py +++ b/http_routes.py @@ -9,12 +9,16 @@ class _MainModuleProxy: - """Prefer ``main.`` after re-exports; fall back during module import.""" + """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): main_mod = sys.modules.get("main") - if main_mod is not None and name in getattr(main_mod, "__dict__", {}): - return main_mod.__dict__[name] + if main_mod is not None: + # Use getattr so ModuleType facades can resolve mutable globals. + try: + return getattr(main_mod, name) + except AttributeError: + pass return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) diff --git a/main.py b/main.py index 9ada0862..5a71fbc3 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,17 @@ -"""Platform HTTP entrypoint with explicit named re-exports.""" +"""InteractiveBrokersPlatform HTTP entrypoint. +Thin facade over the platform split modules. Public helpers are +re-exported by explicit name so existing ``import main`` / monkeypatch +call sites keep working. Mutable process globals resolve through the +module facade so rebinding inside implementation modules stays visible. +""" from __future__ import annotations import sys +import types +# Reload split modules whenever main is (re)imported so test harnesses +# that only pop ``main`` still observe fresh env/stubs. for _split_module_name in ( "runtime_wiring", "notifications_runtime", @@ -13,137 +21,10 @@ sys.modules.pop(_split_module_name, None) del _split_module_name -import hashlib -import json -import importlib -import os -import re -import threading -import time -from collections.abc import Mapping -from dataclasses import replace -from datetime import date, datetime, timedelta -from zoneinfo import ZoneInfo - -import pandas as pd -import requests -from flask import Flask, request - -try: - from quant_platform_kit.cloud import get_compute_discovery -except ImportError: - get_compute_discovery = None - -from application.cycle_result import coerce_strategy_cycle_result -from application.execution_receipt_adapter import ( - attach_cycle_execution_receipt, - attach_terminal_fallback_execution_receipt, -) -from application.broker_reconciliation import ( - IBKRReconciliationReadError, - build_reconciliation_candidate, - collect_read_only_reconciliation_observations, -) -from application.reconciliation_reporting import ( - build_persistable_reconciliation_report, - normalize_reconciliation_request_id, - normalize_reconciliation_scheduler_job_sha256, -) -from application.runtime_broker_adapters import ( - IBKRGatewayUnavailableError, - IBKRTradingPermissionError, - build_runtime_broker_adapters, -) -from application.runtime_composer import build_runtime_composer -from application.runtime_deadline import ( - RuntimeDeadlineExceeded, - classify_runtime_deadline_qualification_caller, - classify_runtime_deadline_stage, - enforce_runtime_deadline, - recycle_current_process_after_response, -) -from application import signal_provenance -from application.runtime_strategy_adapters import ( - build_runtime_strategy_adapters, - fetch_yfinance_historical_candles, -) -from application.rebalance_service import run_strategy_core as run_rebalance_cycle -from application.signal_snapshot import build_signal_snapshot -from decision_mapper import map_strategy_decision -from entrypoints.cloud_run import is_market_open_now -from runtime_execution_policy import ( - IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON, - dca_execution_unsupported_reason, -) -from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message -from notifications.unified_adapter import with_run as with_notification_run -from quant_platform_kit.notifications.strategy_plugin_alerts import ( - StrategyPluginAlertStateSettings, - build_strategy_plugin_alert_context_label as build_alert_context_label, - publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, -) -from quant_platform_kit.common.runtime_assembly import build_runtime_assembly -from quant_platform_kit.common.execution_commands import build_execution_command_store_from_env -from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt -from quant_platform_kit.common.runtime_reports import ( - append_runtime_report_error, - build_runtime_report_base, - finalize_runtime_report, - persist_runtime_report, -) -from quant_platform_kit.common.strategy_plugins import ( - build_strategy_plugin_report_payload, - load_configured_strategy_plugin_signals, - parse_strategy_plugin_mounts, -) -from quant_platform_kit.ibkr import ( - connect_ib as ibkr_connect_ib, - ensure_event_loop as ibkr_ensure_event_loop, - fetch_historical_price_candles, - fetch_historical_price_series, - fetch_quote_snapshots as qpk_fetch_quote_snapshots, -) -from application.ibkr_market_data import fetch_quote_snapshots_with_expected_error_summary -from application.ibkr_order_execution import probe_order_write_access, submit_order_intent -from application.monitor_dispatcher import ( - dispatch_due_monitor_targets, - load_monitor_targets, - lookback_minutes_from_env, - max_workers_from_env, - timeout_seconds_from_env, -) -from application.ibkr_portfolio import ( - IBKRPortfolioSnapshotUnavailableError, - fetch_reconciled_paper_portfolio_snapshot, - fetch_portfolio_snapshot, -) -from application.execution_service import ( - _SettledCashDeadlineIB, - check_order_submitted as application_check_order_submitted, - execute_rebalance as application_execute_rebalance, - get_market_prices as application_get_market_prices, -) -from application.paper_liquidation_service import execute_paper_liquidation -from application.paper_execution_admission import resolve_paper_execution_admission_enabled -from application.paper_execution_command_consumer import ( - consume_due_paper_execution_commands, - resolve_paper_execution_command_consumer_enabled, -) -from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace -from runtime_config_support import ( - EXECUTION_BACKEND_GATEWAY, - load_platform_runtime_settings, - resolve_ib_gateway_ip_mode, -) -from strategy_runtime import load_strategy_runtime -from application.http_routes import ( - _dispatch_local_monitor, - _handle_monitor_dispatch, - _handle_probe, - _handle_reconciliation, - _handle_live_permission_probe, - health_impl, -) +import http_routes as _http_routes +import notifications_runtime as _notifications_runtime +import runtime_wiring as _runtime_wiring +import strategy_plugins_runtime as _strategy_plugins_runtime from runtime_wiring import ( EXPECTED_EXECUTION_GUARD_REASON_PREFIXES, @@ -158,41 +39,22 @@ get_positive_int_env, get_non_negative_float_env, _env_flag, - RUNTIME_SETTINGS, - IB_HOST, - IB_PORT, - IB_CLIENT_ID, IB_CONNECT_TIMEOUT_SECONDS, IB_CONNECT_ATTEMPTS, IB_CONNECT_RETRY_DELAY_SECONDS, IB_CLIENT_ID_RETRY_OFFSET, IBKR_DRY_RUN_DEADLINE_SECONDS, IBKR_DEADLINE_REPORT_GRACE_SECONDS, - STRATEGY_PROFILE, - STRATEGY_DISPLAY_NAME, _normalize_plugin_mounts_strategy, - ACCOUNT_GROUP, - SERVICE_NAME, - ACCOUNT_IDS, - PROJECT_ID, - EXECUTION_BACKEND, QUANTCONNECT_PROJECT_ID, QUANTCONNECT_NODE_ID, - MARKET, - MARKET_CALENDAR, - MARKET_CURRENCY, MARKET_DATA_SYMBOL_SUFFIX, MARKET_EXCHANGE, - MARKET_TIMEZONE, - STRATEGY_RUNTIME, STRATEGY_ENTRYPOINT, STRATEGY_SIGNAL_SOURCE, STRATEGY_STATUS_ICON, SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, FEATURE_RUNTIME_PARAMETERS, - STRATEGY_RUNTIME_CONFIG, - SAFE_HAVEN, - RANKING_POOL, CANARY_ASSETS, TOP_N, SMA_PERIOD, @@ -203,10 +65,6 @@ FEATURE_RUNTIME_CONFIG_PATH, FEATURE_RUNTIME_CONFIG_SOURCE, RECONCILIATION_OUTPUT_PATH, - PAPER_LIQUIDATE_ONLY, - TG_TOKEN, - TG_CHAT_ID, - NOTIFY_LANG, _NOTIFICATION_CHANNEL, _NOTIFICATION_WEBHOOK_URL, CASH_RESERVE_RATIO, @@ -219,10 +77,8 @@ HIST_DATA_PACING_SEC, SEPARATOR, _load_limit_buy_premium_by_symbol, - LIMIT_BUY_PREMIUM_BY_SYMBOL, t, strategy_display_name, - RUNTIME_LOG_CONTEXT, resolve_reporting_managed_symbols, build_strategy_adapters, format_market_data_symbol, @@ -278,7 +134,6 @@ app, ensure_event_loop, NEW_YORK_TZ, - STRATEGY_RUN_LOCK, _is_execution_failure, log_runtime_event, build_execution_report, @@ -317,106 +172,6 @@ ) __all__ = [ - 'EXECUTION_BACKEND_GATEWAY', - 'Flask', - 'IBKRGatewayUnavailableError', - 'IBKRPortfolioSnapshotUnavailableError', - 'IBKRReconciliationReadError', - 'IBKRTradingPermissionError', - 'IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON', - 'Mapping', - 'RuntimeDeadlineExceeded', - 'StrategyPluginAlertStateSettings', - 'ZoneInfo', - '_SettledCashDeadlineIB', - '_dispatch_local_monitor', - '_handle_live_permission_probe', - '_handle_monitor_dispatch', - '_handle_probe', - '_handle_reconciliation', - 'append_runtime_report_error', - 'application_check_order_submitted', - 'application_execute_rebalance', - 'application_get_market_prices', - 'attach_cycle_execution_receipt', - 'attach_terminal_fallback_execution_receipt', - 'build_alert_context_label', - 'build_execution_command_store_from_env', - 'build_persistable_reconciliation_report', - 'build_reconciliation_candidate', - 'build_run_id', - 'build_runtime_assembly', - 'build_runtime_broker_adapters', - 'build_runtime_composer', - 'build_runtime_loaded_receipt', - 'build_runtime_report_base', - 'build_runtime_strategy_adapters', - 'build_signal_snapshot', - 'build_strategy_display_name', - 'build_strategy_plugin_report_payload', - 'build_translator', - 'classify_runtime_deadline_qualification_caller', - 'classify_runtime_deadline_stage', - 'coerce_strategy_cycle_result', - 'collect_read_only_reconciliation_observations', - 'consume_due_paper_execution_commands', - 'date', - 'datetime', - 'dca_execution_unsupported_reason', - 'dispatch_due_monitor_targets', - 'dispatch_strategy_plugin_alerts', - 'emit_runtime_log', - 'enforce_runtime_deadline', - 'execute_paper_liquidation', - 'extract_cloud_trace', - 'fetch_historical_price_candles', - 'fetch_historical_price_series', - 'fetch_portfolio_snapshot', - 'fetch_quote_snapshots_with_expected_error_summary', - 'fetch_reconciled_paper_portfolio_snapshot', - 'fetch_yfinance_historical_candles', - 'finalize_runtime_report', - 'get_compute_discovery', - 'hashlib', - 'health_impl', - 'ibkr_connect_ib', - 'ibkr_ensure_event_loop', - 'importlib', - 'is_market_open_now', - 'json', - 'load_configured_strategy_plugin_signals', - 'load_monitor_targets', - 'load_platform_runtime_settings', - 'load_strategy_runtime', - 'lookback_minutes_from_env', - 'map_strategy_decision', - 'max_workers_from_env', - 'normalize_reconciliation_request_id', - 'normalize_reconciliation_scheduler_job_sha256', - 'os', - 'parse_strategy_plugin_mounts', - 'pd', - 'persist_runtime_report', - 'probe_order_write_access', - 'qpk_fetch_quote_snapshots', - 're', - 'recycle_current_process_after_response', - 'replace', - 'request', - 'requests', - 'resolve_cycle_channel_and_url', - 'resolve_ib_gateway_ip_mode', - 'resolve_paper_execution_admission_enabled', - 'resolve_paper_execution_command_consumer_enabled', - 'run_rebalance_cycle', - 'send_telegram_message', - 'signal_provenance', - 'submit_order_intent', - 'threading', - 'time', - 'timedelta', - 'timeout_seconds_from_env', - 'with_notification_run', 'EXPECTED_EXECUTION_GUARD_REASON_PREFIXES', 'get_project_id', 'get_ib_gateway_ip_mode', @@ -429,41 +184,22 @@ 'get_positive_int_env', 'get_non_negative_float_env', '_env_flag', - 'RUNTIME_SETTINGS', - 'IB_HOST', - 'IB_PORT', - 'IB_CLIENT_ID', 'IB_CONNECT_TIMEOUT_SECONDS', 'IB_CONNECT_ATTEMPTS', 'IB_CONNECT_RETRY_DELAY_SECONDS', 'IB_CLIENT_ID_RETRY_OFFSET', 'IBKR_DRY_RUN_DEADLINE_SECONDS', 'IBKR_DEADLINE_REPORT_GRACE_SECONDS', - 'STRATEGY_PROFILE', - 'STRATEGY_DISPLAY_NAME', '_normalize_plugin_mounts_strategy', - 'ACCOUNT_GROUP', - 'SERVICE_NAME', - 'ACCOUNT_IDS', - 'PROJECT_ID', - 'EXECUTION_BACKEND', 'QUANTCONNECT_PROJECT_ID', 'QUANTCONNECT_NODE_ID', - 'MARKET', - 'MARKET_CALENDAR', - 'MARKET_CURRENCY', 'MARKET_DATA_SYMBOL_SUFFIX', 'MARKET_EXCHANGE', - 'MARKET_TIMEZONE', - 'STRATEGY_RUNTIME', 'STRATEGY_ENTRYPOINT', 'STRATEGY_SIGNAL_SOURCE', 'STRATEGY_STATUS_ICON', 'SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS', 'FEATURE_RUNTIME_PARAMETERS', - 'STRATEGY_RUNTIME_CONFIG', - 'SAFE_HAVEN', - 'RANKING_POOL', 'CANARY_ASSETS', 'TOP_N', 'SMA_PERIOD', @@ -474,10 +210,6 @@ 'FEATURE_RUNTIME_CONFIG_PATH', 'FEATURE_RUNTIME_CONFIG_SOURCE', 'RECONCILIATION_OUTPUT_PATH', - 'PAPER_LIQUIDATE_ONLY', - 'TG_TOKEN', - 'TG_CHAT_ID', - 'NOTIFY_LANG', '_NOTIFICATION_CHANNEL', '_NOTIFICATION_WEBHOOK_URL', 'CASH_RESERVE_RATIO', @@ -490,10 +222,8 @@ 'HIST_DATA_PACING_SEC', 'SEPARATOR', '_load_limit_buy_premium_by_symbol', - 'LIMIT_BUY_PREMIUM_BY_SYMBOL', 't', 'strategy_display_name', - 'RUNTIME_LOG_CONTEXT', 'resolve_reporting_managed_symbols', 'build_strategy_adapters', 'format_market_data_symbol', @@ -516,6 +246,31 @@ '_build_notification_delivery_summary', 'build_account_notification_lines', 'build_extra_notification_lines', + 'RUNTIME_SETTINGS', + 'IB_HOST', + 'IB_PORT', + 'IB_CLIENT_ID', + 'STRATEGY_PROFILE', + 'STRATEGY_DISPLAY_NAME', + 'ACCOUNT_GROUP', + 'SERVICE_NAME', + 'ACCOUNT_IDS', + 'PROJECT_ID', + 'EXECUTION_BACKEND', + 'MARKET', + 'MARKET_CALENDAR', + 'MARKET_CURRENCY', + 'MARKET_TIMEZONE', + 'STRATEGY_RUNTIME', + 'STRATEGY_RUNTIME_CONFIG', + 'SAFE_HAVEN', + 'RANKING_POOL', + 'PAPER_LIQUIDATE_ONLY', + 'TG_TOKEN', + 'TG_CHAT_ID', + 'NOTIFY_LANG', + 'LIMIT_BUY_PREMIUM_BY_SYMBOL', + 'RUNTIME_LOG_CONTEXT', '_split_env_list', 'send_tg_message', '_platform_notification_prefix', @@ -540,7 +295,6 @@ 'app', 'ensure_event_loop', 'NEW_YORK_TZ', - 'STRATEGY_RUN_LOCK', '_is_execution_failure', 'log_runtime_event', 'build_execution_report', @@ -576,8 +330,69 @@ 'handle_live_permission_probe', 'handle_monitor_dispatch', 'health', + 'STRATEGY_RUN_LOCK', ] +_IMPL_MODULES = ( + _runtime_wiring, + _notifications_runtime, + _strategy_plugins_runtime, + _http_routes, +) + +# Keep these off this module's __dict__ so reads see live impl bindings. +_MUTABLE_PROCESS_GLOBALS = frozenset({ + 'ACCOUNT_GROUP', + 'ACCOUNT_IDS', + 'EXECUTION_BACKEND', + 'IB_CLIENT_ID', + 'IB_HOST', + 'IB_PORT', + 'LIMIT_BUY_PREMIUM_BY_SYMBOL', + 'MARKET', + 'MARKET_CALENDAR', + 'MARKET_CURRENCY', + 'MARKET_TIMEZONE', + 'NOTIFY_LANG', + 'PAPER_LIQUIDATE_ONLY', + 'PROJECT_ID', + 'RANKING_POOL', + 'RUNTIME_LOG_CONTEXT', + 'RUNTIME_SETTINGS', + 'SAFE_HAVEN', + 'SERVICE_NAME', + 'STRATEGY_DISPLAY_NAME', + 'STRATEGY_PROFILE', + 'STRATEGY_RUNTIME', + 'STRATEGY_RUNTIME_CONFIG', + 'STRATEGY_RUN_LOCK', + 'TG_CHAT_ID', + 'TG_TOKEN', +}) + +class _InteractiveBrokersMainFacade(types.ModuleType): + """Forward attribute writes to the owning implementation module.""" + + def __getattr__(self, name: str): + for module in _IMPL_MODULES: + if name in module.__dict__: + return module.__dict__[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + def __setattr__(self, name: str, value): + if name in _MUTABLE_PROCESS_GLOBALS: + for module in _IMPL_MODULES: + if name in module.__dict__: + setattr(module, name, value) + return + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + super().__setattr__(name, value) + for module in _IMPL_MODULES: + if name in module.__dict__: + setattr(module, name, value) + +sys.modules[__name__].__class__ = _InteractiveBrokersMainFacade + if __name__ == "__main__": import os diff --git a/notifications_runtime.py b/notifications_runtime.py index 95464e69..24c94a1d 100644 --- a/notifications_runtime.py +++ b/notifications_runtime.py @@ -9,12 +9,16 @@ class _MainModuleProxy: - """Prefer ``main.`` after re-exports; fall back during module import.""" + """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): main_mod = sys.modules.get("main") - if main_mod is not None and name in getattr(main_mod, "__dict__", {}): - return main_mod.__dict__[name] + if main_mod is not None: + # Use getattr so ModuleType facades can resolve mutable globals. + try: + return getattr(main_mod, name) + except AttributeError: + pass return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) diff --git a/runtime_wiring.py b/runtime_wiring.py index 24919b72..5d7cd86d 100644 --- a/runtime_wiring.py +++ b/runtime_wiring.py @@ -9,12 +9,16 @@ class _MainModuleProxy: - """Prefer ``main.`` after re-exports; fall back during module import.""" + """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): main_mod = sys.modules.get("main") - if main_mod is not None and name in getattr(main_mod, "__dict__", {}): - return main_mod.__dict__[name] + if main_mod is not None: + # Use getattr so ModuleType facades can resolve mutable globals. + try: + return getattr(main_mod, name) + except AttributeError: + pass return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) diff --git a/strategy_plugins_runtime.py b/strategy_plugins_runtime.py index 321bdb19..feb83d6a 100644 --- a/strategy_plugins_runtime.py +++ b/strategy_plugins_runtime.py @@ -9,12 +9,16 @@ class _MainModuleProxy: - """Prefer ``main.`` after re-exports; fall back during module import.""" + """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): main_mod = sys.modules.get("main") - if main_mod is not None and name in getattr(main_mod, "__dict__", {}): - return main_mod.__dict__[name] + if main_mod is not None: + # Use getattr so ModuleType facades can resolve mutable globals. + try: + return getattr(main_mod, name) + except AttributeError: + pass return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) diff --git a/tests/test_runtime_notification_i18n.py b/tests/test_runtime_notification_i18n.py index 9076a77c..d0a00074 100644 --- a/tests/test_runtime_notification_i18n.py +++ b/tests/test_runtime_notification_i18n.py @@ -14,7 +14,16 @@ def test_chinese_locale_variants_use_chinese(locale): def runtime_function(name, **overrides): - tree = ast.parse((Path(__file__).resolve().parents[1] / "main.py").read_text()) + root = Path(__file__).resolve().parents[1] + source_name = { + "_runtime_error_notification_message": "notifications_runtime.py", + "_runtime_error_notification_targets": "notifications_runtime.py", + "_notify_runtime_error": "notifications_runtime.py", + "_notify_runtime_error_legacy": "notifications_runtime.py", + "_publish_runtime_failure_notification": "notifications_runtime.py", + "_publish_runtime_failure_notification_legacy": "notifications_runtime.py", + }.get(name, "main.py") + tree = ast.parse((root / source_name).read_text()) function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name) namespace = dict(os=os, request=SimpleNamespace(method="GET", path="/main"), NOTIFY_LANG="zh-CN", SERVICE_NAME="Test service", SECRET_NAME="Test secret alias", @@ -22,7 +31,7 @@ def runtime_function(name, **overrides): strategy_display_name="Test strategy", build_translator=build_translator, t=build_translator("zh-CN")) namespace.update(overrides) - exec(compile(ast.Module(body=[function], type_ignores=[]), "main.py", "exec"), namespace) + exec(compile(ast.Module(body=[function], type_ignores=[]), source_name, "exec"), namespace) return namespace[name] @@ -44,7 +53,7 @@ def test_startup_alert_is_compact_localized_and_does_not_embed_provider_text(loc @pytest.mark.parametrize("handler_name", ['_handle_request']) def test_actual_cycle_exception_handler_emits_no_provider_text(handler_name): - source = (Path(__file__).resolve().parents[1] / "main.py").read_text() + source = (Path(__file__).resolve().parents[1] / "http_routes.py").read_text() function = next(n for n in ast.parse(source).body if isinstance(n, ast.FunctionDef) and n.name == handler_name) handler = next(n for n in ast.walk(function) if isinstance(n, ast.ExceptHandler) and isinstance(n.type, ast.Name) and n.type.id == "Exception" and n.name == "exc" and any(isinstance(c, ast.Call) and isinstance(c.func, ast.Name) and c.func.id == "append_runtime_report_error" for c in ast.walk(n))) emitted = [] From 3368ed99d528e0e2d521d03a7b1291371a016821 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:34:26 +0800 Subject: [PATCH 3/8] refactor: thin main facade with explicit re-exports Align with Binance Wave2: main is a named-export module facade only, with Flask/wiring living in split modules. Co-authored-by: Cursor --- main.py | 120 +++++++++++++++++++++++++------------------------------- 1 file changed, 54 insertions(+), 66 deletions(-) diff --git a/main.py b/main.py index 5a71fbc3..8abeacf3 100644 --- a/main.py +++ b/main.py @@ -2,16 +2,14 @@ Thin facade over the platform split modules. Public helpers are re-exported by explicit name so existing ``import main`` / monkeypatch -call sites keep working. Mutable process globals resolve through the -module facade so rebinding inside implementation modules stays visible. +call sites keep working. A module facade forwards attribute writes to +the owning implementation module. """ from __future__ import annotations import sys import types -# Reload split modules whenever main is (re)imported so test harnesses -# that only pop ``main`` still observe fresh env/stubs. for _split_module_name in ( "runtime_wiring", "notifications_runtime", @@ -39,22 +37,41 @@ get_positive_int_env, get_non_negative_float_env, _env_flag, + RUNTIME_SETTINGS, + IB_HOST, + IB_PORT, + IB_CLIENT_ID, IB_CONNECT_TIMEOUT_SECONDS, IB_CONNECT_ATTEMPTS, IB_CONNECT_RETRY_DELAY_SECONDS, IB_CLIENT_ID_RETRY_OFFSET, IBKR_DRY_RUN_DEADLINE_SECONDS, IBKR_DEADLINE_REPORT_GRACE_SECONDS, + STRATEGY_PROFILE, + STRATEGY_DISPLAY_NAME, _normalize_plugin_mounts_strategy, + ACCOUNT_GROUP, + SERVICE_NAME, + ACCOUNT_IDS, + PROJECT_ID, + EXECUTION_BACKEND, QUANTCONNECT_PROJECT_ID, QUANTCONNECT_NODE_ID, + MARKET, + MARKET_CALENDAR, + MARKET_CURRENCY, MARKET_DATA_SYMBOL_SUFFIX, MARKET_EXCHANGE, + MARKET_TIMEZONE, + STRATEGY_RUNTIME, STRATEGY_ENTRYPOINT, STRATEGY_SIGNAL_SOURCE, STRATEGY_STATUS_ICON, SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, FEATURE_RUNTIME_PARAMETERS, + STRATEGY_RUNTIME_CONFIG, + SAFE_HAVEN, + RANKING_POOL, CANARY_ASSETS, TOP_N, SMA_PERIOD, @@ -65,6 +82,10 @@ FEATURE_RUNTIME_CONFIG_PATH, FEATURE_RUNTIME_CONFIG_SOURCE, RECONCILIATION_OUTPUT_PATH, + PAPER_LIQUIDATE_ONLY, + TG_TOKEN, + TG_CHAT_ID, + NOTIFY_LANG, _NOTIFICATION_CHANNEL, _NOTIFICATION_WEBHOOK_URL, CASH_RESERVE_RATIO, @@ -77,8 +98,10 @@ HIST_DATA_PACING_SEC, SEPARATOR, _load_limit_buy_premium_by_symbol, + LIMIT_BUY_PREMIUM_BY_SYMBOL, t, strategy_display_name, + RUNTIME_LOG_CONTEXT, resolve_reporting_managed_symbols, build_strategy_adapters, format_market_data_symbol, @@ -134,6 +157,7 @@ app, ensure_event_loop, NEW_YORK_TZ, + STRATEGY_RUN_LOCK, _is_execution_failure, log_runtime_event, build_execution_report, @@ -184,22 +208,41 @@ 'get_positive_int_env', 'get_non_negative_float_env', '_env_flag', + 'RUNTIME_SETTINGS', + 'IB_HOST', + 'IB_PORT', + 'IB_CLIENT_ID', 'IB_CONNECT_TIMEOUT_SECONDS', 'IB_CONNECT_ATTEMPTS', 'IB_CONNECT_RETRY_DELAY_SECONDS', 'IB_CLIENT_ID_RETRY_OFFSET', 'IBKR_DRY_RUN_DEADLINE_SECONDS', 'IBKR_DEADLINE_REPORT_GRACE_SECONDS', + 'STRATEGY_PROFILE', + 'STRATEGY_DISPLAY_NAME', '_normalize_plugin_mounts_strategy', + 'ACCOUNT_GROUP', + 'SERVICE_NAME', + 'ACCOUNT_IDS', + 'PROJECT_ID', + 'EXECUTION_BACKEND', 'QUANTCONNECT_PROJECT_ID', 'QUANTCONNECT_NODE_ID', + 'MARKET', + 'MARKET_CALENDAR', + 'MARKET_CURRENCY', 'MARKET_DATA_SYMBOL_SUFFIX', 'MARKET_EXCHANGE', + 'MARKET_TIMEZONE', + 'STRATEGY_RUNTIME', 'STRATEGY_ENTRYPOINT', 'STRATEGY_SIGNAL_SOURCE', 'STRATEGY_STATUS_ICON', 'SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS', 'FEATURE_RUNTIME_PARAMETERS', + 'STRATEGY_RUNTIME_CONFIG', + 'SAFE_HAVEN', + 'RANKING_POOL', 'CANARY_ASSETS', 'TOP_N', 'SMA_PERIOD', @@ -210,6 +253,10 @@ 'FEATURE_RUNTIME_CONFIG_PATH', 'FEATURE_RUNTIME_CONFIG_SOURCE', 'RECONCILIATION_OUTPUT_PATH', + 'PAPER_LIQUIDATE_ONLY', + 'TG_TOKEN', + 'TG_CHAT_ID', + 'NOTIFY_LANG', '_NOTIFICATION_CHANNEL', '_NOTIFICATION_WEBHOOK_URL', 'CASH_RESERVE_RATIO', @@ -222,8 +269,10 @@ 'HIST_DATA_PACING_SEC', 'SEPARATOR', '_load_limit_buy_premium_by_symbol', + 'LIMIT_BUY_PREMIUM_BY_SYMBOL', 't', 'strategy_display_name', + 'RUNTIME_LOG_CONTEXT', 'resolve_reporting_managed_symbols', 'build_strategy_adapters', 'format_market_data_symbol', @@ -246,31 +295,6 @@ '_build_notification_delivery_summary', 'build_account_notification_lines', 'build_extra_notification_lines', - 'RUNTIME_SETTINGS', - 'IB_HOST', - 'IB_PORT', - 'IB_CLIENT_ID', - 'STRATEGY_PROFILE', - 'STRATEGY_DISPLAY_NAME', - 'ACCOUNT_GROUP', - 'SERVICE_NAME', - 'ACCOUNT_IDS', - 'PROJECT_ID', - 'EXECUTION_BACKEND', - 'MARKET', - 'MARKET_CALENDAR', - 'MARKET_CURRENCY', - 'MARKET_TIMEZONE', - 'STRATEGY_RUNTIME', - 'STRATEGY_RUNTIME_CONFIG', - 'SAFE_HAVEN', - 'RANKING_POOL', - 'PAPER_LIQUIDATE_ONLY', - 'TG_TOKEN', - 'TG_CHAT_ID', - 'NOTIFY_LANG', - 'LIMIT_BUY_PREMIUM_BY_SYMBOL', - 'RUNTIME_LOG_CONTEXT', '_split_env_list', 'send_tg_message', '_platform_notification_prefix', @@ -295,6 +319,7 @@ 'app', 'ensure_event_loop', 'NEW_YORK_TZ', + 'STRATEGY_RUN_LOCK', '_is_execution_failure', 'log_runtime_event', 'build_execution_report', @@ -330,7 +355,6 @@ 'handle_live_permission_probe', 'handle_monitor_dispatch', 'health', - 'STRATEGY_RUN_LOCK', ] _IMPL_MODULES = ( @@ -340,36 +364,6 @@ _http_routes, ) -# Keep these off this module's __dict__ so reads see live impl bindings. -_MUTABLE_PROCESS_GLOBALS = frozenset({ - 'ACCOUNT_GROUP', - 'ACCOUNT_IDS', - 'EXECUTION_BACKEND', - 'IB_CLIENT_ID', - 'IB_HOST', - 'IB_PORT', - 'LIMIT_BUY_PREMIUM_BY_SYMBOL', - 'MARKET', - 'MARKET_CALENDAR', - 'MARKET_CURRENCY', - 'MARKET_TIMEZONE', - 'NOTIFY_LANG', - 'PAPER_LIQUIDATE_ONLY', - 'PROJECT_ID', - 'RANKING_POOL', - 'RUNTIME_LOG_CONTEXT', - 'RUNTIME_SETTINGS', - 'SAFE_HAVEN', - 'SERVICE_NAME', - 'STRATEGY_DISPLAY_NAME', - 'STRATEGY_PROFILE', - 'STRATEGY_RUNTIME', - 'STRATEGY_RUNTIME_CONFIG', - 'STRATEGY_RUN_LOCK', - 'TG_CHAT_ID', - 'TG_TOKEN', -}) - class _InteractiveBrokersMainFacade(types.ModuleType): """Forward attribute writes to the owning implementation module.""" @@ -380,12 +374,6 @@ def __getattr__(self, name: str): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __setattr__(self, name: str, value): - if name in _MUTABLE_PROCESS_GLOBALS: - for module in _IMPL_MODULES: - if name in module.__dict__: - setattr(module, name, value) - return - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") super().__setattr__(name, value) for module in _IMPL_MODULES: if name in module.__dict__: From b8434a4f30e8a1d85307a0b928b713344925bc50 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:39:38 +0800 Subject: [PATCH 4/8] fix: sync IB_HOST, entry proxy, settle context, and manifests Keep facade IB_HOST authoritative, resolve __main__ entry, pass the main facade into settlement, and ship split modules in py-modules. Co-authored-by: Cursor --- http_routes.py | 21 +++++++++++++----- pyproject.toml | 4 ++++ runtime_wiring.py | 14 +++++++++--- tests/test_runtime_notification_i18n.py | 29 ++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/http_routes.py b/http_routes.py index 179cc4ac..936e63ae 100644 --- a/http_routes.py +++ b/http_routes.py @@ -12,13 +12,22 @@ class _MainModuleProxy: """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): - main_mod = sys.modules.get("main") - if main_mod is not None: - # Use getattr so ModuleType facades can resolve mutable globals. + for key in ("main", "__main__"): + main_mod = sys.modules.get(key) + if main_mod is None: + continue + if getattr(main_mod, "__name__", None) in { + "http_routes", + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + }: + continue try: + # Use getattr so ModuleType facades can resolve mutable globals. return getattr(main_mod, name) except AttributeError: - pass + continue return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) @@ -435,7 +444,7 @@ def _handle_request(*, dry_run_only_override: bool | None=None, response_body: s from application.settlement_reporting import publish_settlement_notices, settlement_candidate, settlement_receipt_summary from notifications.unified_adapter import event_for, persist_candidate import sys - settlement_store = runtime_settlement_store(sys.modules[__name__]) + settlement_store = runtime_settlement_store(sys.modules.get("main") or sys.modules.get("__main__") or sys.modules[__name__]) settlement_observation = settlement_summary(settlement_store) publish_settlement_notices(settlement_store, settlement_observation, send=main.send_tg_message, locale=main.NOTIFY_LANG, publish_candidate=lambda row: settlement_candidate(row, event_factory=event_for, persist=persist_candidate)) report_summary = main._build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, dry_run=bool(report.get('dry_run'))) @@ -593,7 +602,7 @@ def handle_probe(): def handle_order_settlement(): from application.settlement_route import handle_settle import sys - return handle_settle(sys.modules[__name__]) + return handle_settle(sys.modules.get("main") or sys.modules.get("__main__") or sys.modules[__name__]) @app.route('/reconcile', methods=['POST']) def handle_reconciliation(): diff --git a/pyproject.toml b/pyproject.toml index 480eeb54..c3e0701c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,15 @@ show_missing = true [tool.setuptools] py-modules = [ "decision_mapper", + "http_routes", "main", + "notifications_runtime", "runtime_config_support", "runtime_execution_policy", "runtime_logging", + "runtime_wiring", "strategy_loader", + "strategy_plugins_runtime", "strategy_registry", "strategy_runtime", ] diff --git a/runtime_wiring.py b/runtime_wiring.py index 5d7cd86d..3c88c3d0 100644 --- a/runtime_wiring.py +++ b/runtime_wiring.py @@ -191,6 +191,14 @@ def resolve_gce_instance_ip(instance_name, zone): print(f'GCE resolve failed for {instance_name}: {exc}, using as hostname', flush=True) return instance_name +def _publish_ib_host(value): + """Keep the main facade IB_HOST binding aligned with runtime_wiring.""" + global IB_HOST + IB_HOST = value + main_module = sys.modules.get("main") or sys.modules.get("__main__") + if main_module is not None: + main_module.__dict__["IB_HOST"] = value + def get_ib_host(): global IB_HOST if IB_HOST: @@ -201,7 +209,7 @@ def get_ib_host(): raise main.IBKRGatewayUnavailableError('GCE Gateway zone is required when a Gateway project is configured') if zone: host = main.resolve_gce_instance_ip(host, zone) - IB_HOST = host + _publish_ib_host(host) return host def refresh_ib_host(): @@ -215,14 +223,14 @@ def refresh_ib_host(): try: resolved_host = main.resolve_gce_instance_ip(instance_name, zone) except main.IBKRGatewayUnavailableError: - IB_HOST = None + _publish_ib_host(None) raise except Exception as exc: print(f'GCE host refresh failed for {instance_name}: {exc}', flush=True) resolved_host = cached_host or instance_name if cached_host and resolved_host == instance_name: resolved_host = cached_host - IB_HOST = resolved_host + _publish_ib_host(resolved_host) return resolved_host def get_ib_gateway_mode(): diff --git a/tests/test_runtime_notification_i18n.py b/tests/test_runtime_notification_i18n.py index d0a00074..c576e201 100644 --- a/tests/test_runtime_notification_i18n.py +++ b/tests/test_runtime_notification_i18n.py @@ -31,6 +31,15 @@ def runtime_function(name, **overrides): strategy_display_name="Test strategy", build_translator=build_translator, t=build_translator("zh-CN")) namespace.update(overrides) + + class _MainProxy: + def __getattr__(self, attr): + try: + return namespace[attr] + except KeyError as exc: + raise AttributeError(attr) from exc + + namespace["main"] = _MainProxy() exec(compile(ast.Module(body=[function], type_ignores=[]), source_name, "exec"), namespace) return namespace[name] @@ -55,7 +64,25 @@ def test_startup_alert_is_compact_localized_and_does_not_embed_provider_text(loc def test_actual_cycle_exception_handler_emits_no_provider_text(handler_name): source = (Path(__file__).resolve().parents[1] / "http_routes.py").read_text() function = next(n for n in ast.parse(source).body if isinstance(n, ast.FunctionDef) and n.name == handler_name) - handler = next(n for n in ast.walk(function) if isinstance(n, ast.ExceptHandler) and isinstance(n.type, ast.Name) and n.type.id == "Exception" and n.name == "exc" and any(isinstance(c, ast.Call) and isinstance(c.func, ast.Name) and c.func.id == "append_runtime_report_error" for c in ast.walk(n))) + def _calls_append_runtime_report_error(node): + for call in ast.walk(node): + if not isinstance(call, ast.Call): + continue + func = call.func + if isinstance(func, ast.Name) and func.id == "append_runtime_report_error": + return True + if isinstance(func, ast.Attribute) and func.attr == "append_runtime_report_error": + return True + return False + + handler = next( + n for n in ast.walk(function) + if isinstance(n, ast.ExceptHandler) + and isinstance(n.type, ast.Name) + and n.type.id == "Exception" + and n.name == "exc" + and _calls_append_runtime_report_error(n) + ) emitted = [] def record(*args, **kwargs): # Exception objects themselves remain private inputs, never rendered outputs. From bb6f94518cc72dede1785c94a73012b99f9c86f3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:40:21 +0800 Subject: [PATCH 5/8] fix: accept __main__ in notification module proxy Keep script-launch entry resolution consistent with http_routes. Co-authored-by: Cursor --- notifications_runtime.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/notifications_runtime.py b/notifications_runtime.py index 24c94a1d..0a991824 100644 --- a/notifications_runtime.py +++ b/notifications_runtime.py @@ -12,13 +12,22 @@ class _MainModuleProxy: """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): - main_mod = sys.modules.get("main") - if main_mod is not None: - # Use getattr so ModuleType facades can resolve mutable globals. + for key in ("main", "__main__"): + main_mod = sys.modules.get(key) + if main_mod is None: + continue + if getattr(main_mod, "__name__", None) in { + "http_routes", + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + }: + continue try: + # Use getattr so ModuleType facades can resolve mutable globals. return getattr(main_mod, name) except AttributeError: - pass + continue return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) From 04df11a32f95b724c991ccce67bdc61d09883306 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:43:02 +0800 Subject: [PATCH 6/8] fix: harden settle context and scheduler deadline source scan Co-authored-by: Cursor --- http_routes.py | 21 +++++++++++++++++++-- tests/test_scheduler_deadline_contract.py | 14 ++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/http_routes.py b/http_routes.py index 936e63ae..28013744 100644 --- a/http_routes.py +++ b/http_routes.py @@ -33,6 +33,23 @@ def __getattr__(self, name): main = _MainModuleProxy() +def _runtime_entrypoint_module(): + """Return the main facade module; never fall back to this route module.""" + for key in ("main", "__main__"): + mod = sys.modules.get(key) + if mod is None: + continue + if getattr(mod, "__name__", None) in { + "http_routes", + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + }: + continue + return mod + raise RuntimeError("main facade module is not loaded") + + import hashlib import json import importlib @@ -444,7 +461,7 @@ def _handle_request(*, dry_run_only_override: bool | None=None, response_body: s from application.settlement_reporting import publish_settlement_notices, settlement_candidate, settlement_receipt_summary from notifications.unified_adapter import event_for, persist_candidate import sys - settlement_store = runtime_settlement_store(sys.modules.get("main") or sys.modules.get("__main__") or sys.modules[__name__]) + settlement_store = runtime_settlement_store(_runtime_entrypoint_module()) settlement_observation = settlement_summary(settlement_store) publish_settlement_notices(settlement_store, settlement_observation, send=main.send_tg_message, locale=main.NOTIFY_LANG, publish_candidate=lambda row: settlement_candidate(row, event_factory=event_for, persist=persist_candidate)) report_summary = main._build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, dry_run=bool(report.get('dry_run'))) @@ -602,7 +619,7 @@ def handle_probe(): def handle_order_settlement(): from application.settlement_route import handle_settle import sys - return handle_settle(sys.modules.get("main") or sys.modules.get("__main__") or sys.modules[__name__]) + return handle_settle(_runtime_entrypoint_module()) @app.route('/reconcile', methods=['POST']) def handle_reconciliation(): diff --git a/tests/test_scheduler_deadline_contract.py b/tests/test_scheduler_deadline_contract.py index 3fd24d38..ba2da00d 100644 --- a/tests/test_scheduler_deadline_contract.py +++ b/tests/test_scheduler_deadline_contract.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env-protected.yml" -MAIN = ROOT / "main.py" +RUNTIME_WIRING = ROOT / "runtime_wiring.py" def test_live_gateway_run_deadline_exceeds_cloud_run_timeout() -> None: @@ -46,7 +46,7 @@ def test_run_deadline_is_plan_scoped_and_warmup_remains_60_seconds() -> None: def test_precheck_uses_per_service_scheduler_with_bounded_deadline() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - main = MAIN.read_text(encoding="utf-8") + runtime_wiring = RUNTIME_WIRING.read_text(encoding="utf-8") assert 'precheck_job_name="${cloud_run_service%-service}-precheck-scheduler"' in workflow assert 'precheck_uri="${service_url}/dry-run"' in workflow @@ -64,8 +64,14 @@ def test_precheck_uses_per_service_scheduler_with_bounded_deadline() -> None: assert 'monitor_job_name="interactive-brokers-monitor-dispatcher-scheduler"' not in workflow assert 'shared_env_pairs+=("IBKR_MONITOR_DISPATCH_TARGETS_JSON=' not in workflow - strategy_deadline = re.search(r'IBKR_DRY_RUN_DEADLINE_SECONDS = get_positive_int_env\([^,]+, (\d+)\)', main) - report_grace = re.search(r'IBKR_DEADLINE_REPORT_GRACE_SECONDS = get_positive_int_env\([^,]+, (\d+)\)', main) + strategy_deadline = re.search( + r'IBKR_DRY_RUN_DEADLINE_SECONDS = get_positive_int_env\([^,]+, (\d+)\)', + runtime_wiring, + ) + report_grace = re.search( + r'IBKR_DEADLINE_REPORT_GRACE_SECONDS = get_positive_int_env\([^,]+, (\d+)\)', + runtime_wiring, + ) assert strategy_deadline is not None assert report_grace is not None assert 120 > int(strategy_deadline.group(1)) + int(report_grace.group(1)) From cbbd7f7fee44ee9b563ec3f62857d3757a842848 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:48:26 +0800 Subject: [PATCH 7/8] fix: provide main proxy in cycle exception AST harness Co-authored-by: Cursor --- tests/test_runtime_notification_i18n.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_runtime_notification_i18n.py b/tests/test_runtime_notification_i18n.py index c576e201..afb0b107 100644 --- a/tests/test_runtime_notification_i18n.py +++ b/tests/test_runtime_notification_i18n.py @@ -93,9 +93,18 @@ def record(*args, **kwargs): _publish_runtime_failure_notification=record, _runtime_error_notification_message=runtime_function("_runtime_error_notification_message"), t=build_translator("zh-CN"), traceback=SimpleNamespace(format_exc=lambda: "Traceback PRIVATE_CYCLE_SENTINEL")) + + class _MainProxy: + def __getattr__(self, attr): + try: + return namespace[attr] + except KeyError as exc: + raise AttributeError(attr) from exc + + namespace["main"] = _MainProxy() body = ast.parse("def exercise():\n pass").body[0] body.body = handler.body - exec(compile(ast.fix_missing_locations(ast.Module(body=[body], type_ignores=[])), "main.py", "exec"), namespace) + exec(compile(ast.fix_missing_locations(ast.Module(body=[body], type_ignores=[])), "http_routes.py", "exec"), namespace) assert namespace["exercise"]() == ("Error", 500) assert "PRIVATE_CYCLE_SENTINEL" not in repr(emitted) assert "Traceback" not in repr(emitted) From 7783597c55bd5eb4d114cd2c56cecca5c2cc15b5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:55:15 +0800 Subject: [PATCH 8/8] fix: resolve __main__ in remaining module entry proxies runtime_wiring and strategy_plugins_runtime must find the script entrypoint facade so composer/plugin helpers resolve across modules. Co-authored-by: Cursor --- runtime_wiring.py | 17 +++++++++++++---- strategy_plugins_runtime.py | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/runtime_wiring.py b/runtime_wiring.py index 3c88c3d0..66808d07 100644 --- a/runtime_wiring.py +++ b/runtime_wiring.py @@ -12,13 +12,22 @@ class _MainModuleProxy: """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): - main_mod = sys.modules.get("main") - if main_mod is not None: - # Use getattr so ModuleType facades can resolve mutable globals. + for key in ("main", "__main__"): + main_mod = sys.modules.get(key) + if main_mod is None: + continue + if getattr(main_mod, "__name__", None) in { + "http_routes", + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + }: + continue try: + # Use getattr so ModuleType facades can resolve mutable globals. return getattr(main_mod, name) except AttributeError: - pass + continue return getattr(sys.modules[_FALLBACK_MODULE_NAME], name) diff --git a/strategy_plugins_runtime.py b/strategy_plugins_runtime.py index feb83d6a..9383aa6d 100644 --- a/strategy_plugins_runtime.py +++ b/strategy_plugins_runtime.py @@ -12,13 +12,22 @@ class _MainModuleProxy: """Prefer ``main.`` (facade-aware); fall back during module import.""" def __getattr__(self, name): - main_mod = sys.modules.get("main") - if main_mod is not None: - # Use getattr so ModuleType facades can resolve mutable globals. + for key in ("main", "__main__"): + main_mod = sys.modules.get(key) + if main_mod is None: + continue + if getattr(main_mod, "__name__", None) in { + "http_routes", + "runtime_wiring", + "notifications_runtime", + "strategy_plugins_runtime", + }: + continue try: + # Use getattr so ModuleType facades can resolve mutable globals. return getattr(main_mod, name) except AttributeError: - pass + continue return getattr(sys.modules[_FALLBACK_MODULE_NAME], name)