From 926147103d0b98b2de1ebfb0fcec940b8c109335 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:56:03 +0800 Subject: [PATCH] Add DCA runtime settings --- requirements.txt | 2 +- runtime_config_support.py | 32 ++++++++++++++++++++++ strategy_runtime.py | 18 +++++++++++++ tests/test_strategy_runtime.py | 49 ++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f9c4b5b..c874c27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b846c9d777a450e95d23c264853997d671f47dd9 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@22689494e922a8b18349562edcc6389d2faaed8f +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@361338f60900182e3be535cd5fd2be2b9a07b422 hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@4007746ac21379f7ce7cf8e999d2bb37123f6767 pandas requests diff --git a/runtime_config_support.py b/runtime_config_support.py index 67e165a..a5dfcf3 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -102,6 +102,8 @@ class PlatformRuntimeSettings: income_layer_enabled: bool | None = None income_layer_start_usd: float | None = None income_layer_max_ratio: float | None = None + dca_mode: str | None = None + dca_base_investment_usd: float | None = None runtime_execution_window_trading_days: int | None = None feature_snapshot_path: str | None = None feature_snapshot_manifest_path: str | None = None @@ -291,6 +293,8 @@ def load_platform_runtime_settings( income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"), income_layer_start_usd=_optional_non_negative_float_env("INCOME_LAYER_START_USD"), income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"), + dca_mode=_optional_dca_mode_env("DCA_MODE"), + dca_base_investment_usd=_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"), runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env( strategy_definition.profile ), @@ -413,6 +417,34 @@ def _optional_non_negative_float_env(name: str) -> float | None: return float(value) +def _optional_positive_float_env(name: str) -> float | None: + value = resolve_optional_float_env(os.environ, name) + if value is None: + return None + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") + if value <= 0: + raise ValueError(f"{name} must be positive, got {value}") + return float(value) + + +def _optional_dca_mode_env(name: str) -> str | None: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return None + value = str(raw_value).strip().lower() + aliases = { + "ordinary": "fixed", + "ordinary_dca": "fixed", + "fixed_dca": "fixed", + "smart_dca": "smart", + } + mode = aliases.get(value, value) + if mode not in {"fixed", "smart"}: + raise ValueError(f"{name} must be fixed or smart, got {raw_value!r}") + return mode + + def _resolve_non_negative_float_env(name: str, *, default: float) -> float: value = resolve_optional_float_env(os.environ, name) if value is None: diff --git a/strategy_runtime.py b/strategy_runtime.py index 1065efa..be056bf 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -26,6 +26,7 @@ _FEATURE_SNAPSHOT_INPUT = "feature_snapshot" +DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"}) @dataclass(frozen=True) @@ -175,6 +176,7 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett overrides["income_layer_start_usd"] = income_layer_start_usd if income_layer_max_ratio is not None: overrides["income_layer_max_ratio"] = income_layer_max_ratio + _apply_dca_runtime_overrides(profile, runtime_settings, overrides) if profile == "tqqq_growth_income": if runtime_settings.income_threshold_usd is not None: overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd @@ -188,6 +190,22 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett return overrides +def _apply_dca_runtime_overrides( + profile: str, + runtime_settings: PlatformRuntimeSettings, + overrides: dict[str, Any], +) -> None: + if profile not in DCA_PROFILES: + return + dca_mode = getattr(runtime_settings, "dca_mode", None) + dca_base_investment_usd = getattr(runtime_settings, "dca_base_investment_usd", None) + if dca_mode is not None: + overrides["investment_amount_mode"] = "fixed" + overrides["smart_multiplier_enabled"] = dca_mode == "smart" + if dca_base_investment_usd is not None: + overrides["base_investment_usd"] = dca_base_investment_usd + + def load_strategy_runtime( raw_profile: str | None, *, diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 9c64c44..a13a2a9 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -33,6 +33,26 @@ def evaluate(self, ctx): return StrategyDecision(diagnostics={"signal_description": "tqqq"}) +class _NasdaqSp500DcaEntrypoint: + manifest = StrategyManifest( + profile="nasdaq_sp500_smart_dca", + domain="us_equity", + display_name="Nasdaq 100 / S&P 500 DCA", + description="test entrypoint", + required_inputs=frozenset({"market_history", "portfolio_snapshot"}), + default_config={ + "managed_symbols": ("QQQM", "SPLG"), + "investment_amount_mode": "fixed", + "smart_multiplier_enabled": False, + "base_investment_usd": 1000.0, + }, + ) + + def evaluate(self, ctx): + self.ctx = ctx + return StrategyDecision(diagnostics={"signal_description": "dca"}) + + class _SemiconductorEntrypoint: def __init__(self): self.manifest = StrategyManifest( @@ -103,6 +123,8 @@ def _build_runtime_settings( income_layer_enabled: bool | None = None, income_layer_start_usd: float | None = None, income_layer_max_ratio: float | None = None, + dca_mode: str | None = None, + dca_base_investment_usd: float | None = None, runtime_execution_window_trading_days: int | None = None, ) -> PlatformRuntimeSettings: return PlatformRuntimeSettings( @@ -124,6 +146,8 @@ def _build_runtime_settings( income_layer_enabled=income_layer_enabled, income_layer_start_usd=income_layer_start_usd, income_layer_max_ratio=income_layer_max_ratio, + dca_mode=dca_mode, + dca_base_investment_usd=dca_base_investment_usd, runtime_execution_window_trading_days=runtime_execution_window_trading_days, feature_snapshot_path=feature_snapshot_path, feature_snapshot_manifest_path=None, @@ -280,6 +304,31 @@ def test_load_strategy_runtime_applies_tqqq_income_overrides_from_settings(self) self.assertEqual(runtime.merged_runtime_config["income_layer_start_usd"], 250000.0) self.assertEqual(runtime.merged_runtime_config["income_layer_max_ratio"], 0.25) + def test_load_strategy_runtime_applies_dca_overrides_from_settings(self): + entrypoint = _NasdaqSp500DcaEntrypoint() + + with patch.object(strategy_runtime_module, "load_strategy_entrypoint_for_profile", return_value=entrypoint): + with patch.object( + strategy_runtime_module, + "load_strategy_runtime_adapter_for_profile", + return_value=StrategyRuntimeAdapter(portfolio_input_name="portfolio_snapshot"), + ): + runtime = strategy_runtime_module.load_strategy_runtime( + "nasdaq_sp500_smart_dca", + runtime_settings=_build_runtime_settings( + "nasdaq_sp500_smart_dca", + dca_mode="smart", + dca_base_investment_usd=500.0, + ), + ) + + self.assertEqual(runtime.runtime_overrides["investment_amount_mode"], "fixed") + self.assertTrue(runtime.runtime_overrides["smart_multiplier_enabled"]) + self.assertEqual(runtime.runtime_overrides["base_investment_usd"], 500.0) + self.assertEqual(runtime.merged_runtime_config["investment_amount_mode"], "fixed") + self.assertTrue(runtime.merged_runtime_config["smart_multiplier_enabled"]) + self.assertEqual(runtime.merged_runtime_config["base_investment_usd"], 500.0) + def test_load_strategy_runtime_applies_tech_execution_window_overrides_from_settings(self): entrypoint = _TechEntrypoint()