1111------------
1212SPY 200-day MA regime signal:
1313- SPY above MA200 (bull): normal weights
14- - SPY below MA200 (bear): TQQQ cut 50% -> 20%, SOXL cut 50% -> 10% , BOXX -> 70%
14+ - SPY below MA200 (bear): risk legs retain 50% by default , BOXX receives the rest
1515"""
1616
1717from __future__ import annotations
1818
19+ import json
20+ import math
21+ from importlib import resources
22+ from pathlib import Path
1923from typing import Any
2024
2125from quant_platform_kit .common .strategies import compute_portfolio_drift
2832DEFAULT_SOXL_WEIGHT = 0.20
2933DEFAULT_BOXX_WEIGHT = 0.40
3034DEFAULT_REBALANCE_THRESHOLD = 0.05 # 5% drift triggers rebalance
35+ DEFAULT_HARD_DEFENSE_RISK_EXPOSURE = 0.50
36+
37+
38+ def _noop_logger (_message : str ) -> None :
39+ return None
40+
41+
42+ def _read_runtime_config_text (config_path : str | Path ) -> tuple [str , str ]:
43+ raw_path = str (config_path ).strip ()
44+ if raw_path .startswith ("package://" ):
45+ package_resource = raw_path .removeprefix ("package://" )
46+ package_name , separator , resource_name = package_resource .partition ("/" )
47+ if not separator or not package_name or not resource_name :
48+ raise ValueError (f"Invalid package runtime config path: { raw_path !r} " )
49+ resource = resources .files (package_name ).joinpath (resource_name )
50+ if not resource .is_file ():
51+ raise FileNotFoundError (f"Runtime strategy config not found: { raw_path } " )
52+ return resource .read_text (encoding = "utf-8" ), raw_path
53+
54+ config_file = Path (config_path )
55+ if not config_file .exists ():
56+ raise FileNotFoundError (f"Runtime strategy config not found: { config_file } " )
57+ return config_file .read_text (encoding = "utf-8" ), str (config_file )
58+
59+
60+ def load_runtime_parameters (
61+ * ,
62+ config_path : str | Path | None = None ,
63+ logger = None ,
64+ ) -> dict [str , object ]:
65+ if logger is None :
66+ logger = _noop_logger
67+ if config_path is None :
68+ return {}
69+
70+ config_text , resolved_config_path = _read_runtime_config_text (config_path )
71+ payload = json .loads (config_text )
72+ if not isinstance (payload , dict ):
73+ raise ValueError ("Runtime strategy config JSON root must be an object" )
74+ profile = str (payload .get ("strategy_profile" ) or PROFILE_NAME ).strip ()
75+ if profile != PROFILE_NAME :
76+ raise ValueError (f"Runtime strategy config strategy_profile must be { PROFILE_NAME !r} " )
77+
78+ raw_runtime_config = payload .get ("runtime_config" , payload )
79+ if not isinstance (raw_runtime_config , dict ):
80+ raise ValueError ("Runtime strategy config runtime_config must be an object" )
81+
82+ runtime_config = dict (raw_runtime_config )
83+ for key in ("tqqq_weight" , "soxl_weight" , "boxx_weight" , "hard_defense_risk_exposure" ):
84+ if key not in runtime_config :
85+ continue
86+ value = float (runtime_config [key ])
87+ if not math .isfinite (value ):
88+ raise ValueError (f"Runtime strategy config { key } must be finite" )
89+ if value < 0.0 :
90+ raise ValueError (f"Runtime strategy config { key } must be non-negative" )
91+ runtime_config [key ] = value
92+ if "hard_defense_risk_exposure" in runtime_config and float (runtime_config ["hard_defense_risk_exposure" ]) > 1.0 :
93+ raise ValueError ("Runtime strategy config hard_defense_risk_exposure must be in [0, 1]" )
94+ if all (key in runtime_config for key in ("tqqq_weight" , "soxl_weight" , "boxx_weight" )):
95+ total = sum (float (runtime_config [key ]) for key in ("tqqq_weight" , "soxl_weight" , "boxx_weight" ))
96+ if total <= 0.0 :
97+ raise ValueError ("Runtime strategy config weights must sum to a positive value" )
98+
99+ runtime_config ["runtime_config_name" ] = str (payload .get ("name" ) or Path (resolved_config_path ).stem )
100+ runtime_config ["runtime_config_path" ] = resolved_config_path
101+ runtime_config ["runtime_config_source" ] = "external_config"
102+ logger (f"[{ PROFILE_NAME } ] runtime config source=external_config path={ resolved_config_path } " )
103+ return runtime_config
31104
32105
33106def build_target_weights (
@@ -46,6 +119,9 @@ def build_target_weights(
46119 Override configuration. Accepted keys:
47120 - tqqq_weight, soxl_weight, boxx_weight (overrides defaults)
48121 - dynamic (bool, default True)
122+ - hard_defense_risk_exposure (float, default 0.50): retained
123+ TQQQ/SOXL fraction when SPY is below MA200. Use 0.0 for a
124+ full BOXX hard-defense shadow.
49125
50126 Returns
51127 -------
@@ -57,15 +133,19 @@ def build_target_weights(
57133 soxl_weight = float (cfg .get ("soxl_weight" , DEFAULT_SOXL_WEIGHT ))
58134 boxx_weight = float (cfg .get ("boxx_weight" , DEFAULT_BOXX_WEIGHT ))
59135 dynamic = bool (cfg .get ("dynamic" , True ))
136+ hard_defense_risk_exposure = min (
137+ 1.0 ,
138+ max (0.0 , float (cfg .get ("hard_defense_risk_exposure" , DEFAULT_HARD_DEFENSE_RISK_EXPOSURE ))),
139+ )
60140
61141 # Dynamic mode: SPY MA200 risk-off
62142 spy_above_ma200 = True
63143 if isinstance (market_data , dict ):
64144 spy_above_ma200 = bool (market_data .get ("spy_above_ma200" , True ))
65145
66146 if dynamic and not spy_above_ma200 :
67- tqqq_weight = DEFAULT_TQQQ_WEIGHT * 0.50
68- soxl_weight = DEFAULT_SOXL_WEIGHT * 0.50
147+ tqqq_weight *= hard_defense_risk_exposure
148+ soxl_weight *= hard_defense_risk_exposure
69149 boxx_weight = 1.0 - tqqq_weight - soxl_weight
70150
71151 # Normalize
@@ -92,6 +172,7 @@ def build_target_weights(
92172 "BOXX" : boxx_weight ,
93173 },
94174 "dynamic" : dynamic ,
175+ "hard_defense_risk_exposure" : hard_defense_risk_exposure ,
95176 "rebalance" : compute_portfolio_drift (
96177 weights ,
97178 holdings = cfg .get ("current_holdings_quantities" , {}),
0 commit comments