diff --git a/notifications/telegram.py b/notifications/telegram.py index 85c7d03..e0b5fb5 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -233,6 +233,8 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "strategy_name_russell_top50_leader_rotation": "罗素 Top50 领涨轮动", "strategy_name_nasdaq_sp500_smart_dca": "纳指100 / 标普500 智能定投", "strategy_name_ibit_smart_dca": "IBIT 比特币 ETF 智能定投", + "strategy_name_us_equity_combo": "美股核心组合", + "strategy_name_us_equity_combo_leveraged": "美股Alpha组合", "strategy_name_hk_global_etf_tactical_rotation": "港股全球 ETF 战术轮动", "strategy_name_hk_low_vol_dividend_quality_snapshot": "港股低波股息质量快照", "strategy_name_tqqq_growth_income": "TQQQ 增长收益", @@ -447,6 +449,8 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "strategy_name_russell_top50_leader_rotation": "Russell Top50 Leader Rotation", "strategy_name_nasdaq_sp500_smart_dca": "Nasdaq 100 / S&P 500 Smart DCA", "strategy_name_ibit_smart_dca": "IBIT Smart DCA", + "strategy_name_us_equity_combo": "US Equity Combo", + "strategy_name_us_equity_combo_leveraged": "US Equity Combo Leveraged", "strategy_name_hk_global_etf_tactical_rotation": "HK Global ETF Tactical Rotation", "strategy_name_hk_low_vol_dividend_quality_snapshot": "HK Low-Vol Dividend Quality Snapshot", "strategy_name_tqqq_growth_income": "TQQQ Growth Income", diff --git a/runtime_config_support.py b/runtime_config_support.py index cd2d922..67787ba 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -18,7 +18,6 @@ resolve_optional_dca_mode_env, resolve_optional_ibit_zscore_exit_mode_env, resolve_optional_positive_float_env, - resolve_optional_ratio_env, resolve_optional_symbol_env, resolve_split_env_list, resolve_strategy_runtime_path_settings, @@ -430,12 +429,12 @@ def load_platform_runtime_settings( os.environ, platform_env_prefix="IBKR", ), - income_layer_enabled=resolve_optional_bool_env("INCOME_LAYER_ENABLED"), + income_layer_enabled=resolve_optional_bool_env_value("INCOME_LAYER_ENABLED"), income_layer_start_usd=resolve_optional_non_negative_float_env("INCOME_LAYER_START_USD"), income_layer_max_ratio=resolve_optional_ratio_env("INCOME_LAYER_MAX_RATIO"), dca_mode=resolve_optional_dca_mode_env("DCA_MODE"), dca_base_investment_usd=resolve_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"), - ibit_zscore_exit_enabled=resolve_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"), + ibit_zscore_exit_enabled=resolve_optional_bool_env_value("IBIT_ZSCORE_EXIT_ENABLED"), ibit_zscore_exit_mode=resolve_optional_ibit_zscore_exit_mode_env("IBIT_ZSCORE_EXIT_MODE"), ibit_zscore_exit_parking_symbol=resolve_optional_symbol_env("IBIT_ZSCORE_EXIT_PARKING_SYMBOL"), ibit_zscore_exit_risk_reduced_exposure=resolve_optional_ratio_env( @@ -444,7 +443,7 @@ def load_platform_runtime_settings( ibit_zscore_exit_risk_off_exposure=resolve_optional_ratio_env( "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE" ), - ibit_zscore_exit_allow_outside_execution_window=resolve_optional_bool_env( + ibit_zscore_exit_allow_outside_execution_window=resolve_optional_bool_env_value( "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW" ), market_signal_handoff_index_uri=first_non_empty( @@ -621,9 +620,27 @@ def resolve_optional_non_negative_float_env(name: str) -> float | None: return resolve_non_negative_float_env(name, default=0.0) +def resolve_optional_bool_env_value(name: str) -> bool | None: + raw_value = os.getenv(f"QSL_{name}") or os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return None + return resolve_optional_bool_env(name) + + +def resolve_optional_ratio_env(name: str, default: float | None = None) -> float | None: + raw_value = os.getenv(f"QSL_{name}") or os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return default + value = float(raw_value) + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0,1], got {value}") + return value + + def resolve_runtime_target_enabled_env() -> bool: - value = resolve_optional_bool_env("RUNTIME_TARGET_ENABLED") - return True if value is None else value + return resolve_optional_bool_env("RUNTIME_TARGET_ENABLED", default=True) def resolve_account_group(raw_value: str | None) -> str: @@ -709,6 +726,11 @@ def load_secret_payload( *, secret_client_factory: Callable[[], Any] | None = None, ) -> str: + if secret_client_factory is not None: + client = secret_client_factory() + name = f"projects/{project_id}/secrets/{secret_name}/versions/latest" + response = client.access_secret_version(request={"name": name}) + return response.payload.data.decode("utf-8") return get_secret_store().get_secret(secret_name, project_id=project_id) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index d7b451c..4de1209 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -6,6 +6,14 @@ import pytest +ROOT = Path(__file__).resolve().parents[1] +QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" +UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" +HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" +for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): + if candidate.exists() and str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + from runtime_config_support import ( DEFAULT_MARKET, DEFAULT_MARKET_CALENDAR, @@ -59,9 +67,13 @@ EXPECTED_IBKR_ENABLED_PROFILES = frozenset( { "global_etf_rotation", + "ibit_smart_dca", + "nasdaq_sp500_smart_dca", "russell_top50_leader_rotation", "soxl_soxx_trend_income", "tqqq_growth_income", + "us_equity_combo", + "us_equity_combo_leveraged", "hk_global_etf_tactical_rotation", "hk_low_vol_dividend_quality_snapshot", } @@ -106,7 +118,7 @@ def test_load_platform_runtime_settings_requires_strategy_profile(monkeypatch): monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.delenv("RUNTIME_TARGET_JSON", raising=False) - with pytest.raises(EnvironmentError, match="RUNTIME_TARGET_JSON is required"): + with pytest.raises(EnvironmentError, match="RUNTIME_TARGET_JSON"): load_platform_runtime_settings(project_id_resolver=lambda: "project-1") @@ -541,7 +553,7 @@ def test_load_platform_runtime_settings_reads_income_layer_overrides(monkeypatch assert settings.income_layer_max_ratio == 0.25 -def test_ibit_smart_dca_profile_is_rejected_on_ibkr(monkeypatch): +def test_ibit_smart_dca_profile_is_loaded_on_ibkr(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("ibit_smart_dca")) monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) @@ -552,8 +564,11 @@ def test_ibit_smart_dca_profile_is_rejected_on_ibkr(monkeypatch): monkeypatch.setenv("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE", "0.25") monkeypatch.setenv("IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW", "true") - with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE='ibit_smart_dca'"): - load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "ibit_smart_dca" + assert settings.ibit_zscore_exit_enabled is True + assert settings.ibit_zscore_exit_mode == "live" def test_load_platform_runtime_settings_rejects_invalid_income_layer_enabled(monkeypatch): @@ -723,13 +738,14 @@ def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch): assert settings.strategy_target_mode == "value" -def test_load_platform_runtime_settings_rejects_nasdaq_sp500_smart_dca(monkeypatch): +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) - with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE='nasdaq_sp500_smart_dca'"): - load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "nasdaq_sp500_smart_dca" def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch): @@ -758,6 +774,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): assert by_profile["global_etf_rotation"] == { "canonical_profile": "global_etf_rotation", "display_name": "Global ETF Rotation", + "display_name_zh": "全球ETF轮动", "domain": "us_equity", "eligible": True, "enabled": True, @@ -769,11 +786,12 @@ 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 "nasdaq_sp500_smart_dca" not in by_profile - assert "ibit_smart_dca" not in by_profile + assert "nasdaq_sp500_smart_dca" in by_profile + assert "ibit_smart_dca" in by_profile assert by_profile["hk_global_etf_tactical_rotation"] == { "canonical_profile": "hk_global_etf_tactical_rotation", "display_name": "HK Global ETF Tactical Rotation", + "display_name_zh": "港股ETF战术轮动", "domain": "hk_equity", "eligible": True, "enabled": True, @@ -814,11 +832,11 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["global_etf_rotation"]["requires_snapshot_artifacts"] is True assert by_profile["global_etf_rotation"]["requires_snapshot_manifest_path"] is True assert by_profile["global_etf_rotation"]["requires_strategy_config_path"] is False - assert "nasdaq_sp500_smart_dca" not in by_profile - assert "ibit_smart_dca" not in by_profile + assert "nasdaq_sp500_smart_dca" in by_profile + assert "ibit_smart_dca" in by_profile assert "tech_communication_pullback_enhancement" not in by_profile assert by_profile["russell_top50_leader_rotation"]["profile_group"] == "snapshot_backed" - assert by_profile["russell_top50_leader_rotation"]["display_name_zh"] == "罗素 Top50 领涨轮动" + assert by_profile["russell_top50_leader_rotation"]["display_name_zh"] == "罗素Top50领涨" assert by_profile["russell_top50_leader_rotation"]["input_mode"] == "feature_snapshot" assert by_profile["russell_top50_leader_rotation"]["requires_snapshot_artifacts"] is True assert by_profile["russell_top50_leader_rotation"]["requires_strategy_config_path"] is False @@ -849,7 +867,7 @@ def test_print_strategy_profile_status_table_contains_expected_headers(): assert "global_etf_rotation" in result.stdout assert "hk_global_etf_tactical_rotation" in result.stdout assert "Russell Top50 Leader Rotation" in result.stdout - assert "罗素 Top50 领涨轮动" in result.stdout + assert "罗素Top50领涨" in result.stdout assert "Tech/Communication Pullback Enhancement" not in result.stdout assert "HK Global ETF Tactical Rotation" in result.stdout assert "TQQQ Growth Income" in result.stdout