1313SCRIPT_DIR = Path (__file__ ).resolve ().parent
1414if str (SCRIPT_DIR ) not in sys .path :
1515 sys .path .insert (0 , str (SCRIPT_DIR ))
16+ ROOT = SCRIPT_DIR .parent
1617
1718from runtime_settings import ( # noqa: E402
1819 SUPPORTED_PLATFORMS ,
118119 "option_income_overlay_nav_risk_ratio" ,
119120)
120121OPTION_OVERLAY_VARIABLES = tuple (field .upper () for field in OPTION_OVERLAY_CONTROL_FIELDS )
122+ OPTION_OVERLAY_MODES = frozenset ({"current" , "enabled" , "disabled" })
123+ OPTION_OVERLAY_PROFILE_PATH = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
121124RUNTIME_TARGET_VARIABLES = (
122125 "RUNTIME_TARGET_ENABLED" ,
123126)
@@ -285,6 +288,16 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
285288 return text
286289
287290
291+ def _normalize_nonnegative_decimal (value : str , * , field_name : str ) -> str :
292+ text = str (value or "" ).strip ()
293+ if not text or not re .fullmatch (r"(?:\d+|\d*\.\d+)" , text ):
294+ raise ValueError (f"{ field_name } must be a non-negative decimal number" )
295+ numeric = float (text )
296+ if numeric < 0 :
297+ raise ValueError (f"{ field_name } must be non-negative" )
298+ return text
299+
300+
288301def _normalize_ratio_decimal (value : str , * , field_name : str ) -> str :
289302 text = str (value or "" ).strip ()
290303 if not text or not re .fullmatch (r"(?:\d+|\d*\.\d+)" , text ):
@@ -296,14 +309,28 @@ def _normalize_ratio_decimal(value: str, *, field_name: str) -> str:
296309
297310
298311def _normalize_optional_bool_text (value : str , * , field_name : str ) -> str :
299- text = str (value or "" ).strip ().lower ()
312+ text = str (value if value is not None else "" ).strip ().lower ()
300313 if text in {"1" , "true" , "yes" , "y" , "on" }:
301314 return "true"
302315 if text in {"0" , "false" , "no" , "n" , "off" }:
303316 return "false"
304317 raise ValueError (f"{ field_name } must be true or false" )
305318
306319
320+ def _normalize_option_overlay_mode (value : str ) -> str :
321+ mode = str (value or "current" ).strip ().lower ()
322+ if mode not in OPTION_OVERLAY_MODES :
323+ raise ValueError ("option_overlay_mode must be current, enabled, or disabled" )
324+ return mode
325+
326+
327+ def _normalize_option_recipe (value : str , * , field_name : str ) -> str :
328+ text = str (value or "" ).strip ()
329+ if not text or not re .fullmatch (r"[A-Za-z0-9._=-]{1,120}" , text ):
330+ raise ValueError (f"{ field_name } must be a recipe slug" )
331+ return text
332+
333+
307334def _normalize_ibit_zscore_exit_mode (value : str ) -> str :
308335 mode = str (value or "" ).strip ().lower ()
309336 aliases = {
@@ -346,6 +373,95 @@ def _extract_ibit_zscore_exit_control_fields(extra_variables: dict[str, Any]) ->
346373 return controls
347374
348375
376+ def _disabled_option_overlay_extra_variables () -> dict [str , str ]:
377+ values = {variable : "" for variable in OPTION_OVERLAY_VARIABLES }
378+ values ["OPTION_OVERLAY_ENABLED" ] = "false"
379+ values ["OPTION_GROWTH_OVERLAY_ENABLED" ] = "false"
380+ values ["OPTION_INCOME_OVERLAY_ENABLED" ] = "false"
381+ return values
382+
383+
384+ def _profile_bool (item : dict [str , Any ], field_name : str , * , default : bool = False ) -> bool :
385+ if item .get (field_name ) is None or str (item .get (field_name )).strip () == "" :
386+ return default
387+ return _normalize_optional_bool_text (item [field_name ], field_name = field_name ) == "true"
388+
389+
390+ def _option_family_defaults (item : dict [str , Any ], family : str ) -> dict [str , str ]:
391+ control_prefix = f"option_{ family } _overlay"
392+ env_prefix = f"OPTION_{ family .upper ()} _OVERLAY"
393+ enabled = _profile_bool (item , f"{ control_prefix } _enabled" , default = False )
394+ values = {
395+ f"{ env_prefix } _ENABLED" : "true" if enabled else "false" ,
396+ f"{ env_prefix } _RECIPE" : "" ,
397+ f"{ env_prefix } _START_USD" : "" ,
398+ }
399+ if family == "growth" :
400+ ratio_field = "option_growth_overlay_nav_budget_ratio"
401+ ratio_variable = "OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO"
402+ else :
403+ ratio_field = "option_income_overlay_nav_risk_ratio"
404+ ratio_variable = "OPTION_INCOME_OVERLAY_NAV_RISK_RATIO"
405+ values [ratio_variable ] = ""
406+ if not enabled :
407+ return values
408+
409+ values [f"{ env_prefix } _RECIPE" ] = _normalize_option_recipe (
410+ item .get (f"{ control_prefix } _recipe" ),
411+ field_name = f"{ control_prefix } _recipe" ,
412+ )
413+ values [f"{ env_prefix } _START_USD" ] = _normalize_nonnegative_decimal (
414+ item .get (f"{ control_prefix } _start_usd" ),
415+ field_name = f"{ control_prefix } _start_usd" ,
416+ )
417+ values [ratio_variable ] = _normalize_ratio_decimal (item .get (ratio_field ), field_name = ratio_field )
418+ return values
419+
420+
421+ def _load_option_overlay_profile_defaults () -> dict [str , dict [str , str ]]:
422+ try :
423+ payload = json .loads (OPTION_OVERLAY_PROFILE_PATH .read_text (encoding = "utf-8" ))
424+ except OSError as exc :
425+ raise ValueError (f"cannot read { OPTION_OVERLAY_PROFILE_PATH } " ) from exc
426+ except json .JSONDecodeError as exc :
427+ raise ValueError (f"{ OPTION_OVERLAY_PROFILE_PATH } must be valid JSON" ) from exc
428+ if not isinstance (payload , list ):
429+ raise ValueError (f"{ OPTION_OVERLAY_PROFILE_PATH } must contain a strategy profile list" )
430+
431+ defaults : dict [str , dict [str , str ]] = {}
432+ for item in payload :
433+ if not isinstance (item , dict ):
434+ continue
435+ profile = str (item .get ("profile" ) or item .get ("strategy_profile" ) or "" ).strip ().lower ()
436+ if not profile :
437+ continue
438+ if not _profile_bool (item , "option_overlay_enabled" , default = False ):
439+ continue
440+ values = _disabled_option_overlay_extra_variables ()
441+ values ["OPTION_OVERLAY_ENABLED" ] = "true"
442+ values .update (_option_family_defaults (item , "growth" ))
443+ values .update (_option_family_defaults (item , "income" ))
444+ if values ["OPTION_GROWTH_OVERLAY_ENABLED" ] != "true" and values ["OPTION_INCOME_OVERLAY_ENABLED" ] != "true" :
445+ raise ValueError (f"{ profile } option overlay is enabled without a growth or income family" )
446+ defaults [profile ] = values
447+ return defaults
448+
449+
450+ def _option_overlay_extra_variables (args : argparse .Namespace , strategy_profile : str ) -> dict [str , str ]:
451+ mode = _normalize_option_overlay_mode (getattr (args , "option_overlay_mode" , "current" ))
452+ if mode == "current" :
453+ return {}
454+ if mode == "disabled" :
455+ return _disabled_option_overlay_extra_variables ()
456+
457+ defaults = _load_option_overlay_profile_defaults ().get (strategy_profile )
458+ if not defaults :
459+ raise ValueError (
460+ "option_overlay_mode enabled is only supported for strategies with option overlay defaults"
461+ )
462+ return dict (defaults )
463+
464+
349465def _dca_extra_variables (
350466 args : argparse .Namespace ,
351467 strategy_profile : str ,
@@ -670,6 +786,7 @@ def _preserve_reserved_cash_fields(
670786 PLATFORM_MIN_RESERVED_CASH_VARIABLES .get (platform ),
671787 PLATFORM_RESERVED_CASH_RATIO_VARIABLES .get (platform ),
672788 * INCOME_LAYER_VARIABLES ,
789+ * OPTION_OVERLAY_VARIABLES ,
673790 * RUNTIME_TARGET_VARIABLES ,
674791 * DCA_RUNTIME_VARIABLES ,
675792 * IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES ,
@@ -754,6 +871,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
754871 extra_variables ["INCOME_LAYER_START_USD" ] = args .income_layer_start_usd
755872 if args .income_layer_max_ratio :
756873 extra_variables ["INCOME_LAYER_MAX_RATIO" ] = args .income_layer_max_ratio
874+ extra_variables .update (_option_overlay_extra_variables (args , runtime_target ["strategy_profile" ]))
757875 extra_variables .update (_dca_extra_variables (args , runtime_target ["strategy_profile" ], dca_controls ))
758876 extra_variables .update (
759877 _ibit_zscore_exit_extra_variables (
@@ -826,6 +944,7 @@ def build_parser() -> argparse.ArgumentParser:
826944 parser .add_argument ("--min-reserved-cash-usd" , default = "" )
827945 parser .add_argument ("--income-layer-start-usd" , default = "" )
828946 parser .add_argument ("--income-layer-max-ratio" , default = "" )
947+ parser .add_argument ("--option-overlay-mode" , choices = sorted (OPTION_OVERLAY_MODES ), default = "current" )
829948 parser .add_argument ("--dca-mode" , default = "" )
830949 parser .add_argument ("--dca-base-investment-usd" , default = "" )
831950 parser .add_argument ("--ibit-zscore-exit-mode" , choices = ("disabled" , "paper" , "live" ), default = "" )
0 commit comments