From b7c5fb4b3c4ff0bbe8f0f2bd1f21c7bbf72f1f30 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 26 May 2026 18:37:27 +0800 Subject: [PATCH] Enable smart DCA IBKR runtime --- README.md | 6 +- main.py | 2 +- notifications/telegram.py | 2 + requirements.txt | 2 +- strategy_runtime.py | 26 ++++++++- tests/test_connect_timeout_alert.py | 1 + tests/test_notifications.py | 2 + tests/test_runtime_config_support.py | 21 +++++++ tests/test_strategy_runtime.py | 82 +++++++++++++++++++++++++++- 9 files changed, 137 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d6370ed..9ea5c54 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The mainline runtime now follows one path only: - `soxl_soxx_trend_income` - `tech_communication_pullback_enhancement` - `mega_cap_leader_rotation_top50_balanced` +- `nasdaq_sp500_smart_dca` **IBKR profile status** @@ -50,6 +51,7 @@ The mainline runtime now follows one path only: | `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | `us_equity` | current IBKR live line | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation | +| `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile | Check the current matrix locally: @@ -99,7 +101,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. | | `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`. | -| `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` | +| `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` | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `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`). | | `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 账户 | `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 | | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | -| `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` | +| `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` | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | diff --git a/main.py b/main.py index 5097408..a194315 100644 --- a/main.py +++ b/main.py @@ -250,7 +250,7 @@ def _env_flag(name: str) -> bool: CASH_RESERVE_RATIO = STRATEGY_RUNTIME.cash_reserve_ratio CASH_RESERVE_FLOOR_USD = getattr(STRATEGY_RUNTIME, "cash_reserve_floor_usd", 0.0) -REBALANCE_THRESHOLD_RATIO = 0.02 +REBALANCE_THRESHOLD_RATIO = STRATEGY_RUNTIME.rebalance_threshold_ratio LIMIT_BUY_PREMIUM = 1.005 SELL_SETTLE_DELAY_SEC = 3 HIST_DATA_PACING_SEC = 0.5 diff --git a/notifications/telegram.py b/notifications/telegram.py index 83850b6..dfec723 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -134,6 +134,7 @@ "strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强", "strategy_name_qqq_tech_enhancement": "科技通信回调增强", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动", + "strategy_name_nasdaq_sp500_smart_dca": "纳斯达克 / 标普智能定投", "strategy_name_tqqq_growth_income": "TQQQ 增长收益", "strategy_name_soxl_soxx_trend_income": "SOXL/SOXX 半导体趋势收益", }, @@ -267,6 +268,7 @@ "strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced", + "strategy_name_nasdaq_sp500_smart_dca": "Nasdaq/S&P 500 Smart DCA", "strategy_name_tqqq_growth_income": "TQQQ Growth Income", "strategy_name_soxl_soxx_trend_income": "SOXL/SOXX Semiconductor Trend Income", }, diff --git a/requirements.txt b/requirements.txt index b9ca8c6..fa1f9ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b704350bc7e7b00f16a1d71d70f138fd1ba0a567 pandas numpy requests diff --git a/strategy_runtime.py b/strategy_runtime.py index 930c1d5..e195140 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -37,6 +37,7 @@ DEFAULT_CASH_RESERVE_RATIO = 0.03 +DEFAULT_REBALANCE_THRESHOLD_RATIO = 0.02 _FEATURE_SNAPSHOT_INPUT = "feature_snapshot" _MARKET_HISTORY_INPUT = "market_history" _BENCHMARK_HISTORY_INPUT = "benchmark_history" @@ -59,6 +60,7 @@ class LoadedStrategyRuntime: merged_runtime_config: Mapping[str, Any] = field(default_factory=dict) status_icon: str = "🐤" cash_reserve_ratio: float = DEFAULT_CASH_RESERVE_RATIO + rebalance_threshold_ratio: float = DEFAULT_REBALANCE_THRESHOLD_RATIO cash_reserve_floor_usd: float = 0.0 logger: Callable[[str], None] = print @@ -293,7 +295,14 @@ def _evaluate_market_data_strategy( runtime_config.setdefault("translator", translator) runtime_config.setdefault("pacing_sec", float(pacing_sec)) apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter) - portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=False) + requires_portfolio = ( + _PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs + or self.runtime_adapter.portfolio_input_name == _PORTFOLIO_SNAPSHOT_INPUT + ) + portfolio_snapshot = self._fetch_portfolio_snapshot_for_context( + ib, + required=requires_portfolio, + ) ctx = self._build_strategy_context( runtime_adapter=self.runtime_adapter, as_of=run_as_of, @@ -305,8 +314,11 @@ def _evaluate_market_data_strategy( ) decision = self.entrypoint.evaluate(ctx) safe_haven_symbol = str(self.merged_runtime_config.get("safe_haven") or "").strip().upper() or None + managed_config_symbols = tuple( + str(symbol) for symbol in self.merged_runtime_config.get("managed_symbols", ()) + ) ranking_pool = tuple(str(symbol) for symbol in self.merged_runtime_config.get("ranking_pool", ())) - managed_candidates = list(ranking_pool) + managed_candidates = [*managed_config_symbols, *ranking_pool] if safe_haven_symbol: managed_candidates.append(safe_haven_symbol) managed_symbols = tuple(dict.fromkeys(managed_candidates)) @@ -638,6 +650,15 @@ def load_strategy_runtime( DEFAULT_CASH_RESERVE_RATIO, ) ) + strategy_rebalance_threshold_ratio = float( + merged_runtime_config.get( + "execution_rebalance_threshold_ratio", + merged_runtime_config.get( + "rebalance_threshold_ratio", + DEFAULT_REBALANCE_THRESHOLD_RATIO, + ), + ) + ) platform_cash_reserve_ratio = runtime_settings.reserved_cash_ratio if platform_cash_reserve_ratio is not None: strategy_cash_reserve_ratio = max( @@ -652,6 +673,7 @@ def load_strategy_runtime( merged_runtime_config=merged_runtime_config, status_icon=runtime_adapter.status_icon, cash_reserve_ratio=strategy_cash_reserve_ratio, + rebalance_threshold_ratio=max(0.0, strategy_rebalance_threshold_ratio), cash_reserve_floor_usd=float(runtime_settings.reserved_cash_floor_usd or 0.0), logger=logger, ) diff --git a/tests/test_connect_timeout_alert.py b/tests/test_connect_timeout_alert.py index ba1bec8..cda9803 100644 --- a/tests/test_connect_timeout_alert.py +++ b/tests/test_connect_timeout_alert.py @@ -92,6 +92,7 @@ def run(self, *args, **kwargs): available_inputs=frozenset({"derived_indicators", "portfolio_snapshot"}) ), cash_reserve_ratio=0.03, + rebalance_threshold_ratio=0.02, evaluate=lambda **_kwargs: None, ) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 3899758..746e80f 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -82,6 +82,8 @@ def test_strategy_display_name_translates_new_live_profiles(): assert en_name("global_etf_confidence_vol_gate") == "Global ETF Confidence Vol Gate" assert zh_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Top50 平衡龙头轮动" assert en_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Leader Rotation Top50 Balanced" + assert zh_name("nasdaq_sp500_smart_dca") == "纳斯达克 / 标普智能定投" + assert en_name("nasdaq_sp500_smart_dca") == "Nasdaq/S&P 500 Smart DCA" def test_supported_strategy_profiles_have_translated_names(): diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index a962807..1115a19 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -463,6 +463,7 @@ def test_platform_supported_profiles_are_filtered_by_registry(): "tech_communication_pullback_enhancement", "global_etf_rotation", "mega_cap_leader_rotation_top50_balanced", + "nasdaq_sp500_smart_dca", "russell_1000_multi_factor_defensive", } ) @@ -476,6 +477,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(): "tech_communication_pullback_enhancement", "global_etf_rotation", "mega_cap_leader_rotation_top50_balanced", + "nasdaq_sp500_smart_dca", "russell_1000_multi_factor_defensive", } ) @@ -528,6 +530,18 @@ def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch): assert settings.strategy_target_mode == "value" +def test_load_platform_runtime_settings_accepts_nasdaq_sp500_smart_dca(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("nasdaq_sp500_smart_dca")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "nasdaq_sp500_smart_dca" + assert settings.strategy_display_name == "Nasdaq/S&P 500 Smart DCA" + assert settings.strategy_target_mode == "value" + + def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("tech_pullback_cash_buffer")) monkeypatch.setenv("ACCOUNT_GROUP", "paper") @@ -557,6 +571,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): "tqqq_growth_income", "tech_communication_pullback_enhancement", "mega_cap_leader_rotation_top50_balanced", + "nasdaq_sp500_smart_dca", } assert by_profile["global_etf_rotation"] == { "canonical_profile": "global_etf_rotation", @@ -572,6 +587,9 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): assert by_profile["tqqq_growth_income"]["display_name"] == "TQQQ Growth Income" assert by_profile["tqqq_growth_income"]["eligible"] is True assert by_profile["tqqq_growth_income"]["enabled"] is True + assert by_profile["nasdaq_sp500_smart_dca"]["display_name"] == "Nasdaq/S&P 500 Smart DCA" + assert by_profile["nasdaq_sp500_smart_dca"]["eligible"] is True + assert by_profile["nasdaq_sp500_smart_dca"]["enabled"] is True def test_print_strategy_profile_status_json_matches_registry(): @@ -602,6 +620,9 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["global_etf_rotation"]["input_mode"] == "market_history" assert by_profile["global_etf_rotation"]["requires_snapshot_artifacts"] is False assert by_profile["global_etf_rotation"]["requires_strategy_config_path"] is False + assert by_profile["nasdaq_sp500_smart_dca"]["profile_group"] == "direct_runtime_inputs" + assert by_profile["nasdaq_sp500_smart_dca"]["input_mode"] == "market_history+portfolio_snapshot" + assert by_profile["nasdaq_sp500_smart_dca"]["requires_snapshot_artifacts"] is False assert by_profile["tech_communication_pullback_enhancement"]["profile_group"] == "snapshot_backed" assert by_profile["tech_communication_pullback_enhancement"]["input_mode"] == "feature_snapshot" assert by_profile["tech_communication_pullback_enhancement"]["requires_snapshot_artifacts"] is True diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index f1891c8..06048e5 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -117,7 +117,11 @@ class FakeEntrypoint: display_name="Tech/Communication Pullback Enhancement", description="test", required_inputs=frozenset({"feature_snapshot"}), - default_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + default_config={ + "safe_haven": "BOXX", + "benchmark_symbol": "QQQ", + "execution_rebalance_threshold_ratio": 0.0, + }, ) def evaluate(self, ctx): @@ -156,6 +160,7 @@ def evaluate(self, ctx): assert runtime.merged_runtime_config["safe_haven"] == "BOXX" assert runtime.merged_runtime_config["benchmark_symbol"] == "SPY" assert runtime.merged_runtime_config["rebalance_months"] == (1, 4, 7, 10) + assert runtime.rebalance_threshold_ratio == 0.0 assert runtime.status_icon == "🧲" @@ -376,6 +381,81 @@ def loader(*_args, **_kwargs): assert result.metadata["execution_timing_contract"] == "next_trading_day" +def test_market_history_value_runtime_requires_portfolio_snapshot(monkeypatch): + captured = {} + + class FakeEntrypoint: + manifest = StrategyManifest( + profile="nasdaq_sp500_smart_dca", + domain="us_equity", + display_name="Nasdaq/S&P 500 Smart DCA", + description="test", + required_inputs=frozenset({"market_history", "portfolio_snapshot"}), + default_config={"managed_symbols": ("QQQM", "SPLG")}, + ) + + def evaluate(self, ctx): + captured["market_data"] = dict(ctx.market_data) + captured["portfolio"] = ctx.portfolio + captured["runtime_config"] = dict(ctx.runtime_config) + return StrategyDecision( + positions=( + PositionTarget(symbol="QQQM", target_value=1500.0), + PositionTarget(symbol="SPLG", target_value=1700.0), + ) + ) + + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=FakeEntrypoint(), + runtime_adapter=StrategyRuntimeAdapter( + status_icon="🧺", + portfolio_input_name="portfolio_snapshot", + runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=0), + ), + runtime_settings=_build_runtime_settings( + profile="nasdaq_sp500_smart_dca", + display_name="Nasdaq/S&P 500 Smart DCA", + target_mode="value", + ), + runtime_config={}, + merged_runtime_config={"managed_symbols": ("QQQM", "SPLG")}, + status_icon="🧺", + logger=lambda _message: None, + ) + portfolio_snapshot = SimpleNamespace(total_equity=10000.0) + + def fetch_snapshot(ib): + assert ib == "fake-ib" + return portfolio_snapshot + + close_loader_symbols = [] + + def close_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): + close_loader_symbols.append((symbol, duration, bar_size)) + return strategy_runtime_module.pd.Series([100.0, 101.0]) + + monkeypatch.setattr(strategy_runtime_module, "fetch_portfolio_snapshot", fetch_snapshot) + + result = runtime.evaluate( + ib="fake-ib", + current_holdings=(), + historical_close_loader=close_loader, + run_as_of=strategy_runtime_module.pd.Timestamp("2026-05-26"), + translator=lambda key, **_kwargs: key, + pacing_sec=0.0, + ) + + assert captured["market_data"]["market_history"] is close_loader + assert captured["portfolio"] is portfolio_snapshot + assert captured["runtime_config"]["signal_effective_after_trading_days"] == 0 + assert result.metadata["portfolio_total_equity"] == 10000.0 + assert result.metadata["managed_symbols"] == ("QQQM", "SPLG") + assert result.metadata["signal_date"] == "2026-05-26" + assert result.metadata["effective_date"] == "2026-05-26" + assert result.metadata["execution_timing_contract"] == "same_trading_day" + assert close_loader_symbols == [("QQQM", "10 D", "1 day"), ("SPLG", "10 D", "1 day")] + + def test_feature_snapshot_runtime_fail_closes_on_entrypoint_exception(monkeypatch): class ExplodingEntrypoint: manifest = StrategyManifest(