Skip to content

Commit e6e662d

Browse files
committed
Harden IBKR Cloud Run connection cycles
1 parent c7f8cc3 commit e6e662d

7 files changed

Lines changed: 141 additions & 16 deletions

File tree

.github/workflows/sync-cloud-run-env.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ jobs:
289289
gcloud_args=(
290290
run services update "${CLOUD_RUN_SERVICE}"
291291
--region "${CLOUD_RUN_REGION}"
292+
--concurrency 1
293+
--max-instances 1
292294
--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")"
293295
--update-env-vars "$(IFS=,; echo "${env_pairs[*]}")"
294296
)

gunicorn.conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# gunicorn.conf.py
22
timeout = 120
33
workers = 1
4-
threads = 8
4+
threads = 1

main.py

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""IBKR strategy runner for shared us_equity strategy profiles."""
22
import os
3+
import threading
34
import time
45
import traceback
56
from datetime import datetime, timezone
@@ -56,6 +57,7 @@
5657
app = Flask(__name__)
5758
ensure_event_loop = ibkr_ensure_event_loop
5859
NEW_YORK_TZ = ZoneInfo("America/New_York")
60+
STRATEGY_RUN_LOCK = threading.Lock()
5961

6062

6163
# ---------------------------------------------------------------------------
@@ -160,6 +162,32 @@ def get_ib_connect_timeout_seconds():
160162
return timeout_seconds
161163

162164

165+
def get_positive_int_env(name, default):
166+
raw_value = os.getenv(name, str(default))
167+
try:
168+
parsed = int(raw_value)
169+
except (TypeError, ValueError):
170+
print(f"Invalid {name}={raw_value!r}; using {default}", flush=True)
171+
return default
172+
if parsed <= 0:
173+
print(f"Invalid {name}={raw_value!r}; using {default}", flush=True)
174+
return default
175+
return parsed
176+
177+
178+
def get_non_negative_float_env(name, default):
179+
raw_value = os.getenv(name, str(default))
180+
try:
181+
parsed = float(raw_value)
182+
except (TypeError, ValueError):
183+
print(f"Invalid {name}={raw_value!r}; using {default}", flush=True)
184+
return default
185+
if parsed < 0:
186+
print(f"Invalid {name}={raw_value!r}; using {default}", flush=True)
187+
return default
188+
return parsed
189+
190+
163191
def _env_flag(name: str) -> bool:
164192
return str(os.getenv(name) or "").strip().lower() in {"1", "true", "yes", "on"}
165193

@@ -172,6 +200,9 @@ def _env_flag(name: str) -> bool:
172200
IB_PORT = get_ib_port()
173201
IB_CLIENT_ID = RUNTIME_SETTINGS.ib_client_id
174202
IB_CONNECT_TIMEOUT_SECONDS = get_ib_connect_timeout_seconds()
203+
IB_CONNECT_ATTEMPTS = get_positive_int_env("IBKR_CONNECT_ATTEMPTS", 3)
204+
IB_CONNECT_RETRY_DELAY_SECONDS = get_non_negative_float_env("IBKR_CONNECT_RETRY_DELAY_SECONDS", 5.0)
205+
IB_CLIENT_ID_RETRY_OFFSET = get_positive_int_env("IBKR_CLIENT_ID_RETRY_OFFSET", 100)
175206
STRATEGY_PROFILE = RUNTIME_SETTINGS.strategy_profile
176207
STRATEGY_DISPLAY_NAME = RUNTIME_SETTINGS.strategy_display_name
177208
ACCOUNT_GROUP = RUNTIME_SETTINGS.account_group
@@ -255,6 +286,8 @@ def t(key, **kwargs):
255286
"strategy_artifact_dir": RUNTIME_SETTINGS.strategy_artifact_dir,
256287
"strategy_display_name": STRATEGY_DISPLAY_NAME,
257288
"strategy_display_name_localized": strategy_display_name,
289+
"ib_connect_attempts": IB_CONNECT_ATTEMPTS,
290+
"ib_client_id_retry_offset": IB_CLIENT_ID_RETRY_OFFSET,
258291
},
259292
)
260293
LAST_CYCLE_DETAILS: dict[str, object] = {}
@@ -271,20 +304,39 @@ def send_tg_message(message):
271304

272305
def connect_ib():
273306
host = get_ib_host()
274-
print(
275-
"Connecting to IB gateway "
276-
f"{host}:{IB_PORT} "
277-
f"(mode={RUNTIME_SETTINGS.ib_gateway_mode}, "
278-
f"client_id={IB_CLIENT_ID}, "
279-
f"timeout={IB_CONNECT_TIMEOUT_SECONDS}s)",
280-
flush=True,
281-
)
282-
return ibkr_connect_ib(
283-
host,
284-
IB_PORT,
285-
IB_CLIENT_ID,
286-
timeout=IB_CONNECT_TIMEOUT_SECONDS,
287-
)
307+
last_error = None
308+
for attempt in range(1, IB_CONNECT_ATTEMPTS + 1):
309+
client_id = IB_CLIENT_ID + ((attempt - 1) * IB_CLIENT_ID_RETRY_OFFSET)
310+
print(
311+
"Connecting to IB gateway "
312+
f"{host}:{IB_PORT} "
313+
f"(mode={RUNTIME_SETTINGS.ib_gateway_mode}, "
314+
f"client_id={client_id}, "
315+
f"attempt={attempt}/{IB_CONNECT_ATTEMPTS}, "
316+
f"timeout={IB_CONNECT_TIMEOUT_SECONDS}s)",
317+
flush=True,
318+
)
319+
try:
320+
return ibkr_connect_ib(
321+
host,
322+
IB_PORT,
323+
client_id,
324+
timeout=IB_CONNECT_TIMEOUT_SECONDS,
325+
)
326+
except (ConnectionError, TimeoutError, OSError) as exc:
327+
last_error = exc
328+
print(
329+
"IB gateway connection attempt failed "
330+
f"(attempt={attempt}/{IB_CONNECT_ATTEMPTS}, "
331+
f"client_id={client_id}, "
332+
f"error_type={type(exc).__name__}, "
333+
f"error={exc})",
334+
flush=True,
335+
)
336+
if attempt < IB_CONNECT_ATTEMPTS and IB_CONNECT_RETRY_DELAY_SECONDS > 0:
337+
time.sleep(IB_CONNECT_RETRY_DELAY_SECONDS)
338+
339+
raise last_error
288340

289341

290342
def log_runtime_event(log_context, event, **fields):
@@ -568,13 +620,27 @@ def handle_request():
568620
LAST_CYCLE_DETAILS = {}
569621
log_context = build_request_log_context()
570622
report = build_execution_report(log_context)
623+
lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False)
571624
try:
572625
log_runtime_event(
573626
log_context,
574627
"strategy_cycle_received",
575628
message="Received strategy execution request",
576629
http_method=request.method,
577630
)
631+
if not lock_acquired:
632+
log_runtime_event(
633+
log_context,
634+
"strategy_cycle_already_running",
635+
message="Another strategy execution is already running; skip overlapping request",
636+
severity="WARNING",
637+
)
638+
finalize_runtime_report(
639+
report,
640+
status="skipped",
641+
diagnostics={"skip_reason": "already_running"},
642+
)
643+
return "Already Running", 200
578644
if not is_market_open_today():
579645
log_runtime_event(
580646
log_context,
@@ -654,6 +720,8 @@ def handle_request():
654720
print(error_msg, flush=True)
655721
return "Error", 500
656722
finally:
723+
if lock_acquired:
724+
STRATEGY_RUN_LOCK.release()
657725
try:
658726
report_path = persist_execution_report(report)
659727
print(f"execution_report {report_path}", flush=True)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@fix-ibkr-longbridge-cash-diagnostics
44
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@main
55
pandas
66
numpy

tests/test_event_loop.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,33 @@ def fake_ibkr_connect(host, port, client_id, **kwargs):
3636
assert observed["args"] == ("127.0.0.1", 4001, 1, {"timeout": 60})
3737

3838

39+
def test_connect_ib_retries_with_offset_client_ids(strategy_module_factory, monkeypatch):
40+
module = strategy_module_factory(
41+
IBKR_CONNECT_ATTEMPTS="3",
42+
IBKR_CONNECT_RETRY_DELAY_SECONDS="0",
43+
IBKR_CLIENT_ID_RETRY_OFFSET="100",
44+
)
45+
observed = {"client_ids": []}
46+
47+
def fake_ibkr_connect(host, port, client_id, **kwargs):
48+
observed["client_ids"].append(client_id)
49+
if len(observed["client_ids"]) < 3:
50+
raise TimeoutError("handshake timeout")
51+
return object()
52+
53+
monkeypatch.setattr(module, "ibkr_connect_ib", fake_ibkr_connect)
54+
55+
module.connect_ib()
56+
57+
assert observed["client_ids"] == [1, 101, 201]
58+
59+
60+
def test_ib_connect_attempts_falls_back_when_invalid(strategy_module_factory):
61+
module = strategy_module_factory(IBKR_CONNECT_ATTEMPTS="0")
62+
63+
assert module.IB_CONNECT_ATTEMPTS == 3
64+
65+
3966
def test_ib_connect_timeout_can_be_overridden(strategy_module_factory):
4067
module = strategy_module_factory(IBKR_CONNECT_TIMEOUT_SECONDS="75")
4168

tests/test_request_handling.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ def fake_run_strategy_core():
2929
assert observed["called"] is True
3030

3131

32+
def test_handle_request_skips_overlapping_post(strategy_module, monkeypatch):
33+
observed = {}
34+
35+
def fail_if_called():
36+
raise AssertionError("overlapping request should not execute strategy")
37+
38+
monkeypatch.setattr(strategy_module, "run_strategy_core", fail_if_called)
39+
monkeypatch.setattr(
40+
strategy_module,
41+
"persist_execution_report",
42+
lambda report: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json",
43+
)
44+
45+
strategy_module.STRATEGY_RUN_LOCK.acquire()
46+
try:
47+
with strategy_module.app.test_request_context("/", method="POST"):
48+
body, status = strategy_module.handle_request()
49+
finally:
50+
strategy_module.STRATEGY_RUN_LOCK.release()
51+
52+
assert status == 200
53+
assert body == "Already Running"
54+
assert observed["report"]["status"] == "skipped"
55+
assert observed["report"]["diagnostics"]["skip_reason"] == "already_running"
56+
57+
3258
def test_handle_request_emits_structured_runtime_events(strategy_module, monkeypatch):
3359
observed = []
3460

tests/test_sync_cloud_run_env_workflow.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,7 @@ grep -Fq '"IB_GATEWAY_INSTANCE_NAME"' "$workflow_file"
8686
grep -Fq '"IB_GATEWAY_MODE"' "$workflow_file"
8787
grep -Fq -- '--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")"' "$workflow_file"
8888
grep -Fq -- '--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")"' "$workflow_file"
89+
grep -Fq -- '--concurrency 1' "$workflow_file"
90+
grep -Fq -- '--max-instances 1' "$workflow_file"
8991
grep -Fq -- '--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")"' "$workflow_file"
9092
grep -Fq -- '--update-env-vars "$(IFS=,; echo "${env_pairs[*]}")"' "$workflow_file"

0 commit comments

Comments
 (0)