Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 增长收益",
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 28 additions & 6 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down
44 changes: 31 additions & 13 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading