Skip to content

Commit f8b3cc1

Browse files
committed
Add SOXX dynamic volatility threshold indicators
1 parent e0f7602 commit f8b3cc1

8 files changed

Lines changed: 295 additions & 39 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.37"
7+
version = "0.7.38"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
setup(
55
name="quant-platform-kit",
6-
version="0.7.37",
6+
version="0.7.38",
77
description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.",
88
package_dir={"": "src"},
99
packages=find_packages(where="src"),

src/quant_platform_kit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
used by older strategy repositories.
55
"""
66

7-
__version__ = "0.7.37"
7+
__version__ = "0.7.38"
88

99
from .common.models import (
1010
ExecutionReport,

src/quant_platform_kit/common/runtime_inputs.py

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
90109
def _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+
143199
def 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:

src/quant_platform_kit/ibkr/runtime_inputs.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,14 @@ def build_semiconductor_rotation_indicators(
7575
*,
7676
trend_ma_window: int = 140,
7777
dynamic_rsi_quantile_window: int = 252,
78+
dynamic_volatility_delever_window: int = 10,
79+
dynamic_volatility_delever_quantile_window: int = 252,
7880
) -> dict[str, dict[str, float]]:
7981
effective_lookback = required_semiconductor_rotation_history_lookback(
8082
trend_ma_window=trend_ma_window,
8183
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
84+
dynamic_volatility_delever_window=dynamic_volatility_delever_window,
85+
dynamic_volatility_delever_quantile_window=dynamic_volatility_delever_quantile_window,
8286
)
8387
soxl_history = historical_close_loader(
8488
ib,
@@ -97,6 +101,8 @@ def build_semiconductor_rotation_indicators(
97101
soxx_history=soxx_history,
98102
trend_ma_window=trend_ma_window,
99103
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
104+
dynamic_volatility_delever_window=dynamic_volatility_delever_window,
105+
dynamic_volatility_delever_quantile_window=dynamic_volatility_delever_quantile_window,
100106
)
101107

102108

@@ -106,12 +112,16 @@ def build_semiconductor_rotation_inputs(
106112
*,
107113
trend_ma_window: int = 140,
108114
dynamic_rsi_quantile_window: int = 252,
115+
dynamic_volatility_delever_window: int = 10,
116+
dynamic_volatility_delever_quantile_window: int = 252,
109117
) -> dict[str, dict[str, dict[str, float]]]:
110118
return {
111119
"derived_indicators": build_semiconductor_rotation_indicators(
112120
ib,
113121
historical_close_loader,
114122
trend_ma_window=trend_ma_window,
115123
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
124+
dynamic_volatility_delever_window=dynamic_volatility_delever_window,
125+
dynamic_volatility_delever_quantile_window=dynamic_volatility_delever_quantile_window,
116126
)
117127
}

0 commit comments

Comments
 (0)