Skip to content

Commit 5533f42

Browse files
authored
Enable smart DCA IBKR runtime (#101)
1 parent d39c1fb commit 5533f42

9 files changed

Lines changed: 137 additions & 7 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The mainline runtime now follows one path only:
3838
- `soxl_soxx_trend_income`
3939
- `tech_communication_pullback_enhancement`
4040
- `mega_cap_leader_rotation_top50_balanced`
41+
- `nasdaq_sp500_smart_dca`
4142

4243

4344
**IBKR profile status**
@@ -50,6 +51,7 @@ The mainline runtime now follows one path only:
5051
| `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | `us_equity` | current IBKR live line |
5152
| `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative |
5253
| `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation |
54+
| `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile |
5355

5456
Check the current matrix locally:
5557

@@ -99,7 +101,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
99101
| `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. |
100102
| `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. |
101103
| `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. |
102-
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` |
104+
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca` |
103105
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
104106
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
105107
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
@@ -426,7 +428,7 @@ IBKR 账户
426428
| `IBKR_CONNECT_ATTEMPTS` || IBKR 连接失败前最多尝试次数。默认 `3`|
427429
| `IBKR_CONNECT_RETRY_DELAY_SECONDS` || IBKR 连接重试间隔,单位秒。默认 `5`|
428430
| `IBKR_CLIENT_ID_RETRY_OFFSET` || 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`|
429-
| `STRATEGY_PROFILE` || 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation``russell_1000_multi_factor_defensive``tqqq_growth_income``soxl_soxx_trend_income``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` |
431+
| `STRATEGY_PROFILE` || 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation``russell_1000_multi_factor_defensive``tqqq_growth_income``soxl_soxx_trend_income``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced``nasdaq_sp500_smart_dca` |
430432
| `ACCOUNT_GROUP` || 账号组选择器,每个部署都要显式设置。 |
431433
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
432434
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` || 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`|

main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def _env_flag(name: str) -> bool:
250250

251251
CASH_RESERVE_RATIO = STRATEGY_RUNTIME.cash_reserve_ratio
252252
CASH_RESERVE_FLOOR_USD = getattr(STRATEGY_RUNTIME, "cash_reserve_floor_usd", 0.0)
253-
REBALANCE_THRESHOLD_RATIO = 0.02
253+
REBALANCE_THRESHOLD_RATIO = STRATEGY_RUNTIME.rebalance_threshold_ratio
254254
LIMIT_BUY_PREMIUM = 1.005
255255
SELL_SETTLE_DELAY_SEC = 3
256256
HIST_DATA_PACING_SEC = 0.5

notifications/telegram.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强",
135135
"strategy_name_qqq_tech_enhancement": "科技通信回调增强",
136136
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动",
137+
"strategy_name_nasdaq_sp500_smart_dca": "纳斯达克 / 标普智能定投",
137138
"strategy_name_tqqq_growth_income": "TQQQ 增长收益",
138139
"strategy_name_soxl_soxx_trend_income": "SOXL/SOXX 半导体趋势收益",
139140
},
@@ -267,6 +268,7 @@
267268
"strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement",
268269
"strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement",
269270
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced",
271+
"strategy_name_nasdaq_sp500_smart_dca": "Nasdaq/S&P 500 Smart DCA",
270272
"strategy_name_tqqq_growth_income": "TQQQ Growth Income",
271273
"strategy_name_soxl_soxx_trend_income": "SOXL/SOXX Semiconductor Trend Income",
272274
},

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
33
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b704350bc7e7b00f16a1d71d70f138fd1ba0a567
55
pandas
66
numpy
77
requests

strategy_runtime.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838

3939
DEFAULT_CASH_RESERVE_RATIO = 0.03
40+
DEFAULT_REBALANCE_THRESHOLD_RATIO = 0.02
4041
_FEATURE_SNAPSHOT_INPUT = "feature_snapshot"
4142
_MARKET_HISTORY_INPUT = "market_history"
4243
_BENCHMARK_HISTORY_INPUT = "benchmark_history"
@@ -59,6 +60,7 @@ class LoadedStrategyRuntime:
5960
merged_runtime_config: Mapping[str, Any] = field(default_factory=dict)
6061
status_icon: str = "🐤"
6162
cash_reserve_ratio: float = DEFAULT_CASH_RESERVE_RATIO
63+
rebalance_threshold_ratio: float = DEFAULT_REBALANCE_THRESHOLD_RATIO
6264
cash_reserve_floor_usd: float = 0.0
6365
logger: Callable[[str], None] = print
6466

@@ -293,7 +295,14 @@ def _evaluate_market_data_strategy(
293295
runtime_config.setdefault("translator", translator)
294296
runtime_config.setdefault("pacing_sec", float(pacing_sec))
295297
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
296-
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=False)
298+
requires_portfolio = (
299+
_PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs
300+
or self.runtime_adapter.portfolio_input_name == _PORTFOLIO_SNAPSHOT_INPUT
301+
)
302+
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(
303+
ib,
304+
required=requires_portfolio,
305+
)
297306
ctx = self._build_strategy_context(
298307
runtime_adapter=self.runtime_adapter,
299308
as_of=run_as_of,
@@ -305,8 +314,11 @@ def _evaluate_market_data_strategy(
305314
)
306315
decision = self.entrypoint.evaluate(ctx)
307316
safe_haven_symbol = str(self.merged_runtime_config.get("safe_haven") or "").strip().upper() or None
317+
managed_config_symbols = tuple(
318+
str(symbol) for symbol in self.merged_runtime_config.get("managed_symbols", ())
319+
)
308320
ranking_pool = tuple(str(symbol) for symbol in self.merged_runtime_config.get("ranking_pool", ()))
309-
managed_candidates = list(ranking_pool)
321+
managed_candidates = [*managed_config_symbols, *ranking_pool]
310322
if safe_haven_symbol:
311323
managed_candidates.append(safe_haven_symbol)
312324
managed_symbols = tuple(dict.fromkeys(managed_candidates))
@@ -638,6 +650,15 @@ def load_strategy_runtime(
638650
DEFAULT_CASH_RESERVE_RATIO,
639651
)
640652
)
653+
strategy_rebalance_threshold_ratio = float(
654+
merged_runtime_config.get(
655+
"execution_rebalance_threshold_ratio",
656+
merged_runtime_config.get(
657+
"rebalance_threshold_ratio",
658+
DEFAULT_REBALANCE_THRESHOLD_RATIO,
659+
),
660+
)
661+
)
641662
platform_cash_reserve_ratio = runtime_settings.reserved_cash_ratio
642663
if platform_cash_reserve_ratio is not None:
643664
strategy_cash_reserve_ratio = max(
@@ -652,6 +673,7 @@ def load_strategy_runtime(
652673
merged_runtime_config=merged_runtime_config,
653674
status_icon=runtime_adapter.status_icon,
654675
cash_reserve_ratio=strategy_cash_reserve_ratio,
676+
rebalance_threshold_ratio=max(0.0, strategy_rebalance_threshold_ratio),
655677
cash_reserve_floor_usd=float(runtime_settings.reserved_cash_floor_usd or 0.0),
656678
logger=logger,
657679
)

tests/test_connect_timeout_alert.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def run(self, *args, **kwargs):
9292
available_inputs=frozenset({"derived_indicators", "portfolio_snapshot"})
9393
),
9494
cash_reserve_ratio=0.03,
95+
rebalance_threshold_ratio=0.02,
9596
evaluate=lambda **_kwargs: None,
9697
)
9798

tests/test_notifications.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ def test_strategy_display_name_translates_new_live_profiles():
8282
assert en_name("global_etf_confidence_vol_gate") == "Global ETF Confidence Vol Gate"
8383
assert zh_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Top50 平衡龙头轮动"
8484
assert en_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Leader Rotation Top50 Balanced"
85+
assert zh_name("nasdaq_sp500_smart_dca") == "纳斯达克 / 标普智能定投"
86+
assert en_name("nasdaq_sp500_smart_dca") == "Nasdaq/S&P 500 Smart DCA"
8587

8688

8789
def test_supported_strategy_profiles_have_translated_names():

tests/test_runtime_config_support.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ def test_platform_supported_profiles_are_filtered_by_registry():
463463
"tech_communication_pullback_enhancement",
464464
"global_etf_rotation",
465465
"mega_cap_leader_rotation_top50_balanced",
466+
"nasdaq_sp500_smart_dca",
466467
"russell_1000_multi_factor_defensive",
467468
}
468469
)
@@ -476,6 +477,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix():
476477
"tech_communication_pullback_enhancement",
477478
"global_etf_rotation",
478479
"mega_cap_leader_rotation_top50_balanced",
480+
"nasdaq_sp500_smart_dca",
479481
"russell_1000_multi_factor_defensive",
480482
}
481483
)
@@ -528,6 +530,18 @@ def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch):
528530
assert settings.strategy_target_mode == "value"
529531

530532

533+
def test_load_platform_runtime_settings_accepts_nasdaq_sp500_smart_dca(monkeypatch):
534+
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("nasdaq_sp500_smart_dca"))
535+
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
536+
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
537+
538+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
539+
540+
assert settings.strategy_profile == "nasdaq_sp500_smart_dca"
541+
assert settings.strategy_display_name == "Nasdaq/S&P 500 Smart DCA"
542+
assert settings.strategy_target_mode == "value"
543+
544+
531545
def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch):
532546
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("tech_pullback_cash_buffer"))
533547
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
@@ -557,6 +571,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
557571
"tqqq_growth_income",
558572
"tech_communication_pullback_enhancement",
559573
"mega_cap_leader_rotation_top50_balanced",
574+
"nasdaq_sp500_smart_dca",
560575
}
561576
assert by_profile["global_etf_rotation"] == {
562577
"canonical_profile": "global_etf_rotation",
@@ -572,6 +587,9 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
572587
assert by_profile["tqqq_growth_income"]["display_name"] == "TQQQ Growth Income"
573588
assert by_profile["tqqq_growth_income"]["eligible"] is True
574589
assert by_profile["tqqq_growth_income"]["enabled"] is True
590+
assert by_profile["nasdaq_sp500_smart_dca"]["display_name"] == "Nasdaq/S&P 500 Smart DCA"
591+
assert by_profile["nasdaq_sp500_smart_dca"]["eligible"] is True
592+
assert by_profile["nasdaq_sp500_smart_dca"]["enabled"] is True
575593

576594

577595
def test_print_strategy_profile_status_json_matches_registry():
@@ -602,6 +620,9 @@ def test_print_strategy_profile_status_json_matches_registry():
602620
assert by_profile["global_etf_rotation"]["input_mode"] == "market_history"
603621
assert by_profile["global_etf_rotation"]["requires_snapshot_artifacts"] is False
604622
assert by_profile["global_etf_rotation"]["requires_strategy_config_path"] is False
623+
assert by_profile["nasdaq_sp500_smart_dca"]["profile_group"] == "direct_runtime_inputs"
624+
assert by_profile["nasdaq_sp500_smart_dca"]["input_mode"] == "market_history+portfolio_snapshot"
625+
assert by_profile["nasdaq_sp500_smart_dca"]["requires_snapshot_artifacts"] is False
605626
assert by_profile["tech_communication_pullback_enhancement"]["profile_group"] == "snapshot_backed"
606627
assert by_profile["tech_communication_pullback_enhancement"]["input_mode"] == "feature_snapshot"
607628
assert by_profile["tech_communication_pullback_enhancement"]["requires_snapshot_artifacts"] is True

tests/test_strategy_runtime.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ class FakeEntrypoint:
117117
display_name="Tech/Communication Pullback Enhancement",
118118
description="test",
119119
required_inputs=frozenset({"feature_snapshot"}),
120-
default_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"},
120+
default_config={
121+
"safe_haven": "BOXX",
122+
"benchmark_symbol": "QQQ",
123+
"execution_rebalance_threshold_ratio": 0.0,
124+
},
121125
)
122126

123127
def evaluate(self, ctx):
@@ -156,6 +160,7 @@ def evaluate(self, ctx):
156160
assert runtime.merged_runtime_config["safe_haven"] == "BOXX"
157161
assert runtime.merged_runtime_config["benchmark_symbol"] == "SPY"
158162
assert runtime.merged_runtime_config["rebalance_months"] == (1, 4, 7, 10)
163+
assert runtime.rebalance_threshold_ratio == 0.0
159164
assert runtime.status_icon == "🧲"
160165

161166

@@ -376,6 +381,81 @@ def loader(*_args, **_kwargs):
376381
assert result.metadata["execution_timing_contract"] == "next_trading_day"
377382

378383

384+
def test_market_history_value_runtime_requires_portfolio_snapshot(monkeypatch):
385+
captured = {}
386+
387+
class FakeEntrypoint:
388+
manifest = StrategyManifest(
389+
profile="nasdaq_sp500_smart_dca",
390+
domain="us_equity",
391+
display_name="Nasdaq/S&P 500 Smart DCA",
392+
description="test",
393+
required_inputs=frozenset({"market_history", "portfolio_snapshot"}),
394+
default_config={"managed_symbols": ("QQQM", "SPLG")},
395+
)
396+
397+
def evaluate(self, ctx):
398+
captured["market_data"] = dict(ctx.market_data)
399+
captured["portfolio"] = ctx.portfolio
400+
captured["runtime_config"] = dict(ctx.runtime_config)
401+
return StrategyDecision(
402+
positions=(
403+
PositionTarget(symbol="QQQM", target_value=1500.0),
404+
PositionTarget(symbol="SPLG", target_value=1700.0),
405+
)
406+
)
407+
408+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
409+
entrypoint=FakeEntrypoint(),
410+
runtime_adapter=StrategyRuntimeAdapter(
411+
status_icon="🧺",
412+
portfolio_input_name="portfolio_snapshot",
413+
runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=0),
414+
),
415+
runtime_settings=_build_runtime_settings(
416+
profile="nasdaq_sp500_smart_dca",
417+
display_name="Nasdaq/S&P 500 Smart DCA",
418+
target_mode="value",
419+
),
420+
runtime_config={},
421+
merged_runtime_config={"managed_symbols": ("QQQM", "SPLG")},
422+
status_icon="🧺",
423+
logger=lambda _message: None,
424+
)
425+
portfolio_snapshot = SimpleNamespace(total_equity=10000.0)
426+
427+
def fetch_snapshot(ib):
428+
assert ib == "fake-ib"
429+
return portfolio_snapshot
430+
431+
close_loader_symbols = []
432+
433+
def close_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
434+
close_loader_symbols.append((symbol, duration, bar_size))
435+
return strategy_runtime_module.pd.Series([100.0, 101.0])
436+
437+
monkeypatch.setattr(strategy_runtime_module, "fetch_portfolio_snapshot", fetch_snapshot)
438+
439+
result = runtime.evaluate(
440+
ib="fake-ib",
441+
current_holdings=(),
442+
historical_close_loader=close_loader,
443+
run_as_of=strategy_runtime_module.pd.Timestamp("2026-05-26"),
444+
translator=lambda key, **_kwargs: key,
445+
pacing_sec=0.0,
446+
)
447+
448+
assert captured["market_data"]["market_history"] is close_loader
449+
assert captured["portfolio"] is portfolio_snapshot
450+
assert captured["runtime_config"]["signal_effective_after_trading_days"] == 0
451+
assert result.metadata["portfolio_total_equity"] == 10000.0
452+
assert result.metadata["managed_symbols"] == ("QQQM", "SPLG")
453+
assert result.metadata["signal_date"] == "2026-05-26"
454+
assert result.metadata["effective_date"] == "2026-05-26"
455+
assert result.metadata["execution_timing_contract"] == "same_trading_day"
456+
assert close_loader_symbols == [("QQQM", "10 D", "1 day"), ("SPLG", "10 D", "1 day")]
457+
458+
379459
def test_feature_snapshot_runtime_fail_closes_on_entrypoint_exception(monkeypatch):
380460
class ExplodingEntrypoint:
381461
manifest = StrategyManifest(

0 commit comments

Comments
 (0)