Skip to content

Commit 90d5a1c

Browse files
committed
Add IBKR support for TQQQ growth income
1 parent 5daa429 commit 90d5a1c

6 files changed

Lines changed: 203 additions & 10 deletions

File tree

main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from quant_platform_kit.ibkr import (
2626
connect_ib as ibkr_connect_ib,
2727
ensure_event_loop as ibkr_ensure_event_loop,
28+
fetch_historical_price_candles,
2829
fetch_historical_price_series,
2930
fetch_portfolio_snapshot,
3031
fetch_quote_snapshots,
@@ -349,6 +350,16 @@ def get_historical_close(ib, symbol, duration="2 Y", bar_size="1 day"):
349350
)
350351

351352

353+
def get_historical_candles(ib, symbol, duration="2 Y", bar_size="1 day"):
354+
"""Fetch daily OHLC candles from IBKR via QuantPlatformKit."""
355+
return fetch_historical_price_candles(
356+
ib,
357+
symbol,
358+
duration=duration,
359+
bar_size=bar_size,
360+
)
361+
362+
352363
# ---------------------------------------------------------------------------
353364
# Strategy logic
354365
# ---------------------------------------------------------------------------
@@ -357,6 +368,7 @@ def compute_signals(ib, current_holdings):
357368
ib=ib,
358369
current_holdings=current_holdings,
359370
historical_close_loader=get_historical_close,
371+
historical_candle_loader=get_historical_candles,
360372
run_as_of=resolve_run_as_of_date(),
361373
translator=t,
362374
pacing_sec=HIST_DATA_PACING_SEC,

strategy_registry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"global_etf_rotation",
2828
"russell_1000_multi_factor_defensive",
2929
"soxl_soxx_trend_income",
30+
"tqqq_growth_income",
3031
}
3132
)
3233

@@ -38,7 +39,9 @@
3839
platform_id=IBKR_PLATFORM,
3940
supported_domains=PLATFORM_SUPPORTED_DOMAINS[IBKR_PLATFORM],
4041
supported_target_modes=frozenset({"weight", "value"}),
41-
supported_inputs=frozenset({"market_history", "feature_snapshot", "derived_indicators", "portfolio_snapshot"}),
42+
supported_inputs=frozenset(
43+
{"market_history", "benchmark_history", "feature_snapshot", "derived_indicators", "portfolio_snapshot"}
44+
),
4245
supported_capabilities=frozenset({"broker_client"}),
4346
)
4447
ELIGIBLE_STRATEGY_PROFILES = derive_eligible_profiles_for_platform(

strategy_runtime.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from quant_platform_kit.common.feature_snapshot import load_feature_snapshot_guarded
1010
from quant_platform_kit.ibkr import (
1111
build_ibkr_strategy_context,
12+
build_benchmark_history_inputs,
1213
build_market_history_inputs,
1314
build_semiconductor_rotation_inputs,
1415
fetch_portfolio_snapshot,
@@ -29,6 +30,7 @@
2930
DEFAULT_CASH_RESERVE_RATIO = 0.03
3031
_FEATURE_SNAPSHOT_INPUT = "feature_snapshot"
3132
_MARKET_HISTORY_INPUT = "market_history"
33+
_BENCHMARK_HISTORY_INPUT = "benchmark_history"
3234
_DERIVED_INDICATORS_INPUT = "derived_indicators"
3335
_PORTFOLIO_SNAPSHOT_INPUT = "portfolio_snapshot"
3436

@@ -64,6 +66,7 @@ def evaluate(
6466
ib,
6567
current_holdings,
6668
historical_close_loader: Callable[..., Any],
69+
historical_candle_loader: Callable[..., Any] | None = None,
6770
run_as_of: pd.Timestamp,
6871
translator: Callable[[str], str],
6972
pacing_sec: float,
@@ -81,15 +84,20 @@ def evaluate(
8184
ib=ib,
8285
current_holdings=current_holdings,
8386
historical_close_loader=historical_close_loader,
87+
historical_candle_loader=historical_candle_loader,
8488
run_as_of=run_as_of,
8589
translator=translator,
8690
pacing_sec=pacing_sec,
8791
)
88-
if {_DERIVED_INDICATORS_INPUT, _PORTFOLIO_SNAPSHOT_INPUT}.issubset(self.required_inputs):
92+
if _PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs and (
93+
_DERIVED_INDICATORS_INPUT in self.required_inputs
94+
or _BENCHMARK_HISTORY_INPUT in self.required_inputs
95+
):
8996
return self._evaluate_value_target_strategy(
9097
ib=ib,
9198
current_holdings=current_holdings,
9299
historical_close_loader=historical_close_loader,
100+
historical_candle_loader=historical_candle_loader,
93101
run_as_of=run_as_of,
94102
translator=translator,
95103
pacing_sec=pacing_sec,
@@ -105,6 +113,7 @@ def _evaluate_market_data_strategy(
105113
ib,
106114
current_holdings,
107115
historical_close_loader: Callable[..., Any],
116+
historical_candle_loader: Callable[..., Any] | None,
108117
run_as_of: pd.Timestamp,
109118
translator: Callable[[str], str],
110119
pacing_sec: float,
@@ -144,6 +153,7 @@ def _evaluate_value_target_strategy(
144153
ib,
145154
current_holdings,
146155
historical_close_loader: Callable[..., Any],
156+
historical_candle_loader: Callable[..., Any] | None,
147157
run_as_of: pd.Timestamp,
148158
translator: Callable[[str], str],
149159
pacing_sec: float,
@@ -152,15 +162,16 @@ def _evaluate_value_target_strategy(
152162
runtime_config.setdefault("translator", translator)
153163
runtime_config.setdefault("pacing_sec", float(pacing_sec))
154164
portfolio_snapshot = fetch_portfolio_snapshot(ib)
165+
market_inputs = self._build_value_target_market_inputs(
166+
ib=ib,
167+
historical_close_loader=historical_close_loader,
168+
historical_candle_loader=historical_candle_loader,
169+
)
155170
ctx = build_ibkr_strategy_context(
156171
entrypoint=self.entrypoint,
157172
runtime_adapter=self.runtime_adapter,
158173
as_of=run_as_of,
159-
market_inputs=build_semiconductor_rotation_inputs(
160-
ib,
161-
historical_close_loader,
162-
trend_ma_window=int(self.merged_runtime_config.get("trend_ma_window", 150)),
163-
),
174+
market_inputs=market_inputs,
164175
portfolio_snapshot=portfolio_snapshot,
165176
runtime_config=runtime_config,
166177
current_holdings=current_holdings,
@@ -183,8 +194,42 @@ def _evaluate_value_target_strategy(
183194
}
184195
if safe_haven_symbol:
185196
metadata["safe_haven_symbol"] = str(safe_haven_symbol)
197+
benchmark_symbol = market_inputs.get("benchmark_symbol")
198+
if benchmark_symbol:
199+
metadata["benchmark_symbol"] = str(benchmark_symbol)
186200
return StrategyEvaluationResult(decision=decision, metadata=metadata)
187201

202+
def _build_value_target_market_inputs(
203+
self,
204+
*,
205+
ib,
206+
historical_close_loader: Callable[..., Any],
207+
historical_candle_loader: Callable[..., Any] | None,
208+
) -> dict[str, Any]:
209+
if _DERIVED_INDICATORS_INPUT in self.required_inputs:
210+
return build_semiconductor_rotation_inputs(
211+
ib,
212+
historical_close_loader,
213+
trend_ma_window=int(self.merged_runtime_config.get("trend_ma_window", 150)),
214+
)
215+
if _BENCHMARK_HISTORY_INPUT in self.required_inputs:
216+
if historical_candle_loader is None:
217+
raise ValueError(
218+
f"IBKR strategy profile {self.profile!r} requires benchmark_history but no candle loader was provided"
219+
)
220+
benchmark_symbol = str(self.merged_runtime_config.get("benchmark_symbol") or "QQQ").strip().upper()
221+
market_inputs = build_benchmark_history_inputs(
222+
ib,
223+
historical_candle_loader,
224+
benchmark_symbol=benchmark_symbol,
225+
)
226+
market_inputs["benchmark_symbol"] = benchmark_symbol
227+
return market_inputs
228+
raise ValueError(
229+
f"Unsupported value-target required_inputs for IBKR strategy profile {self.profile!r}: "
230+
f"{', '.join(sorted(self.required_inputs)) or '<none>'}"
231+
)
232+
188233
def _evaluate_feature_snapshot_strategy(
189234
self,
190235
*,

tests/test_runtime_config_support.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def test_platform_supported_profiles_are_filtered_by_registry():
150150
assert get_supported_profiles_for_platform(IBKR_PLATFORM) == frozenset(
151151
{
152152
"soxl_soxx_trend_income",
153+
"tqqq_growth_income",
153154
"qqq_tech_enhancement",
154155
"global_etf_rotation",
155156
"russell_1000_multi_factor_defensive",
@@ -161,6 +162,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix():
161162
assert get_eligible_profiles_for_platform(IBKR_PLATFORM) == frozenset(
162163
{
163164
"soxl_soxx_trend_income",
165+
"tqqq_growth_income",
164166
"qqq_tech_enhancement",
165167
"global_etf_rotation",
166168
"russell_1000_multi_factor_defensive",
@@ -181,6 +183,18 @@ def test_load_platform_runtime_settings_accepts_qqq_tech_enhancement(monkeypatch
181183
assert settings.strategy_target_mode == "weight"
182184

183185

186+
def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch):
187+
monkeypatch.setenv("STRATEGY_PROFILE", "tqqq_growth_income")
188+
monkeypatch.setenv("ACCOUNT_GROUP", "default")
189+
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
190+
191+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
192+
193+
assert settings.strategy_profile == "tqqq_growth_income"
194+
assert settings.strategy_display_name == "TQQQ Growth Income"
195+
assert settings.strategy_target_mode == "value"
196+
197+
184198
def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch):
185199
monkeypatch.setenv("STRATEGY_PROFILE", "tech_pullback_cash_buffer")
186200
monkeypatch.setenv("ACCOUNT_GROUP", "default")
@@ -207,6 +221,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
207221
"global_etf_rotation",
208222
"russell_1000_multi_factor_defensive",
209223
"soxl_soxx_trend_income",
224+
"tqqq_growth_income",
210225
"qqq_tech_enhancement",
211226
}
212227
assert by_profile["global_etf_rotation"] == {
@@ -222,6 +237,9 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
222237
assert by_profile["soxl_soxx_trend_income"]["display_name"] == "SOXL/SOXX Semiconductor Trend Income"
223238
assert by_profile["soxl_soxx_trend_income"]["eligible"] is True
224239
assert by_profile["soxl_soxx_trend_income"]["enabled"] is True
240+
assert by_profile["tqqq_growth_income"]["display_name"] == "TQQQ Growth Income"
241+
assert by_profile["tqqq_growth_income"]["eligible"] is True
242+
assert by_profile["tqqq_growth_income"]["enabled"] is True
225243

226244

227245
def test_print_strategy_profile_status_json_matches_registry():
@@ -247,6 +265,7 @@ def test_print_strategy_profile_status_table_contains_expected_headers():
247265
assert "display_name" in result.stdout
248266
assert "global_etf_rotation" in result.stdout
249267
assert "QQQ Tech Enhancement" in result.stdout
268+
assert "TQQQ Growth Income" in result.stdout
250269

251270

252271

tests/test_strategy_loader.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ def test_load_strategy_entrypoint_for_profile_resolves_qqq_tech_enhancement(monk
3636
assert entrypoint.manifest.default_config["safe_haven"] == "BOXX"
3737

3838

39+
def test_load_strategy_entrypoint_for_profile_resolves_tqqq_growth_income(monkeypatch):
40+
try:
41+
import pandas # noqa: F401
42+
except ModuleNotFoundError:
43+
return
44+
45+
market_calendars_module = types.ModuleType("pandas_market_calendars")
46+
market_calendars_module.get_calendar = lambda name: None
47+
monkeypatch.setitem(sys.modules, "pandas_market_calendars", market_calendars_module)
48+
49+
entrypoint = load_strategy_entrypoint_for_profile("tqqq_growth_income")
50+
51+
assert entrypoint.manifest.profile == "tqqq_growth_income"
52+
assert entrypoint.manifest.required_inputs == frozenset({"benchmark_history", "portfolio_snapshot"})
53+
assert entrypoint.manifest.default_config["benchmark_symbol"] == "QQQ"
54+
55+
3956
def test_load_strategy_entrypoint_for_profile_rejects_legacy_cash_buffer_profile(monkeypatch):
4057
try:
4158
import pandas # noqa: F401
@@ -95,6 +112,22 @@ def test_load_strategy_runtime_adapter_for_profile_resolves_semiconductor_inputs
95112
assert adapter.portfolio_input_name == "portfolio_snapshot"
96113

97114

115+
def test_load_strategy_runtime_adapter_for_profile_resolves_tqqq_inputs(monkeypatch):
116+
try:
117+
import pandas # noqa: F401
118+
except ModuleNotFoundError:
119+
return
120+
121+
market_calendars_module = types.ModuleType("pandas_market_calendars")
122+
market_calendars_module.get_calendar = lambda name: None
123+
monkeypatch.setitem(sys.modules, "pandas_market_calendars", market_calendars_module)
124+
125+
adapter = load_strategy_runtime_adapter_for_profile("tqqq_growth_income")
126+
127+
assert adapter.available_inputs == frozenset({"benchmark_history", "portfolio_snapshot"})
128+
assert adapter.portfolio_input_name == "portfolio_snapshot"
129+
130+
98131
def test_load_strategy_runtime_adapter_for_profile_rejects_legacy_semiconductor_alias(monkeypatch):
99132
try:
100133
import pandas # noqa: F401

0 commit comments

Comments
 (0)