Skip to content

Commit 3f29133

Browse files
committed
Add IBKR TCP preflight diagnostics
1 parent 12f235c commit 3f29133

4 files changed

Lines changed: 105 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.2"
7+
version = "0.7.3"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""QuantPlatformKit public package surface."""
22

3-
__version__ = "0.7.2"
3+
__version__ = "0.7.3"
44

55
from .common.models import (
66
ExecutionReport,

src/quant_platform_kit/ibkr/connection.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import socket
45
from typing import Any, Callable
56

67

@@ -20,15 +21,46 @@ def ensure_event_loop() -> asyncio.AbstractEventLoop:
2021
return loop
2122

2223

24+
def probe_tcp_endpoint(
25+
host: str,
26+
port: int,
27+
*,
28+
timeout: float,
29+
socket_create_connection: Callable[..., Any] | None = None,
30+
) -> None:
31+
if socket_create_connection is None:
32+
socket_create_connection = socket.create_connection
33+
34+
try:
35+
connection = socket_create_connection((host, port), timeout)
36+
except TimeoutError as exc:
37+
raise TimeoutError(f"TCP preflight timed out for {host}:{port}") from exc
38+
except OSError as exc:
39+
raise ConnectionError(f"TCP preflight failed for {host}:{port}: {exc}") from exc
40+
41+
close = getattr(connection, "close", None)
42+
if callable(close):
43+
close()
44+
45+
2346
def connect_ib(
2447
host: str,
2548
port: int,
2649
client_id: int,
2750
*,
2851
timeout: int = 20,
52+
tcp_preflight_timeout: float | None = 3.0,
53+
socket_create_connection: Callable[..., Any] | None = None,
2954
ib_factory: Callable[[], Any] | None = None,
3055
) -> Any:
3156
ensure_event_loop()
57+
if tcp_preflight_timeout is not None and tcp_preflight_timeout > 0:
58+
probe_tcp_endpoint(
59+
host,
60+
port,
61+
timeout=min(float(timeout), float(tcp_preflight_timeout)),
62+
socket_create_connection=socket_create_connection,
63+
)
3264
if ib_factory is None:
3365
from ib_insync import IB
3466

tests/test_ibkr_connection.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from concurrent.futures import ThreadPoolExecutor
55
import unittest
66

7-
from quant_platform_kit.ibkr.connection import connect_ib, ensure_event_loop
7+
from quant_platform_kit.ibkr.connection import connect_ib, ensure_event_loop, probe_tcp_endpoint
88

99

1010
class IbkrConnectionTests(unittest.TestCase):
@@ -31,11 +31,80 @@ class FakeIB:
3131
def connect(self, host, port, clientId, timeout):
3232
observed["args"] = (host, port, clientId, timeout)
3333

34-
ib = connect_ib("127.0.0.1", 4001, 9, ib_factory=FakeIB)
34+
class FakeConnection:
35+
def close(self):
36+
observed["socket_closed"] = True
37+
38+
def fake_socket_create_connection(address, timeout):
39+
observed["socket_args"] = (address, timeout)
40+
return FakeConnection()
41+
42+
ib = connect_ib(
43+
"127.0.0.1",
44+
4001,
45+
9,
46+
socket_create_connection=fake_socket_create_connection,
47+
ib_factory=FakeIB,
48+
)
3549

3650
self.assertIsInstance(ib, FakeIB)
3751
self.assertEqual(observed["args"], ("127.0.0.1", 4001, 9, 20))
3852

3953

54+
def test_probe_tcp_endpoint_wraps_timeout(self) -> None:
55+
def fake_socket_create_connection(_address, _timeout):
56+
raise TimeoutError()
57+
58+
with self.assertRaisesRegex(TimeoutError, r'TCP preflight timed out for 10\.0\.0\.8:4002'):
59+
probe_tcp_endpoint(
60+
'10.0.0.8',
61+
4002,
62+
timeout=2.5,
63+
socket_create_connection=fake_socket_create_connection,
64+
)
65+
66+
def test_probe_tcp_endpoint_wraps_os_error(self) -> None:
67+
def fake_socket_create_connection(_address, _timeout):
68+
raise OSError('connection refused')
69+
70+
with self.assertRaisesRegex(ConnectionError, r'TCP preflight failed for 10\.0\.0\.8:4002: connection refused'):
71+
probe_tcp_endpoint(
72+
'10.0.0.8',
73+
4002,
74+
timeout=2.5,
75+
socket_create_connection=fake_socket_create_connection,
76+
)
77+
78+
def test_connect_ib_runs_tcp_preflight_before_ib_connect(self) -> None:
79+
observed: dict[str, object] = {}
80+
81+
class FakeConnection:
82+
def close(self):
83+
observed['socket_closed'] = True
84+
85+
def fake_socket_create_connection(address, timeout):
86+
observed['socket_args'] = (address, timeout)
87+
return FakeConnection()
88+
89+
class FakeIB:
90+
def connect(self, host, port, clientId, timeout):
91+
observed['ib_args'] = (host, port, clientId, timeout)
92+
93+
ib = connect_ib(
94+
'10.0.0.8',
95+
4002,
96+
9,
97+
timeout=20,
98+
tcp_preflight_timeout=3.5,
99+
socket_create_connection=fake_socket_create_connection,
100+
ib_factory=FakeIB,
101+
)
102+
103+
self.assertIsInstance(ib, FakeIB)
104+
self.assertEqual(observed['socket_args'], (('10.0.0.8', 4002), 3.5))
105+
self.assertTrue(observed['socket_closed'])
106+
self.assertEqual(observed['ib_args'], ('10.0.0.8', 4002, 9, 20))
107+
108+
40109
if __name__ == "__main__":
41110
unittest.main()

0 commit comments

Comments
 (0)