Skip to content

Commit de78a37

Browse files
authored
Add semiconductor runtime indicators (#9)
1 parent aad1eee commit de78a37

6 files changed

Lines changed: 64 additions & 11 deletions

File tree

src/quant_platform_kit/ibkr/connection.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,14 @@ def connect_ib(
6767
ib_factory = IB
6868

6969
ib = ib_factory()
70-
ib.connect(host, port, clientId=client_id, timeout=timeout)
70+
try:
71+
ib.connect(host, port, clientId=client_id, timeout=timeout)
72+
except TimeoutError as exc:
73+
raise TimeoutError(
74+
"IBKR API handshake timed out after TCP preflight succeeded "
75+
f"for {host}:{port} clientId={client_id}. "
76+
"Check that IB Gateway/TWS is fully logged in, API access is enabled, "
77+
"the paper/live port matches the session, no login/API prompt is blocking, "
78+
"and the client ID is not already stuck in another session."
79+
) from exc
7180
return ib

src/quant_platform_kit/ibkr/runtime_inputs.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def build_semiconductor_rotation_indicators(
8787
historical_close_loader(
8888
ib,
8989
"SOXX",
90-
duration="20 D",
90+
duration=f"{effective_lookback} D",
9191
bar_size="1 day",
9292
)
9393
)
@@ -96,17 +96,23 @@ def build_semiconductor_rotation_indicators(
9696

9797
soxl_close = pd.to_numeric(soxl_series, errors="coerce").dropna()
9898
soxx_close = pd.to_numeric(soxx_series, errors="coerce").dropna()
99-
if len(soxl_close) < int(trend_ma_window) or soxx_close.empty:
99+
if len(soxl_close) < int(trend_ma_window) or len(soxx_close) < int(trend_ma_window):
100100
raise ValueError("IBKR semiconductor runtime requires sufficient SOXL/SOXX history")
101101

102102
soxl_ma_trend = float(soxl_close.rolling(int(trend_ma_window)).mean().iloc[-1])
103+
soxx_ma_trend = float(soxx_close.rolling(int(trend_ma_window)).mean().iloc[-1])
104+
soxx_ma20 = float(soxx_close.rolling(20).mean().iloc[-1])
105+
soxx_ma20_slope = float(soxx_close.rolling(20).mean().diff().iloc[-1])
103106
return {
104107
"soxl": {
105108
"price": float(soxl_close.iloc[-1]),
106109
"ma_trend": soxl_ma_trend,
107110
},
108111
"soxx": {
109112
"price": float(soxx_close.iloc[-1]),
113+
"ma_trend": soxx_ma_trend,
114+
"ma20": soxx_ma20,
115+
"ma20_slope": soxx_ma20_slope,
110116
},
111117
}
112118

src/quant_platform_kit/longbridge/market_data.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,28 @@ def calculate_rotation_indicators(
2222

2323
effective_lookback = lookback if lookback is not None else max(220, trend_window + 20)
2424
soxl_bars = q_ctx.candlesticks("SOXL.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
25-
soxx_bars = q_ctx.candlesticks("SOXX.US", Period.Day, 20, AdjustType.ForwardAdjust)
25+
soxx_bars = q_ctx.candlesticks("SOXX.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
2626
if not soxl_bars or not soxx_bars:
2727
return None
2828

2929
df_soxl = pd.DataFrame([{"close": float(k.close)} for k in soxl_bars])
3030
df_soxx = pd.DataFrame([float(k.close) for k in soxx_bars], columns=["close"])
31-
if len(df_soxl) < trend_window or len(df_soxx) < 1:
31+
if len(df_soxl) < trend_window or len(df_soxx) < trend_window:
3232
return None
3333

3434
df_soxl["ma_trend"] = df_soxl["close"].rolling(trend_window).mean()
35+
df_soxx["ma_trend"] = df_soxx["close"].rolling(trend_window).mean()
36+
df_soxx["ma20"] = df_soxx["close"].rolling(20).mean()
37+
df_soxx["ma20_slope"] = df_soxx["ma20"].diff()
3538
return {
3639
"soxl": {
3740
"price": float(df_soxl["close"].iloc[-1]),
3841
"ma_trend": float(df_soxl["ma_trend"].iloc[-1]),
3942
},
4043
"soxx": {
4144
"price": float(df_soxx["close"].iloc[-1]),
45+
"ma_trend": float(df_soxx["ma_trend"].iloc[-1]),
46+
"ma20": float(df_soxx["ma20"].iloc[-1]),
47+
"ma20_slope": float(df_soxx["ma20_slope"].iloc[-1]),
4248
},
4349
}

tests/test_ibkr_connection.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ def fake_socket_create_connection(address, timeout):
5050
self.assertIsInstance(ib, FakeIB)
5151
self.assertEqual(observed["args"], ("127.0.0.1", 4001, 9, 20))
5252

53+
def test_connect_ib_wraps_api_handshake_timeout(self) -> None:
54+
class FakeConnection:
55+
def close(self):
56+
pass
57+
58+
def fake_socket_create_connection(_address, _timeout):
59+
return FakeConnection()
60+
61+
class FakeIB:
62+
def connect(self, host, port, clientId, timeout):
63+
raise TimeoutError()
64+
65+
with self.assertRaisesRegex(TimeoutError, "API handshake timed out"):
66+
connect_ib(
67+
"10.0.0.8",
68+
4002,
69+
9,
70+
socket_create_connection=fake_socket_create_connection,
71+
ib_factory=FakeIB,
72+
)
5373

5474
def test_probe_tcp_endpoint_wraps_timeout(self) -> None:
5575
def fake_socket_create_connection(_address, _timeout):

tests/test_ibkr_runtime_inputs.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
8181
if symbol == "SOXL":
8282
return [100.0 + idx for idx in range(170)]
8383
if symbol == "SOXX":
84-
return [200.0 + idx for idx in range(20)]
84+
return [200.0 + idx for idx in range(170)]
8585
raise AssertionError(symbol)
8686

8787
indicators = build_semiconductor_rotation_indicators(
@@ -91,20 +91,29 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
9191
)
9292

9393
self.assertEqual(observed[0], ("SOXL", "220 D", "1 day"))
94-
self.assertEqual(observed[1], ("SOXX", "20 D", "1 day"))
94+
self.assertEqual(observed[1], ("SOXX", "220 D", "1 day"))
9595
self.assertEqual(indicators["soxl"]["price"], 269.0)
9696
self.assertAlmostEqual(
9797
indicators["soxl"]["ma_trend"],
9898
sum(100.0 + idx for idx in range(20, 170)) / 150,
9999
)
100-
self.assertEqual(indicators["soxx"]["price"], 219.0)
100+
self.assertEqual(indicators["soxx"]["price"], 369.0)
101+
self.assertAlmostEqual(
102+
indicators["soxx"]["ma_trend"],
103+
sum(200.0 + idx for idx in range(20, 170)) / 150,
104+
)
105+
self.assertAlmostEqual(
106+
indicators["soxx"]["ma20"],
107+
sum(200.0 + idx for idx in range(150, 170)) / 20,
108+
)
109+
self.assertGreater(indicators["soxx"]["ma20_slope"], 0.0)
101110

102111
def test_build_semiconductor_rotation_inputs_wraps_derived_indicators(self) -> None:
103112
def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
104113
if symbol == "SOXL":
105114
return [100.0] * 170
106115
if symbol == "SOXX":
107-
return [200.0] * 20
116+
return [200.0] * 170
108117
raise AssertionError(symbol)
109118

110119
payload = build_semiconductor_rotation_inputs(
@@ -116,6 +125,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
116125
self.assertEqual(set(payload), {"derived_indicators"})
117126
self.assertEqual(payload["derived_indicators"]["soxl"]["price"], 100.0)
118127
self.assertEqual(payload["derived_indicators"]["soxx"]["price"], 200.0)
128+
self.assertEqual(payload["derived_indicators"]["soxx"]["ma20"], 200.0)
119129

120130
def test_build_semiconductor_rotation_indicators_requires_sufficient_history(self) -> None:
121131
def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):

tests/test_longbridge_market_data.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def quote(self, symbols):
2525
def candlesticks(self, symbol, period, count, adjust_type):
2626
if symbol == "SOXL.US":
2727
return [FakeBar(100 + i) for i in range(count)]
28-
return [FakeBar(200.0) for _ in range(20)]
28+
return [FakeBar(200.0 + i) for i in range(count)]
2929

3030

3131
class LongBridgeMarketDataTests(unittest.TestCase):
@@ -43,7 +43,9 @@ def test_calculate_rotation_indicators(self) -> None:
4343

4444
self.assertIsNotNone(indicators)
4545
self.assertEqual(indicators["soxl"]["price"], 319.0)
46-
self.assertEqual(indicators["soxx"]["price"], 200.0)
46+
self.assertEqual(indicators["soxx"]["price"], 419.0)
47+
self.assertAlmostEqual(indicators["soxx"]["ma20"], sum(200.0 + i for i in range(200, 220)) / 20)
48+
self.assertGreater(indicators["soxx"]["ma20_slope"], 0.0)
4749

4850

4951
if __name__ == "__main__":

0 commit comments

Comments
 (0)