Skip to content

Commit 3368b26

Browse files
authored
Add dynamic RSI semiconductor indicators (#26)
* Add dynamic RSI semiconductor indicators * Address dynamic RSI indicator review
1 parent 268171a commit 3368b26

7 files changed

Lines changed: 69 additions & 10 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.19"
7+
version = "0.7.20"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""QuantPlatformKit public package surface."""
22

3-
__version__ = "0.7.19"
3+
__version__ = "0.7.20"
44

55
from .common.models import (
66
ExecutionReport,

src/quant_platform_kit/common/runtime_inputs.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,19 @@ def build_semiconductor_rotation_indicators_from_history(
4747
soxl_history: Iterable[float] | pd.Series,
4848
soxx_history: Iterable[float] | pd.Series,
4949
trend_ma_window: int = 140,
50+
dynamic_rsi_quantile_window: int = 252,
51+
dynamic_rsi_quantile: float = 0.90,
52+
dynamic_rsi_floor: float = 70.0,
5053
) -> dict[str, dict[str, float]]:
5154
window = int(trend_ma_window)
5255
if window <= 0:
5356
raise ValueError("trend_ma_window must be positive")
57+
rsi_quantile_window = int(dynamic_rsi_quantile_window)
58+
if rsi_quantile_window <= 0:
59+
raise ValueError("dynamic_rsi_quantile_window must be positive")
60+
rsi_quantile = float(dynamic_rsi_quantile)
61+
if not 0.0 < rsi_quantile < 1.0:
62+
raise ValueError("dynamic_rsi_quantile must be between 0 and 1")
5463

5564
soxl_close = _normalize_numeric_history(soxl_history, label="SOXL")
5665
soxx_close = _normalize_numeric_history(soxx_history, label="SOXX")
@@ -61,7 +70,26 @@ def build_semiconductor_rotation_indicators_from_history(
6170
soxx_ma_trend = float(soxx_close.rolling(window).mean().iloc[-1])
6271
soxx_ma20 = float(soxx_close.rolling(20).mean().iloc[-1])
6372
soxx_ma20_slope = float(soxx_close.rolling(20).mean().diff().iloc[-1])
64-
soxx_rsi14 = float(_compute_rsi(soxx_close, window=14).iloc[-1])
73+
soxx_rsi_history = _compute_rsi(soxx_close, window=14)
74+
soxx_rsi14 = float(soxx_rsi_history.iloc[-1])
75+
rsi_threshold_history = (
76+
soxx_rsi_history.rolling(
77+
rsi_quantile_window,
78+
min_periods=min(rsi_quantile_window, max(60, min(rsi_quantile_window, 126) // 2)),
79+
)
80+
.quantile(rsi_quantile)
81+
.shift(1)
82+
)
83+
soxx_dynamic_rsi_threshold = float(
84+
max(
85+
float(dynamic_rsi_floor),
86+
(
87+
rsi_threshold_history.iloc[-1]
88+
if pd.notna(rsi_threshold_history.iloc[-1])
89+
else float(dynamic_rsi_floor)
90+
),
91+
)
92+
)
6593
soxx_bb_mid = float(soxx_close.rolling(20).mean().iloc[-1])
6694
soxx_bb_std = float(soxx_close.rolling(20).std(ddof=0).iloc[-1])
6795
return {
@@ -75,6 +103,7 @@ def build_semiconductor_rotation_indicators_from_history(
75103
"ma20": soxx_ma20,
76104
"ma20_slope": soxx_ma20_slope,
77105
"rsi14": soxx_rsi14,
106+
"rsi14_dynamic_threshold": soxx_dynamic_rsi_threshold,
78107
"bb_mid": soxx_bb_mid,
79108
"bb_upper": soxx_bb_mid + 2.0 * soxx_bb_std,
80109
"bb_lower": soxx_bb_mid - 2.0 * soxx_bb_std,
@@ -87,12 +116,18 @@ def build_semiconductor_rotation_inputs_from_history(
87116
soxl_history: Iterable[float] | pd.Series,
88117
soxx_history: Iterable[float] | pd.Series,
89118
trend_ma_window: int = 140,
119+
dynamic_rsi_quantile_window: int = 252,
120+
dynamic_rsi_quantile: float = 0.90,
121+
dynamic_rsi_floor: float = 70.0,
90122
) -> dict[str, dict[str, dict[str, float]]]:
91123
return {
92124
"derived_indicators": build_semiconductor_rotation_indicators_from_history(
93125
soxl_history=soxl_history,
94126
soxx_history=soxx_history,
95127
trend_ma_window=trend_ma_window,
128+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
129+
dynamic_rsi_quantile=dynamic_rsi_quantile,
130+
dynamic_rsi_floor=dynamic_rsi_floor,
96131
)
97132
}
98133

src/quant_platform_kit/ibkr/runtime_inputs.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ def build_semiconductor_rotation_indicators(
7474
*,
7575
trend_ma_window: int = 140,
7676
lookback_buffer: int = 20,
77+
dynamic_rsi_quantile_window: int = 252,
7778
) -> dict[str, dict[str, float]]:
78-
effective_lookback = max(220, int(trend_ma_window) + int(lookback_buffer))
79+
effective_lookback = max(
80+
420,
81+
int(trend_ma_window) + int(lookback_buffer),
82+
int(dynamic_rsi_quantile_window) + int(lookback_buffer) + 90,
83+
)
7984
soxl_history = historical_close_loader(
8085
ib,
8186
"SOXL",
@@ -92,6 +97,7 @@ def build_semiconductor_rotation_indicators(
9297
soxl_history=soxl_history,
9398
soxx_history=soxx_history,
9499
trend_ma_window=trend_ma_window,
100+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
95101
)
96102

97103

@@ -101,12 +107,14 @@ def build_semiconductor_rotation_inputs(
101107
*,
102108
trend_ma_window: int = 140,
103109
lookback_buffer: int = 20,
110+
dynamic_rsi_quantile_window: int = 252,
104111
) -> dict[str, dict[str, dict[str, float]]]:
105112
return {
106113
"derived_indicators": build_semiconductor_rotation_indicators(
107114
ib,
108115
historical_close_loader,
109116
trend_ma_window=trend_ma_window,
110117
lookback_buffer=lookback_buffer,
118+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
111119
)
112120
}

src/quant_platform_kit/longbridge/market_data.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ def calculate_rotation_indicators(
2121
*,
2222
trend_window: int,
2323
lookback: int | None = None,
24+
dynamic_rsi_quantile_window: int = 252,
2425
) -> dict[str, dict[str, float]] | None:
2526
from longport.openapi import AdjustType, Period
2627

27-
effective_lookback = lookback if lookback is not None else max(220, trend_window + 20)
28+
effective_lookback = lookback if lookback is not None else max(280, trend_window + 20, dynamic_rsi_quantile_window + 28)
2829
soxl_bars = q_ctx.candlesticks("SOXL.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
2930
soxx_bars = q_ctx.candlesticks("SOXX.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
3031
if not soxl_bars or not soxx_bars:
@@ -39,4 +40,5 @@ def calculate_rotation_indicators(
3940
soxl_history=df_soxl["close"],
4041
soxx_history=df_soxx["close"],
4142
trend_ma_window=trend_window,
43+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
4244
)

tests/test_ibkr_runtime_inputs.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
9494
trend_ma_window=140,
9595
)
9696

97-
self.assertEqual(observed[0], ("SOXL", "220 D", "1 day"))
98-
self.assertEqual(observed[1], ("SOXX", "220 D", "1 day"))
97+
self.assertEqual(observed[0], ("SOXL", "420 D", "1 day"))
98+
self.assertEqual(observed[1], ("SOXX", "420 D", "1 day"))
9999
self.assertEqual(indicators["soxl"]["price"], 269.0)
100100
self.assertAlmostEqual(
101101
indicators["soxl"]["ma_trend"],
@@ -112,6 +112,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
112112
)
113113
self.assertGreater(indicators["soxx"]["ma20_slope"], 0.0)
114114
self.assertEqual(indicators["soxx"]["rsi14"], 100.0)
115+
self.assertGreaterEqual(indicators["soxx"]["rsi14_dynamic_threshold"], 70.0)
115116
self.assertGreater(indicators["soxx"]["bb_upper"], indicators["soxx"]["price"])
116117
self.assertLess(indicators["soxx"]["bb_lower"], indicators["soxx"]["price"])
117118

@@ -133,6 +134,7 @@ def test_build_semiconductor_rotation_indicators_from_history_is_generic(self) -
133134
sum(200.0 + idx for idx in range(30, 170)) / 140,
134135
)
135136
self.assertEqual(indicators["soxx"]["rsi14"], 100.0)
137+
self.assertGreaterEqual(indicators["soxx"]["rsi14_dynamic_threshold"], 70.0)
136138
self.assertGreater(indicators["soxx"]["bb_upper"], indicators["soxx"]["price"])
137139
wrapped = build_semiconductor_rotation_inputs_from_history(
138140
soxl_history=[100.0 + idx for idx in range(170)],
@@ -142,6 +144,16 @@ def test_build_semiconductor_rotation_indicators_from_history_is_generic(self) -
142144
self.assertEqual(set(wrapped), {"derived_indicators"})
143145
self.assertEqual(wrapped["derived_indicators"]["soxl"]["price"], 269.0)
144146

147+
def test_build_semiconductor_rotation_indicators_accepts_short_dynamic_rsi_window(self) -> None:
148+
indicators = build_semiconductor_rotation_indicators_from_history(
149+
soxl_history=[100.0 + idx for idx in range(170)],
150+
soxx_history=[200.0 + idx for idx in range(170)],
151+
trend_ma_window=140,
152+
dynamic_rsi_quantile_window=20,
153+
)
154+
155+
self.assertEqual(indicators["soxx"]["rsi14_dynamic_threshold"], 100.0)
156+
145157
def test_build_semiconductor_rotation_inputs_wraps_derived_indicators(self) -> None:
146158
def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
147159
if symbol == "SOXL":
@@ -161,6 +173,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
161173
self.assertEqual(payload["derived_indicators"]["soxx"]["price"], 200.0)
162174
self.assertEqual(payload["derived_indicators"]["soxx"]["ma20"], 200.0)
163175
self.assertEqual(payload["derived_indicators"]["soxx"]["rsi14"], 50.0)
176+
self.assertEqual(payload["derived_indicators"]["soxx"]["rsi14_dynamic_threshold"], 70.0)
164177

165178
def test_build_semiconductor_rotation_indicators_requires_sufficient_history(self) -> None:
166179
def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):

tests/test_longbridge_market_data.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ def test_calculate_rotation_indicators(self) -> None:
4242
indicators = calculate_rotation_indicators(FakeQuoteContext(), trend_window=150)
4343

4444
self.assertIsNotNone(indicators)
45-
self.assertEqual(indicators["soxl"]["price"], 319.0)
46-
self.assertEqual(indicators["soxx"]["price"], 419.0)
47-
self.assertAlmostEqual(indicators["soxx"]["ma20"], sum(200.0 + i for i in range(200, 220)) / 20)
45+
self.assertEqual(indicators["soxl"]["price"], 379.0)
46+
self.assertEqual(indicators["soxx"]["price"], 479.0)
47+
self.assertAlmostEqual(indicators["soxx"]["ma20"], sum(200.0 + i for i in range(260, 280)) / 20)
4848
self.assertGreater(indicators["soxx"]["ma20_slope"], 0.0)
4949
self.assertEqual(indicators["soxx"]["rsi14"], 100.0)
50+
self.assertGreaterEqual(indicators["soxx"]["rsi14_dynamic_threshold"], 70.0)
5051
self.assertGreater(indicators["soxx"]["bb_upper"], indicators["soxx"]["price"])
5152
self.assertLess(indicators["soxx"]["bb_lower"], indicators["soxx"]["price"])
5253

0 commit comments

Comments
 (0)