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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ jobs:
IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}
IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}
IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}
IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }}
IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }}
IBKR_MARKET: ${{ vars.IBKR_MARKET }}
IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }}
Expand Down
157 changes: 157 additions & 0 deletions application/ibkr_market_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""IBKR market-data error handling."""

from __future__ import annotations

import json
import logging
import re
from collections import Counter
from collections.abc import Callable, Iterable
from threading import Lock, RLock
from typing import Any


_EXPECTED_ENTITLEMENT_ERROR_CODES = frozenset({10089, 10168})
_SECONDARY_CANCEL_ERROR_CODE = 300
_ERROR_CODE_PATTERN = re.compile(r"^(?:Error|Warning) (\d+), reqId (-?\d+)")
_LOCK_CREATION_GUARD = Lock()
_QUOTE_FETCH_LOCK_ATTR = "_ibkr_market_data_quote_fetch_lock"


def _quote_fetch_lock(wrapper: Any) -> RLock:
if wrapper is None:
return RLock()
with _LOCK_CREATION_GUARD:
lock = getattr(wrapper, _QUOTE_FETCH_LOCK_ATTR, None)
if lock is None:
lock = RLock()
setattr(wrapper, _QUOTE_FETCH_LOCK_ATTR, lock)
return lock


class _ExpectedMarketDataErrorTracker:
def __init__(self, *, wrapper: Any, requested_symbols: set[str]) -> None:
self.wrapper = wrapper
self.requested_symbols = requested_symbols
self.entitlement_request_ids: set[int] = set()

def _matches_requested_contract(self, request_id: int, contract: Any = None) -> bool:
resolved_contract = contract
if resolved_contract is None:
contracts = getattr(self.wrapper, "_reqId2Contract", {})
resolved_contract = contracts.get(request_id) if hasattr(contracts, "get") else None
symbol = str(getattr(resolved_contract, "symbol", "") or "").strip().upper()
return bool(symbol and symbol in self.requested_symbols)
Comment on lines +43 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match the actual quote request before suppressing errors

During a snapshot fetch, any other outstanding market-data request on the same IB connection whose contract has the same symbol is treated as part of this fetch, even if it has a different request ID, security type, exchange, or is a pre-existing streaming subscription. If that request emits 10089/10168, its error and subsequent 300 are hidden and included in this fetch's fallback summary, so unrelated subscription failures can still be obscured; suppression needs to be limited to request IDs/contracts initiated by the wrapped fetch rather than symbol equality alone.

Useful? React with 👍 / 👎.


def should_suppress_message(self, message: str) -> bool:
match = _ERROR_CODE_PATTERN.match(message)
if match is None:
return False
error_code = int(match.group(1))
request_id = int(match.group(2))
if (
error_code in _EXPECTED_ENTITLEMENT_ERROR_CODES
and self._matches_requested_contract(request_id)
):
self.entitlement_request_ids.add(request_id)
return True
if (
error_code == _SECONDARY_CANCEL_ERROR_CODE
and request_id in self.entitlement_request_ids
):
return True
return False

def should_count_event(self, request_id: int, error_code: int, contract: Any) -> bool:
if (
error_code in _EXPECTED_ENTITLEMENT_ERROR_CODES
and self._matches_requested_contract(request_id, contract)
):
self.entitlement_request_ids.add(request_id)
return True
return (
error_code == _SECONDARY_CANCEL_ERROR_CODE
and request_id in self.entitlement_request_ids
)


class _InstanceScopedMarketDataLogger(logging.LoggerAdapter):
def __init__(self, logger: logging.Logger, tracker: _ExpectedMarketDataErrorTracker) -> None:
super().__init__(logger, {})
self.tracker = tracker

def log(self, level, msg, *args, **kwargs) -> None:
if not self.isEnabledFor(level):
return
try:
message = str(msg) % args if args else str(msg)
except (TypeError, ValueError):
message = str(msg)
if self.tracker.should_suppress_message(message):
return
super().log(level, msg, *args, **kwargs)


def fetch_quote_snapshots_with_expected_error_summary(
ib: Any,
symbols: Iterable[str],
*,
fetch_quote_snapshots: Callable[..., dict],
printer: Callable[[str], None] = print,
**kwargs,
) -> dict:
"""Collapse expected entitlement errors while preserving unrelated broker errors."""
normalized_symbols = tuple(dict.fromkeys(str(symbol).strip().upper() for symbol in symbols))
error_counts: Counter[int] = Counter()
wrapper = getattr(ib, "wrapper", None)
tracker = _ExpectedMarketDataErrorTracker(
wrapper=wrapper,
requested_symbols=set(normalized_symbols),
)

def record_error(req_id, error_code, _error_string, contract) -> None:
try:
code = int(error_code)
request_id = int(req_id)
except (TypeError, ValueError):
return
if tracker.should_count_event(request_id, code, contract):
error_counts[code] += 1

error_event = getattr(ib, "errorEvent", None)
original_logger = getattr(wrapper, "_logger", None)
quote_fetch_lock = _quote_fetch_lock(wrapper)
scoped_logger = (
_InstanceScopedMarketDataLogger(original_logger, tracker)
if isinstance(original_logger, logging.Logger)
else None
)

with quote_fetch_lock:
if scoped_logger is not None:
wrapper._logger = scoped_logger
if error_event is not None:
error_event += record_error
try:
snapshots = fetch_quote_snapshots(ib, normalized_symbols, **kwargs)
finally:
if error_event is not None:
error_event -= record_error
if scoped_logger is not None and getattr(wrapper, "_logger", None) is scoped_logger:
wrapper._logger = original_logger

if error_counts:
printer(
"ibkr_market_data_fallback "
+ json.dumps(
{
"error_counts": {
str(code): error_counts[code] for code in sorted(error_counts)
},
"requested_symbols": len(normalized_symbols),
"resolved_symbols": len(snapshots),
},
sort_keys=True,
)
)
return snapshots
35 changes: 32 additions & 3 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,10 +700,39 @@ def _execution_already_recorded_message(*, config: IBKRRebalanceConfig, signal_m
def _should_record_execution_marker(*, trade_logs, execution_summary, config: IBKRRebalanceConfig) -> bool:
if not getattr(config, "execution_dedup_enabled", False):
return False
if tuple(trade_logs or ()):
return True
if execution_summary is None:
return bool(tuple(trade_logs or ()))
summary = dict(execution_summary or {})
return bool(summary.get("action_done")) or int(summary.get("orders_previewed_count") or 0) > 0
execution_status = str(summary.get("execution_status") or "").strip().lower()
if execution_status in {"blocked", "error", "failed", "failure"}:
return False
accepted_order_keys = (
"orders_submitted",
"orders_filled",
"orders_partially_filled",
"option_orders_submitted",
"option_orders_filled",
"option_orders_partially_filled",
)
has_structured_execution_result = any(
key in summary
for key in (
"execution_status",
"action_done",
"orders_previewed_count",
*accepted_order_keys,
)
)
return (
execution_status == "executed"
or any(tuple(summary.get(key) or ()) for key in accepted_order_keys)
or bool(summary.get("action_done"))
or int(summary.get("orders_previewed_count") or 0) > 0
or (
not has_structured_execution_result
and bool(tuple(trade_logs or ()))
)
)


def _record_execution_marker(
Expand Down
6 changes: 4 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@
ensure_event_loop as ibkr_ensure_event_loop,
fetch_historical_price_candles,
fetch_historical_price_series,
fetch_quote_snapshots,
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,
Expand Down Expand Up @@ -524,9 +525,10 @@ def fetch_market_fallback_historical_candles(symbol, **kwargs):


def fetch_market_quote_snapshots(ib, symbols, **kwargs):
return fetch_quote_snapshots(
return fetch_quote_snapshots_with_expected_error_summary(
ib,
symbols,
fetch_quote_snapshots=qpk_fetch_quote_snapshots,
exchange=MARKET_EXCHANGE,
currency=MARKET_CURRENCY,
**kwargs,
Expand Down
9 changes: 7 additions & 2 deletions scripts/build_cloud_run_env_sync_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def _should_add_local_src(candidate: Path) -> bool:
"IBKR_MARKET_TIMEZONE",
"IB_GATEWAY_ZONE",
"IB_GATEWAY_IP_MODE",
"IBKR_EXECUTION_DEDUP_ENABLED",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply per-target dedup overrides before the shared fallback

When the repository-level variable is set and a service's runtime_target.overrides specifies the opposite value, adding this setting to SHARED_TARGET_FALLBACK_ENV makes _target_env_value select the shared value first; the override at lines 432–440 is only consulted when that value is None. For example, a shared false silently overrides a live target's explicit true, disabling deduplication and allowing manual and scheduled runs to submit the same signal, so the per-target override must take precedence.

Useful? React with 👍 / 👎.

"EXECUTION_REPORT_GCS_URI",
}
)
Expand Down Expand Up @@ -97,6 +98,7 @@ def _should_add_local_src(candidate: Path) -> bool:
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON",
"IBKR_RECONCILIATION_OUTPUT_PATH",
"IBKR_DRY_RUN_ONLY",
"IBKR_EXECUTION_DEDUP_ENABLED",
"IBKR_PAPER_LIQUIDATE_ONLY",
"IBKR_MIN_RESERVED_CASH_USD",
"IBKR_RESERVED_CASH_RATIO",
Expand Down Expand Up @@ -429,10 +431,13 @@ def _build_target_plan(
value = strategy_defaults.get(name)
# 4. Override from RUNTIME_TARGET_JSON.overrides
if value is None and isinstance(overrides, Mapping):
override_value = overrides.get(name) or overrides.get(name.lower())
override_value = (
overrides[name]
if name in overrides
else overrides.get(name.lower())
)
if override_value is not None:
value = _coerce_env_value(override_value)

if value is None:
remove_env_vars.append(name)
else:
Expand Down
10 changes: 8 additions & 2 deletions tests/test_connect_timeout_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ def run(self, *args, **kwargs):
notify_lang="en",
tg_token=None,
tg_chat_id="chat-id",
notification_channel="telegram",
wecom_webhook_url=None,
dingtalk_webhook_url=None,
feishu_webhook_url=None,
serverchan_webhook_url=None,
strategy_metadata=None,
ibkr_feature_snapshot_manifest_path=None,
ibkr_reconciliation_output_path=None,
market_hours_source="cloud_run",
Expand Down Expand Up @@ -194,7 +200,7 @@ def test_handle_request_sends_ibkr_connect_timeout_notification(self):

module.is_market_open_now = lambda **_kwargs: True
module.run_strategy_core = lambda **_kwargs: (_ for _ in ()).throw(
TimeoutError("IBKR API handshake timed out")
module.IBKRGatewayUnavailableError("IBKR API handshake timed out")
)
module.persist_execution_report = lambda report: observed.setdefault("report", dict(report)) or "/tmp/report.json"
module.publish_notification = lambda *, detailed_text, compact_text: observed["messages"].append(
Expand All @@ -205,7 +211,7 @@ def test_handle_request_sends_ibkr_connect_timeout_notification(self):
with module.app.test_request_context("/", method="POST"):
body, status = module.handle_request()

self.assertEqual(status, 500)
self.assertEqual(status, 503)
self.assertEqual(body, "Error")
self.assertEqual(observed["report"]["errors"][0]["stage"], "ibkr_connect")
self.assertIn("IBKR 连接异常", observed["messages"][0][0])
Expand Down
Loading
Loading