@@ -34,7 +34,9 @@ def _normalize_numeric_history(
3434 continue
3535 normalized .append (numeric )
3636 if not normalized :
37- raise ValueError (f"Semiconductor rotation inputs require non-empty { label } history" )
37+ raise ValueError (
38+ f"Semiconductor rotation inputs require non-empty { label } history"
39+ )
3840 return tuple (normalized )
3941
4042
@@ -75,10 +77,10 @@ def _tail_std(values: tuple[float, ...], window: int) -> float:
7577 return _std (values [- window :])
7678
7779
78- def _tail_realized_volatility (values : tuple [float , ...], window : int ) -> float :
80+ def _sample_realized_volatility (values : tuple [float , ...], window : int ) -> float :
7981 if len (values ) < window + 1 :
8082 raise ValueError ("insufficient history for realized volatility" )
81- tail_values = values [- (window + 1 ):]
83+ tail_values = values [- (window + 1 ) :]
8284 returns : list [float ] = []
8385 for previous , current in zip (tail_values , tail_values [1 :]):
8486 if previous == 0.0 :
@@ -87,6 +89,23 @@ def _tail_realized_volatility(values: tuple[float, ...], window: int) -> float:
8789 return float (_sample_std (returns ) * sqrt (252 ))
8890
8991
92+ def _tail_realized_volatility (values : tuple [float , ...], window : int ) -> float :
93+ return _sample_realized_volatility (values , window )
94+
95+
96+ def _realized_volatility_history (
97+ values : tuple [float , ...], * , window : int
98+ ) -> tuple [float | None , ...]:
99+ if window <= 0 :
100+ raise ValueError ("window must be positive" )
101+ result : list [float | None ] = [None ] * len (values )
102+ for index in range (window , len (values )):
103+ result [index ] = _sample_realized_volatility (
104+ values [index - window : index + 1 ], window
105+ )
106+ return tuple (result )
107+
108+
90109def _compute_rsi (values : tuple [float , ...], * , window : int = 14 ) -> tuple [float , ...]:
91110 if len (values ) < window + 1 :
92111 raise ValueError ("insufficient history for RSI" )
@@ -122,24 +141,61 @@ def _rsi_from_avg(avg_gain_value: float, avg_loss_value: float) -> float:
122141 return tuple (rsis )
123142
124143
125- def _rolling_quantile (values : tuple [float , ...], * , window : int , quantile : float ) -> tuple [float | None , ...]:
144+ def _rolling_count (values : tuple [float | None , ...], * , window : int ) -> tuple [int , ...]:
145+ if window <= 0 :
146+ raise ValueError ("window must be positive" )
147+ result : list [int ] = [0 ] * len (values )
148+ for index in range (len (values )):
149+ start = max (0 , index - window + 1 )
150+ result [index ] = sum (
151+ 1 for value in values [start : index + 1 ] if value is not None
152+ )
153+ return tuple (result )
154+
155+
156+ def _rolling_quantile (
157+ values : tuple [float | None , ...],
158+ * ,
159+ window : int ,
160+ quantile : float ,
161+ min_periods : int | None = None ,
162+ ) -> tuple [float | None , ...]:
126163 if window <= 0 :
127164 raise ValueError ("window must be positive" )
128165 if not 0.0 < quantile < 1.0 :
129166 raise ValueError ("quantile must be between 0 and 1" )
167+ effective_min_periods = window if min_periods is None else max (1 , int (min_periods ))
130168 result : list [float | None ] = [None ] * len (values )
131- for index in range (window - 1 , len (values )):
132- chunk = sorted (values [index - window + 1 : index + 1 ])
133- if not chunk :
169+ for index in range (len (values )):
170+ start = max (0 , index - window + 1 )
171+ chunk = sorted (
172+ value for value in values [start : index + 1 ] if value is not None
173+ )
174+ if len (chunk ) < effective_min_periods :
134175 continue
135176 position = (len (chunk ) - 1 ) * quantile
136177 lower_index = int (position )
137178 upper_index = min (lower_index + 1 , len (chunk ) - 1 )
138179 fraction = position - lower_index
139- result [index ] = chunk [lower_index ] * (1.0 - fraction ) + chunk [upper_index ] * fraction
180+ result [index ] = (
181+ chunk [lower_index ] * (1.0 - fraction ) + chunk [upper_index ] * fraction
182+ )
140183 return tuple (result )
141184
142185
186+ def _bounded_threshold (
187+ value : float | None , * , floor : float | None , cap : float | None
188+ ) -> float | None :
189+ if value is None :
190+ return None
191+ threshold = float (value )
192+ if floor is not None :
193+ threshold = max (float (floor ), threshold )
194+ if cap is not None :
195+ threshold = min (float (cap ), threshold )
196+ return threshold
197+
198+
143199def build_semiconductor_rotation_indicators_from_history (
144200 * ,
145201 soxl_history : Iterable [float ],
@@ -148,6 +204,12 @@ def build_semiconductor_rotation_indicators_from_history(
148204 dynamic_rsi_quantile_window : int = 252 ,
149205 dynamic_rsi_quantile : float = 0.90 ,
150206 dynamic_rsi_floor : float = 70.0 ,
207+ dynamic_volatility_delever_window : int = 10 ,
208+ dynamic_volatility_delever_quantile_window : int = 252 ,
209+ dynamic_volatility_delever_quantile : float = 0.95 ,
210+ dynamic_volatility_delever_min_periods : int = 126 ,
211+ dynamic_volatility_delever_floor : float = 0.50 ,
212+ dynamic_volatility_delever_cap : float = 0.75 ,
151213) -> dict [str , dict [str , float ]]:
152214 window = int (trend_ma_window )
153215 if window <= 0 :
@@ -158,11 +220,26 @@ def build_semiconductor_rotation_indicators_from_history(
158220 rsi_quantile = float (dynamic_rsi_quantile )
159221 if not 0.0 < rsi_quantile < 1.0 :
160222 raise ValueError ("dynamic_rsi_quantile must be between 0 and 1" )
223+ volatility_window = int (dynamic_volatility_delever_window )
224+ if volatility_window <= 0 :
225+ raise ValueError ("dynamic_volatility_delever_window must be positive" )
226+ volatility_quantile_window = int (dynamic_volatility_delever_quantile_window )
227+ if volatility_quantile_window <= 0 :
228+ raise ValueError ("dynamic_volatility_delever_quantile_window must be positive" )
229+ volatility_quantile = float (dynamic_volatility_delever_quantile )
230+ if not 0.0 < volatility_quantile < 1.0 :
231+ raise ValueError ("dynamic_volatility_delever_quantile must be between 0 and 1" )
232+ volatility_min_periods = max (
233+ 1 ,
234+ min (volatility_quantile_window , int (dynamic_volatility_delever_min_periods )),
235+ )
161236
162237 soxl_close = _normalize_numeric_history (soxl_history , label = "SOXL" )
163238 soxx_close = _normalize_numeric_history (soxx_history , label = "SOXX" )
164239 if len (soxl_close ) < window or len (soxx_close ) < window :
165- raise ValueError ("Semiconductor rotation inputs require sufficient SOXL/SOXX history" )
240+ raise ValueError (
241+ "Semiconductor rotation inputs require sufficient SOXL/SOXX history"
242+ )
166243
167244 soxl_ma_trend = _tail_mean (soxl_close , window )
168245 soxx_ma_trend = _tail_mean (soxx_close , window )
@@ -176,17 +253,68 @@ def build_semiconductor_rotation_indicators_from_history(
176253 window = rsi_quantile_window ,
177254 quantile = rsi_quantile ,
178255 )
179- previous_threshold = rsi_threshold_history [- 2 ] if len (rsi_threshold_history ) >= 2 else None
256+ previous_threshold = (
257+ rsi_threshold_history [- 2 ] if len (rsi_threshold_history ) >= 2 else None
258+ )
180259 soxx_dynamic_rsi_threshold = float (
181260 max (
182261 float (dynamic_rsi_floor ),
183- float (previous_threshold ) if previous_threshold is not None else float (dynamic_rsi_floor ),
262+ float (previous_threshold )
263+ if previous_threshold is not None
264+ else float (dynamic_rsi_floor ),
184265 )
185266 )
186267 soxx_bb_mid = _tail_mean (soxx_close , 20 )
187268 soxx_bb_std = _tail_std (soxx_close , 20 )
188269 soxx_realized_volatility_10 = _tail_realized_volatility (soxx_close , 10 )
189270 soxx_realized_volatility_20 = _tail_realized_volatility (soxx_close , 20 )
271+ soxx_volatility_history = _realized_volatility_history (
272+ soxx_close ,
273+ window = volatility_window ,
274+ )
275+ volatility_threshold_history = _rolling_quantile (
276+ soxx_volatility_history ,
277+ window = volatility_quantile_window ,
278+ quantile = volatility_quantile ,
279+ min_periods = volatility_min_periods ,
280+ )
281+ soxx_dynamic_volatility_threshold = _bounded_threshold (
282+ volatility_threshold_history [- 1 ],
283+ floor = dynamic_volatility_delever_floor ,
284+ cap = dynamic_volatility_delever_cap ,
285+ )
286+ soxx_dynamic_volatility_sample_count = _rolling_count (
287+ soxx_volatility_history ,
288+ window = volatility_quantile_window ,
289+ )[- 1 ]
290+ volatility_threshold_fields = {
291+ f"realized_volatility_{ volatility_window } _dynamic_threshold" : soxx_dynamic_volatility_threshold ,
292+ f"realized_volatility_{ volatility_window } _dynamic_sample_count" : float (
293+ soxx_dynamic_volatility_sample_count
294+ ),
295+ f"realized_volatility_{ volatility_window } _dynamic_lookback" : float (
296+ volatility_quantile_window
297+ ),
298+ f"realized_volatility_{ volatility_window } _dynamic_percentile" : volatility_quantile ,
299+ f"realized_volatility_{ volatility_window } _dynamic_min_periods" : float (
300+ volatility_min_periods
301+ ),
302+ f"realized_volatility_{ volatility_window } _dynamic_floor" : float (
303+ dynamic_volatility_delever_floor
304+ ),
305+ f"realized_volatility_{ volatility_window } _dynamic_cap" : float (
306+ dynamic_volatility_delever_cap
307+ ),
308+ "realized_volatility_dynamic_threshold" : soxx_dynamic_volatility_threshold ,
309+ "realized_volatility_dynamic_sample_count" : float (
310+ soxx_dynamic_volatility_sample_count
311+ ),
312+ "realized_volatility_dynamic_lookback" : float (volatility_quantile_window ),
313+ "realized_volatility_dynamic_percentile" : volatility_quantile ,
314+ "realized_volatility_dynamic_min_periods" : float (volatility_min_periods ),
315+ "realized_volatility_dynamic_floor" : float (dynamic_volatility_delever_floor ),
316+ "realized_volatility_dynamic_cap" : float (dynamic_volatility_delever_cap ),
317+ }
190318 return {
191319 "soxl" : {
192320 "price" : float (soxl_close [- 1 ]),
@@ -205,6 +333,11 @@ def build_semiconductor_rotation_indicators_from_history(
205333 "realized_volatility" : soxx_realized_volatility_20 ,
206334 "realized_volatility_10" : soxx_realized_volatility_10 ,
207335 "realized_volatility_20" : soxx_realized_volatility_20 ,
336+ ** {
337+ key : value
338+ for key , value in volatility_threshold_fields .items ()
339+ if value is not None
340+ },
208341 },
209342 }
210343
@@ -213,12 +346,17 @@ def required_semiconductor_rotation_history_lookback(
213346 * ,
214347 trend_ma_window : int = 140 ,
215348 dynamic_rsi_quantile_window : int = 252 ,
349+ dynamic_volatility_delever_window : int = 10 ,
350+ dynamic_volatility_delever_quantile_window : int = 252 ,
216351 minimum_lookback : int = DEFAULT_SEMICONDUCTOR_ROTATION_HISTORY_LOOKBACK ,
217352) -> int :
218353 return max (
219354 int (minimum_lookback ),
220355 int (trend_ma_window ) + 20 ,
221356 int (dynamic_rsi_quantile_window ) + 28 ,
357+ int (dynamic_volatility_delever_quantile_window )
358+ + int (dynamic_volatility_delever_window )
359+ + 1 ,
222360 )
223361
224362
@@ -230,6 +368,12 @@ def build_semiconductor_rotation_inputs_from_history(
230368 dynamic_rsi_quantile_window : int = 252 ,
231369 dynamic_rsi_quantile : float = 0.90 ,
232370 dynamic_rsi_floor : float = 70.0 ,
371+ dynamic_volatility_delever_window : int = 10 ,
372+ dynamic_volatility_delever_quantile_window : int = 252 ,
373+ dynamic_volatility_delever_quantile : float = 0.95 ,
374+ dynamic_volatility_delever_min_periods : int = 126 ,
375+ dynamic_volatility_delever_floor : float = 0.50 ,
376+ dynamic_volatility_delever_cap : float = 0.75 ,
233377) -> dict [str , dict [str , dict [str , float ]]]:
234378 return {
235379 "derived_indicators" : build_semiconductor_rotation_indicators_from_history (
@@ -239,6 +383,12 @@ def build_semiconductor_rotation_inputs_from_history(
239383 dynamic_rsi_quantile_window = dynamic_rsi_quantile_window ,
240384 dynamic_rsi_quantile = dynamic_rsi_quantile ,
241385 dynamic_rsi_floor = dynamic_rsi_floor ,
386+ dynamic_volatility_delever_window = dynamic_volatility_delever_window ,
387+ dynamic_volatility_delever_quantile_window = dynamic_volatility_delever_quantile_window ,
388+ dynamic_volatility_delever_quantile = dynamic_volatility_delever_quantile ,
389+ dynamic_volatility_delever_min_periods = dynamic_volatility_delever_min_periods ,
390+ dynamic_volatility_delever_floor = dynamic_volatility_delever_floor ,
391+ dynamic_volatility_delever_cap = dynamic_volatility_delever_cap ,
242392 )
243393 }
244394
@@ -250,7 +400,9 @@ def build_account_state_from_portfolio_snapshot(
250400 liquid_cash : float | None = None ,
251401) -> dict [str , Any ]:
252402 metadata = getattr (snapshot , "metadata" , {}) or {}
253- raw_sellable_quantities = metadata .get ("sellable_quantities" ) if isinstance (metadata , Mapping ) else None
403+ raw_sellable_quantities = (
404+ metadata .get ("sellable_quantities" ) if isinstance (metadata , Mapping ) else None
405+ )
254406 resolved_sellable_quantities : dict [str , float ] = {}
255407 if isinstance (raw_sellable_quantities , Mapping ):
256408 resolved_sellable_quantities = {
@@ -281,7 +433,9 @@ def build_account_state_from_portfolio_snapshot(
281433
282434 quantity = float (position .quantity )
283435 quantities [symbol ] = quantity
284- sellable_quantities [symbol ] = float (resolved_sellable_quantities .get (symbol , quantity ))
436+ sellable_quantities [symbol ] = float (
437+ resolved_sellable_quantities .get (symbol , quantity )
438+ )
285439 market_values [symbol ] = float (position .market_value )
286440
287441 resolved_liquid_cash = liquid_cash
@@ -301,7 +455,9 @@ def build_account_state_from_portfolio_snapshot(
301455 "sellable_quantities" : sellable_quantities ,
302456 "total_strategy_equity" : float (snapshot .total_equity ),
303457 }
304- raw_cash_by_currency = metadata .get ("cash_by_currency" ) if isinstance (metadata , Mapping ) else None
458+ raw_cash_by_currency = (
459+ metadata .get ("cash_by_currency" ) if isinstance (metadata , Mapping ) else None
460+ )
305461 if isinstance (raw_cash_by_currency , Mapping ):
306462 account_state ["cash_by_currency" ] = {
307463 str (currency ).strip ().upper (): float (amount )
@@ -321,7 +477,9 @@ def build_portfolio_snapshot_from_account_state(
321477 normalized_symbols = _normalize_symbols (strategy_symbols )
322478 market_values = dict (account_state ["market_values" ])
323479 quantities = dict (account_state ["quantities" ])
324- symbols = normalized_symbols or tuple (sorted (str (symbol ) for symbol in market_values ))
480+ symbols = normalized_symbols or tuple (
481+ sorted (str (symbol ) for symbol in market_values )
482+ )
325483
326484 positions : list [Position ] = []
327485 for symbol in symbols :
0 commit comments