diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index 089fcce..a288181 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -13,7 +13,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "BinancePlatform", @@ -27,14 +27,14 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "CharlesSchwabPlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -48,7 +48,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -118,14 +118,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "CryptoStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "FirstradePlatform", @@ -195,7 +195,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -216,7 +216,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -237,7 +237,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "LongBridgePlatform", @@ -258,7 +258,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "LongBridgePlatform", @@ -300,7 +300,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "37c81901160c5b31127a27dba1c63944933fb6bf" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "UsEquitySnapshotPipelines", @@ -314,14 +314,14 @@ "path": "pyproject.toml", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "17ddb86c72d44b2c7b78ba7a10d8f71b21180166" + "ref": "b0b590ce5ac0233a40b8fb957c249cf375a6ff21" }, { "consumer_repo": "UsEquitySnapshotPipelines", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "37c81901160c5b31127a27dba1c63944933fb6bf" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "UsEquitySnapshotPipelines", @@ -335,21 +335,21 @@ "path": "uv.lock", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "17ddb86c72d44b2c7b78ba7a10d8f71b21180166" + "ref": "b0b590ce5ac0233a40b8fb957c249cf375a6ff21" }, { "consumer_repo": "UsEquityStrategies", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" }, { "consumer_repo": "UsEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" } ] } diff --git a/platform-config.json b/platform-config.json index 2dbd88e..72c6fa0 100644 --- a/platform-config.json +++ b/platform-config.json @@ -27,19 +27,75 @@ "domains": { "us_equity": { "label_zh": "美股", - "label_en": "US Equity" + "label_en": "US Equity", + "scheduler_profile": "us_daily" }, "hk_equity": { "label_zh": "港股", - "label_en": "HK Equity" + "label_en": "HK Equity", + "scheduler_profile": "hk_daily" }, "cn_equity": { "label_zh": "A股", - "label_en": "CN A-share" + "label_en": "CN A-share", + "scheduler_profile": "cn_daily" }, "crypto": { "label_zh": "加密", - "label_en": "Crypto" + "label_en": "Crypto", + "scheduler_profile": "crypto_intraday" + } + }, + "scheduling": { + "profiles": { + "us_daily": { + "timezone": "America/New_York", + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *" + }, + "us_dca_month_end": { + "timezone": "America/New_York", + "main_time": "45 15 25-28 * *", + "probe_time": "35 9,15 25-28 * *", + "precheck_time": "45 9 25-28 * *" + }, + "us_snapshot_month_start": { + "timezone": "America/New_York", + "main_time": "45 15 1-7 * *", + "probe_time": "35 9,15 1-7 * *", + "precheck_time": "45 9 1-7 * *" + }, + "hk_daily": { + "timezone": "Asia/Hong_Kong", + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *" + }, + "hk_snapshot_month_start": { + "timezone": "Asia/Hong_Kong", + "main_time": "45 15 1-7 * *", + "probe_time": "35 9,15 1-7 * *", + "precheck_time": "45 9 1-7 * *" + }, + "cn_daily": { + "timezone": "Asia/Shanghai", + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *" + }, + "cn_snapshot_month_start": { + "timezone": "Asia/Shanghai", + "main_time": "45 15 1-7 * *", + "probe_time": "35 9,15 1-7 * *", + "precheck_time": "45 9 1-7 * *" + }, + "crypto_intraday": { + "timezone": "UTC", + "main_time": "0,30 * * * *", + "probe_time": "0 6,18 * * *", + "precheck_time": "55 5 * * *" + } } }, "platforms": { @@ -353,6 +409,7 @@ "label": "纳指标普定投", "label_en": "NASDAQ/S&P 500 DCA", "domain": "us_equity", + "scheduler_profile": "us_dca_month_end", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, @@ -377,6 +434,10 @@ "label": "IBIT比特币定投", "label_en": "IBIT Bitcoin DCA", "domain": "us_equity", + "scheduler_profile": "us_dca_month_end", + "scheduler_profile_by_plugin": { + "ibit_zscore_exit": "us_daily" + }, "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, @@ -440,6 +501,7 @@ "label": "罗素Top50领涨", "label_en": "Russell Top50 Leaders", "domain": "us_equity", + "scheduler_profile": "us_snapshot_month_start", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, @@ -596,6 +658,7 @@ "label": "港股红利质量", "label_en": "HK Dividend Quality", "domain": "hk_equity", + "scheduler_profile": "hk_snapshot_month_start", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, @@ -728,6 +791,7 @@ "label": "A股红利质量", "label_en": "CN Dividend Quality", "domain": "cn_equity", + "scheduler_profile": "cn_snapshot_month_start", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, @@ -746,6 +810,7 @@ "label": "创业板成长质量快照", "label_en": "CN ChiNext Growth Quality Snapshot", "domain": "cn_equity", + "scheduler_profile": "cn_snapshot_month_start", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, diff --git a/python/scripts/build_config.py b/python/scripts/build_config.py index 79c69c0..bf2043f 100644 --- a/python/scripts/build_config.py +++ b/python/scripts/build_config.py @@ -19,6 +19,7 @@ import subprocess import sys from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError ROOT = Path(__file__).resolve().parents[2] CONFIG_PATH = ROOT / "platform-config.json" @@ -36,6 +37,7 @@ "allowed_execution_modes", "blocked_live_reason", } +SCHEDULER_FIELDS = {"timezone", "main_time", "probe_time", "precheck_time"} def load_config() -> dict: @@ -45,6 +47,31 @@ def load_config() -> dict: def validate(config: dict) -> list[str]: errors: list[str] = [] + scheduling = config.get("scheduling") + scheduler_profiles = scheduling.get("profiles") if isinstance(scheduling, dict) else None + if not isinstance(scheduler_profiles, dict) or not scheduler_profiles: + errors.append("scheduling.profiles must be a non-empty object") + scheduler_profiles = {} + for profile, scheduler in scheduler_profiles.items(): + if not isinstance(scheduler, dict): + errors.append(f"scheduler profile {profile}: must be an object") + continue + if set(scheduler) != SCHEDULER_FIELDS: + errors.append( + f"scheduler profile {profile}: fields must be {sorted(SCHEDULER_FIELDS)}" + ) + continue + timezone = scheduler.get("timezone") + try: + ZoneInfo(str(timezone or "")) + except (ZoneInfoNotFoundError, ValueError): + errors.append(f"scheduler profile {profile}: invalid timezone {timezone!r}") + for field in SCHEDULER_FIELDS - {"timezone"}: + value = scheduler.get(field) + if not isinstance(value, str) or len(value.split()) not in {2, 5}: + errors.append( + f"scheduler profile {profile}: {field} must have 2 time fields or 5 cron fields" + ) for pid, pdata in config.get("platforms", {}).items(): if "capabilities" not in pdata: errors.append(f"platform {pid}: missing capabilities") @@ -52,9 +79,52 @@ def validate(config: dict) -> list[str]: errors.append(f"platform {pid}: missing default_account") if "supported_domains" not in pdata: errors.append(f"platform {pid}: missing supported_domains") + domains = config.get("domains", {}) + for domain, domain_data in domains.items(): + if not isinstance(domain_data, dict): + errors.append(f"domain {domain}: must be an object") + continue + scheduler_profile = domain_data.get("scheduler_profile") + if not isinstance(scheduler_profile, str) or scheduler_profile not in scheduler_profiles: + errors.append( + f"domain {domain}: unknown scheduler_profile {scheduler_profile!r}" + ) for sid, sdata in config.get("strategies", {}).items(): if "domain" not in sdata: errors.append(f"strategy {sid}: missing domain") + continue + domain_ref = sdata["domain"] + if not isinstance(domain_ref, str): + errors.append(f"strategy {sid}: domain must be a string") + domain_data = {} + elif domain_ref not in domains: + errors.append(f"strategy {sid}: unknown domain {domain_ref!r}") + domain_data = {} + else: + domain_data = domains[domain_ref] + if not isinstance(domain_data, dict): + domain_data = {} + strategy_scheduler_profile = sdata.get("scheduler_profile") + if strategy_scheduler_profile is not None and not isinstance(strategy_scheduler_profile, str): + errors.append( + f"strategy {sid}: scheduler_profile must be a string" + ) + scheduler_profile = None + else: + scheduler_profile = strategy_scheduler_profile or domain_data.get("scheduler_profile") + if not isinstance(scheduler_profile, str) or scheduler_profile not in scheduler_profiles: + errors.append( + f"strategy {sid}: unknown scheduler_profile {scheduler_profile!r}" + ) + plugin_overrides = sdata.get("scheduler_profile_by_plugin", {}) + if not isinstance(plugin_overrides, dict): + errors.append(f"strategy {sid}: scheduler_profile_by_plugin must be an object") + else: + for plugin, override in plugin_overrides.items(): + if not isinstance(override, str) or override not in scheduler_profiles: + errors.append( + f"strategy {sid}: plugin {plugin} references unknown scheduler_profile {override!r}" + ) return errors diff --git a/python/scripts/build_runtime_switch.py b/python/scripts/build_runtime_switch.py index 2c59f67..e3bd541 100644 --- a/python/scripts/build_runtime_switch.py +++ b/python/scripts/build_runtime_switch.py @@ -25,6 +25,7 @@ DEFAULT_ARTIFACT_BUCKET_URI = "gs://qsl-runtime-logs-shared" +PLATFORM_CONFIG_PATH = ROOT / "platform-config.json" # Keep this list limited to strategy-scope artifacts that the publisher # currently produces. QPK may parse broader explicit mounts for forward # compatibility, but auto mode must not generate missing latest_signal paths. @@ -36,58 +37,6 @@ ) IBIT_ZSCORE_EXIT_STRATEGY_PROFILE = "ibit_smart_dca" IBIT_ZSCORE_EXIT_PLUGIN = "ibit_zscore_exit" -US_DAILY_SCHEDULER = { - "timezone": "America/New_York", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *", -} -US_DCA_SCHEDULER = { - "timezone": "America/New_York", - "main_time": "45 15 25-28 * *", - "probe_time": "35 9,15 25-28 * *", - "precheck_time": "45 9 25-28 * *", -} -US_SNAPSHOT_SCHEDULER = { - "timezone": "America/New_York", - "main_time": "45 15 1-7 * *", - "probe_time": "35 9,15 1-7 * *", - "precheck_time": "45 9 1-7 * *", -} -HK_DAILY_SCHEDULER = { - "timezone": "Asia/Hong_Kong", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *", -} -HK_SNAPSHOT_SCHEDULER = { - "timezone": "Asia/Hong_Kong", - "main_time": "45 15 1-7 * *", - "probe_time": "35 9,15 1-7 * *", - "precheck_time": "45 9 1-7 * *", -} -CN_DAILY_SCHEDULER = { - "timezone": "Asia/Shanghai", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *", -} -CN_SNAPSHOT_SCHEDULER = { - "timezone": "Asia/Shanghai", - "main_time": "45 15 1-7 * *", - "probe_time": "35 9,15 1-7 * *", - "precheck_time": "45 9 1-7 * *", -} -STRATEGY_SCHEDULER_PROFILES = { - "nasdaq_sp500_smart_dca": US_DCA_SCHEDULER, - "ibit_smart_dca": US_DCA_SCHEDULER, - "russell_top50_leader_rotation": US_SNAPSHOT_SCHEDULER, - "hk_low_vol_dividend_quality_snapshot": HK_SNAPSHOT_SCHEDULER, - "cn_index_etf_tactical_rotation": CN_DAILY_SCHEDULER, - "cn_industry_etf_rotation": CN_DAILY_SCHEDULER, - "cn_industry_etf_rotation_aggressive": CN_DAILY_SCHEDULER, - "cn_dividend_quality_snapshot": CN_SNAPSHOT_SCHEDULER, -} PLATFORM_DRY_RUN_VARIABLES = { "schwab": "SCHWAB_DRY_RUN_ONLY", "longbridge": "LONGBRIDGE_DRY_RUN_ONLY", @@ -714,19 +663,14 @@ def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]: raise ValueError("execution_mode must be live or paper") -def _has_enabled_plugin_mount( - mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...], - *, - strategy_profile: str, - plugin: str, -) -> bool: - return any( - isinstance(mount, dict) - and mount.get("strategy") == strategy_profile - and mount.get("plugin") == plugin - and mount.get("enabled") is True - for mount in mounts - ) +def _load_platform_config() -> dict[str, Any]: + try: + payload = json.loads(PLATFORM_CONFIG_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"unable to load platform config {PLATFORM_CONFIG_PATH}: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("platform-config.json must contain an object") + return payload def _scheduler_plan_for_strategy( @@ -734,21 +678,41 @@ def _scheduler_plan_for_strategy( plugin_mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (), ) -> dict[str, str]: profile = str(strategy_profile or "").strip().lower() - if profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE and _has_enabled_plugin_mount( - plugin_mounts, - strategy_profile=profile, - plugin=IBIT_ZSCORE_EXIT_PLUGIN, - ): - return dict(US_DAILY_SCHEDULER) - scheduler = STRATEGY_SCHEDULER_PROFILES.get(profile) - if scheduler is None: - if profile.startswith("cn_"): - scheduler = CN_DAILY_SCHEDULER - elif profile.startswith("hk_"): - scheduler = HK_DAILY_SCHEDULER - else: - scheduler = US_DAILY_SCHEDULER - return dict(scheduler) + config = _load_platform_config() + scheduling = config.get("scheduling") + strategies = config.get("strategies") + domains = config.get("domains") + if not isinstance(scheduling, dict) or not isinstance(strategies, dict) or not isinstance(domains, dict): + raise ValueError("platform-config.json must define scheduling, strategies, and domains") + scheduler_profiles = scheduling.get("profiles") + strategy = strategies.get(profile) + if not isinstance(scheduler_profiles, dict) or not isinstance(strategy, dict): + raise ValueError(f"strategy {profile!r} is missing from the scheduling catalog") + + domain = domains.get(strategy.get("domain")) + if not isinstance(domain, dict): + raise ValueError(f"strategy {profile!r} has no configured scheduling domain") + scheduler_profile = strategy.get("scheduler_profile") or domain.get("scheduler_profile") + plugin_overrides = strategy.get("scheduler_profile_by_plugin") or {} + if not isinstance(plugin_overrides, dict): + raise ValueError(f"strategy {profile!r} scheduler_profile_by_plugin must be an object") + for mount in plugin_mounts: + if ( + not isinstance(mount, dict) + or mount.get("strategy") != profile + or mount.get("enabled") is not True + ): + continue + plugin = str(mount.get("plugin") or "").strip() + if plugin in plugin_overrides: + scheduler_profile = plugin_overrides[plugin] + + scheduler = scheduler_profiles.get(scheduler_profile) + if not isinstance(scheduler, dict): + raise ValueError( + f"strategy {profile!r} references unknown scheduler profile {scheduler_profile!r}" + ) + return {str(key): str(value) for key, value in scheduler.items()} def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: diff --git a/python/tests/test_internal_dependency_matrix.py b/python/tests/test_internal_dependency_matrix.py index 7c449e5..ef859d3 100644 --- a/python/tests/test_internal_dependency_matrix.py +++ b/python/tests/test_internal_dependency_matrix.py @@ -100,14 +100,11 @@ def test_current_matrix_matches_local_workspace(self): self.assertEqual(report.issues, []) def test_qpk_rollout_consumers_use_canonical_pin(self): - canonical_pin = "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + canonical_pin = "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" rollout_consumers = { "BinancePlatform", "CharlesSchwabPlatform", - "CnEquityStrategies", "CryptoStrategies", - "FirstradePlatform", - "HkEquityStrategies", "InteractiveBrokersPlatform", "LongBridgePlatform", "UsEquityStrategies", @@ -122,6 +119,23 @@ def test_qpk_rollout_consumers_use_canonical_pin(self): self.assertEqual(len(refs), len(rollout_consumers) * 2) self.assertEqual(set(refs.values()), {canonical_pin}) + def test_qpk_legacy_consumers_remain_on_previous_canonical_pin(self): + previous_canonical_pin = "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + legacy_consumers = { + "CnEquityStrategies", + "FirstradePlatform", + "HkEquityStrategies", + } + matrix_pins = check_internal_dependency_matrix.load_matrix(ROOT / "internal_dependency_matrix.json") + refs = { + (pin.consumer_repo, pin.path): pin.ref + for pin in matrix_pins + if pin.consumer_repo in legacy_consumers and pin.source_repo == "QuantPlatformKit" + } + + self.assertEqual(len(refs), len(legacy_consumers) * 2) + self.assertEqual(set(refs.values()), {previous_canonical_pin}) + def test_require_consumer_files_treats_missing_paths_as_issues(self): projects_root = self._make_projects_root({}) expected = [ diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index 6b77231..6616e7c 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -782,6 +782,15 @@ def test_build_switch_target_defaults_binance_repository_scope(self): self.assertEqual(target["github"]["variable_scope"], "repository") self.assertEqual(target["runtime_target"]["platform_id"], "binance") self.assertEqual(assignments["BINANCE_DRY_RUN"], "false") + self.assertEqual( + target["runtime_target"]["scheduler"], + { + "timezone": "UTC", + "main_time": "0,30 * * * *", + "probe_time": "0 6,18 * * *", + "precheck_time": "55 5 * * *", + }, + ) def test_build_switch_target_uses_dca_monthly_scheduler_window(self): parser = build_runtime_switch.build_parser() @@ -887,7 +896,10 @@ def test_build_switch_target_disables_ibit_zscore_exit_for_fixed_dca(self): assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"]) - self.assertEqual(target["runtime_target"]["scheduler"], build_runtime_switch.US_DCA_SCHEDULER) + self.assertEqual( + target["runtime_target"]["scheduler"], + build_runtime_switch._load_platform_config()["scheduling"]["profiles"]["us_dca_month_end"], + ) self.assertEqual(plugin_payload["strategy_plugins"], []) self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "false") self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "paper") @@ -912,7 +924,10 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled target = build_runtime_switch.build_switch_target(args) assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} - self.assertEqual(target["runtime_target"]["scheduler"], build_runtime_switch.US_DCA_SCHEDULER) + self.assertEqual( + target["runtime_target"]["scheduler"], + build_runtime_switch._load_platform_config()["scheduling"]["profiles"]["us_dca_month_end"], + ) self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "false") self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "paper") @@ -1300,6 +1315,83 @@ def test_build_switch_target_uses_snapshot_scheduler_window(self): }, ) + def test_scheduler_plan_uses_catalog_strategy_override_without_code_mapping(self): + scheduler = { + "timezone": "Europe/London", + "main_time": "5 14 * * 1-5", + "probe_time": "55 13 * * 1-5", + "precheck_time": "0 14 * * 1-5", + } + config = { + "scheduling": {"profiles": {"custom_market": scheduler}}, + "domains": {"custom_equity": {"scheduler_profile": "custom_market"}}, + "strategies": {"custom_strategy": {"domain": "custom_equity"}}, + } + + with patch.object(build_runtime_switch, "_load_platform_config", return_value=config): + resolved = build_runtime_switch._scheduler_plan_for_strategy("custom_strategy") + + self.assertEqual(resolved, scheduler) + + def test_scheduler_plugin_override_ignores_mount_for_another_strategy(self): + config = build_runtime_switch._load_platform_config() + + resolved = build_runtime_switch._scheduler_plan_for_strategy( + "ibit_smart_dca", + plugin_mounts=[ + { + "strategy": "another_strategy", + "plugin": "ibit_zscore_exit", + "enabled": True, + } + ], + ) + + self.assertEqual( + resolved, + config["scheduling"]["profiles"]["us_dca_month_end"], + ) + + def test_build_config_rejects_unknown_strategy_scheduler_profile(self): + config = build_config.load_config() + config["strategies"] = dict(config["strategies"]) + config["strategies"]["global_etf_rotation"] = { + **config["strategies"]["global_etf_rotation"], + "scheduler_profile": "missing_profile", + } + + self.assertIn( + "strategy global_etf_rotation: unknown scheduler_profile 'missing_profile'", + build_config.validate(config), + ) + + def test_build_config_reports_malformed_scheduler_timezone(self): + config = build_config.load_config() + config["scheduling"]["profiles"]["us_daily"]["timezone"] = "../UTC" + + self.assertIn( + "scheduler profile us_daily: invalid timezone '../UTC'", + build_config.validate(config), + ) + + def test_build_config_reports_non_string_scheduler_references(self): + config = build_config.load_config() + config["domains"]["us_equity"]["scheduler_profile"] = [] + config["strategies"]["global_etf_rotation"]["scheduler_profile"] = ["us_daily"] + config["strategies"]["tqqq_growth_income"]["domain"] = [] + config["strategies"]["ibit_smart_dca"]["scheduler_profile_by_plugin"] = { + "ibit_zscore_exit": ["us_daily"] + } + + errors = build_config.validate(config) + self.assertIn("domain us_equity: unknown scheduler_profile []", errors) + self.assertIn("strategy global_etf_rotation: scheduler_profile must be a string", errors) + self.assertIn("strategy tqqq_growth_income: domain must be a string", errors) + self.assertIn( + "strategy ibit_smart_dca: plugin ibit_zscore_exit references unknown scheduler_profile ['us_daily']", + errors, + ) + def test_runtime_target_scheduler_rejects_invalid_cron_shape(self): _, target = self.load_target("examples/targets/schwab/live.example.json") target["runtime_target"]["scheduler"] = {