Skip to content

Commit bb833bf

Browse files
authored
Add scheduler plans to runtime strategy switches
1 parent 911274c commit bb833bf

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

scripts/build_runtime_switch.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@
3232
"mega_cap_leader_rotation_top50_balanced",
3333
}
3434
)
35+
US_DAILY_SCHEDULER = {
36+
"timezone": "America/New_York",
37+
"main_time": "45 15 * * *",
38+
"probe_time": "35 9,15 * * *",
39+
"precheck_time": "45 9 * * *",
40+
}
41+
US_DCA_SCHEDULER = {
42+
"timezone": "America/New_York",
43+
"main_time": "45 15 25-29 * *",
44+
"probe_time": "35 9,15 25-29 * *",
45+
"precheck_time": "45 9 25-29 * *",
46+
}
47+
US_SNAPSHOT_SCHEDULER = {
48+
"timezone": "America/New_York",
49+
"main_time": "45 15 1-7 * *",
50+
"probe_time": "35 9,15 1-7 * *",
51+
"precheck_time": "45 9 1-7 * *",
52+
}
53+
HK_DAILY_SCHEDULER = {
54+
"timezone": "Asia/Hong_Kong",
55+
"main_time": "45 15 * * *",
56+
"probe_time": "35 9,15 * * *",
57+
"precheck_time": "45 9 * * *",
58+
}
59+
HK_SNAPSHOT_SCHEDULER = {
60+
"timezone": "Asia/Hong_Kong",
61+
"main_time": "45 15 1-7 * *",
62+
"probe_time": "35 9,15 1-7 * *",
63+
"precheck_time": "45 9 1-7 * *",
64+
}
65+
STRATEGY_SCHEDULER_PROFILES = {
66+
"nasdaq_sp500_smart_dca": US_DCA_SCHEDULER,
67+
"ibit_smart_dca": US_DCA_SCHEDULER,
68+
"russell_1000_multi_factor_defensive": US_SNAPSHOT_SCHEDULER,
69+
"mega_cap_leader_rotation_top50_balanced": US_SNAPSHOT_SCHEDULER,
70+
"hk_low_vol_dividend_quality_snapshot": HK_SNAPSHOT_SCHEDULER,
71+
}
3572
PLATFORM_DRY_RUN_VARIABLES = {
3673
"schwab": "SCHWAB_DRY_RUN_ONLY",
3774
"longbridge": "LONGBRIDGE_DRY_RUN_ONLY",
@@ -216,6 +253,14 @@ def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]:
216253
raise ValueError("execution_mode must be live or paper")
217254

218255

256+
def _scheduler_plan_for_strategy(strategy_profile: str) -> dict[str, str]:
257+
profile = str(strategy_profile or "").strip().lower()
258+
scheduler = STRATEGY_SCHEDULER_PROFILES.get(profile)
259+
if scheduler is None:
260+
scheduler = HK_DAILY_SCHEDULER if profile.startswith("hk_") else US_DAILY_SCHEDULER
261+
return dict(scheduler)
262+
263+
219264
def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
220265
platform = _normalize_platform(args.platform)
221266
target_name = _normalize_target_name(args.target_name)
@@ -232,15 +277,17 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
232277
)
233278
account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope)
234279
service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name)
280+
strategy_profile = args.strategy_profile.strip().lower()
235281
runtime_target: dict[str, Any] = {
236282
"platform_id": platform,
237-
"strategy_profile": args.strategy_profile.strip().lower(),
283+
"strategy_profile": strategy_profile,
238284
"dry_run_only": dry_run_only,
239285
"deployment_selector": deployment_selector,
240286
"account_selector": account_selector,
241287
"account_scope": account_scope,
242288
"service_name": service_name,
243289
"execution_mode": execution_mode,
290+
"scheduler": _scheduler_plan_for_strategy(strategy_profile),
244291
}
245292
execution_windows = _load_json_object(args.execution_windows_json, field_name="execution_windows_json")
246293
if execution_windows:

scripts/runtime_settings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"precheck": {"notify_only", "dry_run"},
4545
"execution": {"live", "paper", "dry_run"},
4646
}
47+
SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"})
4748
GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"}
4849
SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET")
4950
PLATFORM_DRY_RUN_VARIABLES = {
@@ -311,6 +312,24 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None:
311312
)
312313
break
313314

315+
scheduler = runtime_target.get("scheduler")
316+
if scheduler is not None:
317+
if not isinstance(scheduler, dict):
318+
errors.append("runtime_target.scheduler must be an object when present")
319+
else:
320+
for field in scheduler:
321+
if field not in SCHEDULER_FIELDS:
322+
errors.append(f"runtime_target.scheduler.{field} is unsupported")
323+
timezone = scheduler.get("timezone")
324+
if not isinstance(timezone, str) or not timezone.strip():
325+
errors.append("runtime_target.scheduler.timezone must be a non-empty string")
326+
for field in ("main_time", "probe_time", "precheck_time"):
327+
value = scheduler.get(field)
328+
if not isinstance(value, str) or len(value.split()) not in {2, 5}:
329+
errors.append(
330+
f"runtime_target.scheduler.{field} must have 2 time fields or 5 cron fields"
331+
)
332+
314333

315334
def validate_plugin_mounts(target: dict[str, Any], errors: list[str]) -> None:
316335
runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {}

tests/test_runtime_settings.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ def test_build_switch_target_defaults_longbridge_sg_tqqq(self):
181181
self.assertEqual(target["github"]["environment"], "longbridge-sg")
182182
self.assertEqual(target["runtime_target"]["service_name"], "longbridge-quant-sg-service")
183183
self.assertEqual(target["runtime_target"]["account_scope"], "SG")
184+
self.assertEqual(
185+
target["runtime_target"]["scheduler"],
186+
{
187+
"timezone": "America/New_York",
188+
"main_time": "45 15 * * *",
189+
"probe_time": "35 9,15 * * *",
190+
"precheck_time": "45 9 * * *",
191+
},
192+
)
184193
self.assertEqual(assignments["STRATEGY_PROFILE"], "tqqq_growth_income")
185194
self.assertEqual(assignments["LONGBRIDGE_DRY_RUN_ONLY"], "false")
186195
plugin_payload = json.loads(assignments["LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON"])
@@ -285,6 +294,74 @@ def test_build_switch_target_defaults_firstrade_repository_scope(self):
285294
plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"])
286295
self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "market_regime_control")
287296

297+
def test_build_switch_target_uses_dca_monthly_scheduler_window(self):
298+
parser = build_runtime_switch.build_parser()
299+
args = parser.parse_args(
300+
[
301+
"--platform",
302+
"ibkr",
303+
"--target-name",
304+
"dca",
305+
"--strategy-profile",
306+
"nasdaq_sp500_smart_dca",
307+
"--plugin-mode",
308+
"none",
309+
]
310+
)
311+
312+
target = build_runtime_switch.build_switch_target(args)
313+
314+
self.assertEqual(
315+
target["runtime_target"]["scheduler"],
316+
{
317+
"timezone": "America/New_York",
318+
"main_time": "45 15 25-29 * *",
319+
"probe_time": "35 9,15 25-29 * *",
320+
"precheck_time": "45 9 25-29 * *",
321+
},
322+
)
323+
324+
def test_build_switch_target_uses_snapshot_scheduler_window(self):
325+
parser = build_runtime_switch.build_parser()
326+
args = parser.parse_args(
327+
[
328+
"--platform",
329+
"longbridge",
330+
"--target-name",
331+
"hk",
332+
"--strategy-profile",
333+
"hk_low_vol_dividend_quality_snapshot",
334+
"--plugin-mode",
335+
"none",
336+
]
337+
)
338+
339+
target = build_runtime_switch.build_switch_target(args)
340+
341+
self.assertEqual(
342+
target["runtime_target"]["scheduler"],
343+
{
344+
"timezone": "Asia/Hong_Kong",
345+
"main_time": "45 15 1-7 * *",
346+
"probe_time": "35 9,15 1-7 * *",
347+
"precheck_time": "45 9 1-7 * *",
348+
},
349+
)
350+
351+
def test_runtime_target_scheduler_rejects_invalid_cron_shape(self):
352+
_, target = self.load_target("examples/targets/schwab/live.example.json")
353+
target["runtime_target"]["scheduler"] = {
354+
"timezone": "America/New_York",
355+
"main_time": "45",
356+
"probe_time": "35 9,15 * * *",
357+
"precheck_time": "45 9 * * *",
358+
}
359+
360+
self.assertIn(
361+
"runtime_target.scheduler.main_time must have 2 time fields or 5 cron fields",
362+
runtime_settings.validate_target(target),
363+
)
364+
288365
def test_build_switch_target_rejects_secret_extra_variable(self):
289366
parser = build_runtime_switch.build_parser()
290367
args = parser.parse_args(

0 commit comments

Comments
 (0)