3131 "russell_top50_leader_rotation" ,
3232 }
3333)
34+ IBIT_ZSCORE_EXIT_STRATEGY_PROFILE = "ibit_smart_dca"
35+ IBIT_ZSCORE_EXIT_PLUGIN = "ibit_zscore_exit"
3436US_DAILY_SCHEDULER = {
3537 "timezone" : "America/New_York" ,
3638 "main_time" : "45 15 * * *" ,
108110)
109111DCA_MODE_CONTROL_FIELD = "dca_mode"
110112DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd"
113+ IBIT_ZSCORE_EXIT_ENABLED_VARIABLE = "IBIT_ZSCORE_EXIT_ENABLED"
114+ IBIT_ZSCORE_EXIT_MODE_VARIABLE = "IBIT_ZSCORE_EXIT_MODE"
115+ IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE = "IBIT_ZSCORE_EXIT_PARKING_SYMBOL"
116+ IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE"
117+ IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE"
118+ IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE = "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW"
119+ IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES = (
120+ IBIT_ZSCORE_EXIT_ENABLED_VARIABLE ,
121+ IBIT_ZSCORE_EXIT_MODE_VARIABLE ,
122+ IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE ,
123+ IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE ,
124+ IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE ,
125+ IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE ,
126+ )
127+ IBIT_ZSCORE_EXIT_CONTROL_FIELDS = (
128+ "ibit_zscore_exit_mode" ,
129+ "ibit_zscore_exit_parking_symbol" ,
130+ "ibit_zscore_exit_risk_reduced_exposure" ,
131+ "ibit_zscore_exit_risk_off_exposure" ,
132+ "ibit_zscore_exit_allow_outside_execution_window" ,
133+ )
111134DEFAULT_VARIABLE_SCOPE = {
112135 "longbridge" : "environment" ,
113136 "ibkr" : "repository" ,
@@ -236,6 +259,51 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
236259 return text
237260
238261
262+ def _normalize_ratio_decimal (value : str , * , field_name : str ) -> str :
263+ text = str (value or "" ).strip ()
264+ if not text or not re .fullmatch (r"(?:\d+|\d*\.\d+)" , text ):
265+ raise ValueError (f"{ field_name } must be a decimal number" )
266+ numeric = float (text )
267+ if numeric < 0 or numeric > 1 :
268+ raise ValueError (f"{ field_name } must be between 0 and 1" )
269+ return text
270+
271+
272+ def _normalize_optional_bool_text (value : str , * , field_name : str ) -> str :
273+ text = str (value or "" ).strip ().lower ()
274+ if text in {"1" , "true" , "yes" , "y" , "on" }:
275+ return "true"
276+ if text in {"0" , "false" , "no" , "n" , "off" }:
277+ return "false"
278+ raise ValueError (f"{ field_name } must be true or false" )
279+
280+
281+ def _normalize_ibit_zscore_exit_mode (value : str ) -> str :
282+ mode = str (value or "" ).strip ().lower ()
283+ aliases = {
284+ "off" : "disabled" ,
285+ "none" : "disabled" ,
286+ "false" : "disabled" ,
287+ "0" : "disabled" ,
288+ "disable" : "disabled" ,
289+ "enabled" : "live" ,
290+ "shadow" : "paper" ,
291+ "dry_run" : "paper" ,
292+ "dry-run" : "paper" ,
293+ }
294+ mode = aliases .get (mode , mode )
295+ if mode not in {"disabled" , "paper" , "live" }:
296+ raise ValueError ("ibit_zscore_exit_mode must be disabled, paper, or live" )
297+ return mode
298+
299+
300+ def _normalize_symbol_text (value : str , * , field_name : str ) -> str :
301+ text = str (value or "" ).strip ().upper ().removesuffix (".US" )
302+ if not text or not re .fullmatch (r"[A-Z0-9.-]{1,12}" , text ):
303+ raise ValueError (f"{ field_name } must be a symbol" )
304+ return text
305+
306+
239307def _extract_dca_control_fields (extra_variables : dict [str , Any ]) -> dict [str , Any ]:
240308 controls : dict [str , Any ] = {}
241309 for field_name in (DCA_MODE_CONTROL_FIELD , DCA_BASE_INVESTMENT_CONTROL_FIELD ):
@@ -244,6 +312,14 @@ def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, An
244312 return controls
245313
246314
315+ def _extract_ibit_zscore_exit_control_fields (extra_variables : dict [str , Any ]) -> dict [str , Any ]:
316+ controls : dict [str , Any ] = {}
317+ for field_name in IBIT_ZSCORE_EXIT_CONTROL_FIELDS :
318+ if field_name in extra_variables :
319+ controls [field_name ] = extra_variables .pop (field_name )
320+ return controls
321+
322+
247323def _dca_extra_variables (
248324 args : argparse .Namespace ,
249325 strategy_profile : str ,
@@ -288,23 +364,130 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
288364 )
289365
290366
367+ def _reject_direct_ibit_zscore_exit_extra_variables (extra_variables : dict [str , Any ]) -> None :
368+ provided = [
369+ variable
370+ for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES
371+ if variable in extra_variables and str (extra_variables .get (variable ) or "" ).strip ()
372+ ]
373+ if provided :
374+ names = ", " .join (provided )
375+ raise ValueError (
376+ "use ibit_zscore_exit_* control fields instead of extra_variables_json "
377+ f"for { names } "
378+ )
379+
380+
381+ def _ibit_zscore_exit_extra_variables (
382+ args : argparse .Namespace ,
383+ strategy_profile : str ,
384+ plugin_mode : str ,
385+ controls : dict [str , Any ] | None = None ,
386+ ) -> dict [str , Any ]:
387+ controls = dict (controls or {})
388+ cli_mode = str (getattr (args , "ibit_zscore_exit_mode" , "" ) or "" ).strip ()
389+ mode_value = cli_mode or controls .get ("ibit_zscore_exit_mode" , "" )
390+ has_controls = bool (mode_value ) or any (
391+ str (controls .get (field , "" ) or "" ).strip ()
392+ for field in IBIT_ZSCORE_EXIT_CONTROL_FIELDS
393+ if field != "ibit_zscore_exit_mode"
394+ )
395+ has_cli_controls = any (
396+ str (getattr (args , attr , "" ) or "" ).strip ()
397+ for attr in (
398+ "ibit_zscore_exit_parking_symbol" ,
399+ "ibit_zscore_exit_risk_reduced_exposure" ,
400+ "ibit_zscore_exit_risk_off_exposure" ,
401+ "ibit_zscore_exit_allow_outside_execution_window" ,
402+ )
403+ )
404+ is_ibit_profile = strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE
405+ if not is_ibit_profile :
406+ if has_controls or has_cli_controls :
407+ raise ValueError ("IBIT Z-Score exit settings are only supported for ibit_smart_dca" )
408+ return {variable : "" for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES }
409+
410+ if not mode_value :
411+ mode = "disabled" if plugin_mode == "none" else "live"
412+ else :
413+ mode = _normalize_ibit_zscore_exit_mode (mode_value )
414+ if plugin_mode == "none" and mode != "disabled" :
415+ raise ValueError ("IBIT Z-Score exit live/paper modes require plugin_mode auto or custom" )
416+
417+ parking_symbol = (
418+ getattr (args , "ibit_zscore_exit_parking_symbol" , "" )
419+ or controls .get ("ibit_zscore_exit_parking_symbol" )
420+ or "BOXX"
421+ )
422+ risk_reduced_exposure = (
423+ getattr (args , "ibit_zscore_exit_risk_reduced_exposure" , "" )
424+ or controls .get ("ibit_zscore_exit_risk_reduced_exposure" )
425+ or "0.50"
426+ )
427+ risk_off_exposure = (
428+ getattr (args , "ibit_zscore_exit_risk_off_exposure" , "" )
429+ or controls .get ("ibit_zscore_exit_risk_off_exposure" )
430+ or "0.25"
431+ )
432+ allow_outside_window = (
433+ getattr (args , "ibit_zscore_exit_allow_outside_execution_window" , "" )
434+ or controls .get ("ibit_zscore_exit_allow_outside_execution_window" )
435+ or "true"
436+ )
437+ return {
438+ IBIT_ZSCORE_EXIT_ENABLED_VARIABLE : "true" if mode != "disabled" else "false" ,
439+ IBIT_ZSCORE_EXIT_MODE_VARIABLE : "paper" if mode == "disabled" else mode ,
440+ IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE : _normalize_symbol_text (
441+ parking_symbol ,
442+ field_name = "ibit_zscore_exit_parking_symbol" ,
443+ ),
444+ IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE : _normalize_ratio_decimal (
445+ risk_reduced_exposure ,
446+ field_name = "ibit_zscore_exit_risk_reduced_exposure" ,
447+ ),
448+ IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE : _normalize_ratio_decimal (
449+ risk_off_exposure ,
450+ field_name = "ibit_zscore_exit_risk_off_exposure" ,
451+ ),
452+ IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE : _normalize_optional_bool_text (
453+ allow_outside_window ,
454+ field_name = "ibit_zscore_exit_allow_outside_execution_window" ,
455+ ),
456+ }
457+
458+
291459def _auto_plugin_mounts (strategy_profile : str , artifact_bucket_uri : str ) -> list [dict [str , Any ]]:
292- if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES :
293- return []
294460 prefix = artifact_bucket_uri .rstrip ("/" )
295- return [
296- {
297- "strategy" : strategy_profile ,
298- "plugin" : "market_regime_control" ,
299- "signal_path" : (
300- f"{ prefix } /strategy-artifacts/us_equity/{ strategy_profile } "
301- "/plugins/market_regime_control/latest_signal.json"
302- ),
303- "enabled" : True ,
304- "expected_mode" : "shadow" ,
305- "expected_schema_version" : "market_regime_control.v1" ,
306- }
307- ]
461+ mounts : list [dict [str , Any ]] = []
462+ if strategy_profile in MARKET_REGIME_CONTROL_PROFILES :
463+ mounts .append (
464+ {
465+ "strategy" : strategy_profile ,
466+ "plugin" : "market_regime_control" ,
467+ "signal_path" : (
468+ f"{ prefix } /strategy-artifacts/us_equity/{ strategy_profile } "
469+ "/plugins/market_regime_control/latest_signal.json"
470+ ),
471+ "enabled" : True ,
472+ "expected_mode" : "shadow" ,
473+ "expected_schema_version" : "market_regime_control.v1" ,
474+ }
475+ )
476+ if strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE :
477+ mounts .append (
478+ {
479+ "strategy" : strategy_profile ,
480+ "plugin" : IBIT_ZSCORE_EXIT_PLUGIN ,
481+ "signal_path" : (
482+ f"{ prefix } /strategy-artifacts/us_equity/{ strategy_profile } "
483+ f"/plugins/{ IBIT_ZSCORE_EXIT_PLUGIN } /latest_signal.json"
484+ ),
485+ "enabled" : True ,
486+ "expected_mode" : "shadow" ,
487+ "expected_schema_version" : "ibit_zscore_exit.v1" ,
488+ }
489+ )
490+ return mounts
308491
309492
310493def _custom_plugin_mounts (raw_json : str ) -> list [dict [str , Any ]]:
@@ -342,8 +525,32 @@ def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]:
342525 raise ValueError ("execution_mode must be live or paper" )
343526
344527
345- def _scheduler_plan_for_strategy (strategy_profile : str ) -> dict [str , str ]:
528+ def _has_enabled_plugin_mount (
529+ mounts : list [dict [str , Any ]] | tuple [dict [str , Any ], ...],
530+ * ,
531+ strategy_profile : str ,
532+ plugin : str ,
533+ ) -> bool :
534+ return any (
535+ isinstance (mount , dict )
536+ and mount .get ("strategy" ) == strategy_profile
537+ and mount .get ("plugin" ) == plugin
538+ and mount .get ("enabled" ) is True
539+ for mount in mounts
540+ )
541+
542+
543+ def _scheduler_plan_for_strategy (
544+ strategy_profile : str ,
545+ plugin_mounts : list [dict [str , Any ]] | tuple [dict [str , Any ], ...] = (),
546+ ) -> dict [str , str ]:
346547 profile = str (strategy_profile or "" ).strip ().lower ()
548+ if profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE and _has_enabled_plugin_mount (
549+ plugin_mounts ,
550+ strategy_profile = profile ,
551+ plugin = IBIT_ZSCORE_EXIT_PLUGIN ,
552+ ):
553+ return dict (US_DAILY_SCHEDULER )
347554 scheduler = STRATEGY_SCHEDULER_PROFILES .get (profile )
348555 if scheduler is None :
349556 scheduler = HK_DAILY_SCHEDULER if profile .startswith ("hk_" ) else US_DAILY_SCHEDULER
@@ -420,6 +627,7 @@ def _preserve_reserved_cash_fields(
420627 * INCOME_LAYER_VARIABLES ,
421628 * RUNTIME_TARGET_VARIABLES ,
422629 * DCA_RUNTIME_VARIABLES ,
630+ * IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES ,
423631 ):
424632 if variable and variable not in replacement and variable in current_entry :
425633 replacement [variable ] = current_entry [variable ]
@@ -482,10 +690,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
482690 github_environment = args .github_environment or _default_github_environment (platform , target_name , variable_scope )
483691 runtime_target = _build_runtime_target (args )
484692 mounts = _plugin_mounts (args , runtime_target ["strategy_profile" ])
693+ runtime_target ["scheduler" ] = _scheduler_plan_for_strategy (runtime_target ["strategy_profile" ], mounts )
485694 mounts_variable = f"{ SUPPORTED_PLATFORMS [platform ]['plugin_mounts_prefix' ]} STRATEGY_PLUGIN_MOUNTS_JSON"
486695 extra_variables = _parse_extra_variables (args .extra_variable , args .extra_variables_json )
487696 dca_controls = _extract_dca_control_fields (extra_variables )
697+ ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields (extra_variables )
488698 _reject_direct_dca_extra_variables (extra_variables )
699+ _reject_direct_ibit_zscore_exit_extra_variables (extra_variables )
489700
490701 if args .set_platform_dry_run_variable :
491702 extra_variables [PLATFORM_DRY_RUN_VARIABLES [platform ]] = env_string (runtime_target ["dry_run_only" ])
@@ -502,6 +713,14 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
502713 if args .qqqi_income_ratio :
503714 extra_variables ["QQQI_INCOME_RATIO" ] = args .qqqi_income_ratio
504715 extra_variables .update (_dca_extra_variables (args , runtime_target ["strategy_profile" ], dca_controls ))
716+ extra_variables .update (
717+ _ibit_zscore_exit_extra_variables (
718+ args ,
719+ runtime_target ["strategy_profile" ],
720+ str (args .plugin_mode or "auto" ).strip ().lower (),
721+ ibit_zscore_exit_controls ,
722+ )
723+ )
505724
506725 service_targets = _load_json_from_file (
507726 args .existing_service_targets_json_file ,
@@ -569,6 +788,11 @@ def build_parser() -> argparse.ArgumentParser:
569788 parser .add_argument ("--qqqi-income-ratio" , default = "" )
570789 parser .add_argument ("--dca-mode" , default = "" )
571790 parser .add_argument ("--dca-base-investment-usd" , default = "" )
791+ parser .add_argument ("--ibit-zscore-exit-mode" , choices = ("disabled" , "paper" , "live" ), default = "" )
792+ parser .add_argument ("--ibit-zscore-exit-parking-symbol" , default = "" )
793+ parser .add_argument ("--ibit-zscore-exit-risk-reduced-exposure" , default = "" )
794+ parser .add_argument ("--ibit-zscore-exit-risk-off-exposure" , default = "" )
795+ parser .add_argument ("--ibit-zscore-exit-allow-outside-execution-window" , default = "" )
572796 parser .add_argument ("--existing-service-targets-json-file" , default = "" )
573797 parser .add_argument ("--no-platform-dry-run-variable" , dest = "set_platform_dry_run_variable" , action = "store_false" )
574798 parser .set_defaults (set_platform_dry_run_variable = True )
0 commit comments