Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions src/features/regime_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,31 +323,58 @@ def calculate_atr(self, high: pd.Series, low: pd.Series, close: pd.Series,
tr = pd.DataFrame({'tr1': tr1, 'tr2': tr2, 'tr3': tr3}).max(axis=1)
return tr.rolling(window=period).mean()

def _rolling_rank(self, series: pd.Series, window: int = 252) -> pd.Series:
"""Return each value's rank (0-1) within the preceding window."""
def _rank(x):
lo, hi = x.min(), x.max()
return (x.iloc[-1] - lo) / (hi - lo) if hi > lo else 0.5
return series.rolling(window, min_periods=20).apply(_rank, raw=False)

def _get_vix_aligned(self, dates: pd.DatetimeIndex) -> pd.DataFrame:
"""Fetch ^VIX history and align to the given date index."""
try:
import yfinance as yf
# Strip tz for clean alignment; yfinance dates vary by version
start = dates[0].tz_localize(None) if hasattr(dates[0], 'tz_localize') and dates[0].tzinfo else dates[0]
end = dates[-1].tz_localize(None) if hasattr(dates[-1], 'tz_localize') and dates[-1].tzinfo else dates[-1]
vix_raw = yf.Ticker("^VIX").history(start=start, end=end + pd.Timedelta(days=5))
vix_close = vix_raw['Close']
vix_close.index = vix_close.index.tz_localize(None) if vix_close.index.tzinfo else vix_close.index
plain_dates = dates.tz_localize(None) if dates.tzinfo else dates
aligned = vix_close.reindex(plain_dates, method='ffill').bfill().fillna(20.0)
vix_pct = self._rolling_rank(aligned).fillna(0.5)
return pd.DataFrame({'vix': aligned.values, 'vix_pct': vix_pct.values}, index=dates)
except Exception:
n = len(dates)
return pd.DataFrame({'vix': [20.0] * n, 'vix_pct': [0.5] * n}, index=dates)

def calculate(self, symbol: str, data: Optional[pd.DataFrame] = None) -> pd.DataFrame:
"""Calculate volatility features for a symbol."""
df = data if data is not None else self.get_data(symbol, days=300)
df = data if data is not None else self.get_data(symbol, days=365)
df = self.handle_missing_data(df)

features = pd.DataFrame(index=df.index)

# 1. Historical volatility (20-day rolling)
# 1. Historical volatility (20-day rolling, annualized)
returns = df['Close'].pct_change()
features['historical_volatility_20'] = returns.rolling(20).std() * np.sqrt(252) # Annualized
hv20 = returns.rolling(20).std() * np.sqrt(252)
features['historical_volatility_20'] = hv20

# 2-4. Implied volatility features (stubbed - would need options data)
features['implied_volatility'] = 0.2 # Stub with average IV
features['iv_rank'] = 0.5 # Stub with neutral rank
features['iv_percentile'] = 0.5 # Stub with neutral percentile
# 2-4. IV features: use HV20 as proxy; rank/percentile from rolling window
# ponytail: true IV requires historical options chain data not in yfinance
features['implied_volatility'] = hv20
features['iv_rank'] = self._rolling_rank(hv20)
features['iv_percentile'] = features['iv_rank']

# 5-6. VIX features (stubbed - would need VIX data)
features['vix_level'] = 20.0 # Stub with average VIX
features['vix_percentile'] = 0.5 # Stub with neutral percentile
# 5-6. VIX level and percentile from live ^VIX history
vix = self._get_vix_aligned(df.index)
features['vix_level'] = vix['vix'].values
features['vix_percentile'] = vix['vix_pct'].values

# 7. Term structure slope (stubbed - would need options data)
features['term_structure_slope'] = 0.0 # Stub with flat term structure

# 8. Put/call skew (stubbed - would need options data)
features['put_call_skew'] = 0.0 # Stub with neutral skew
# 7-8. Options-derived — require historical chain data yfinance does not provide
# ponytail: wire up when a historical options data source is added
features['term_structure_slope'] = 0.0
features['put_call_skew'] = 0.0

# 9-10. Bollinger Bands features
bb_upper, bb_lower, bb_middle = self.calculate_bollinger_bands(df['Close'])
Expand All @@ -358,7 +385,6 @@ def calculate(self, symbol: str, data: Optional[pd.DataFrame] = None) -> pd.Data
atr = self.calculate_atr(df['High'], df['Low'], df['Close'])
features['atr_normalized'] = atr / df['Close']

# Handle missing data and normalize
features = self.handle_missing_data(features)
features = features.fillna(0)

Expand Down
52 changes: 51 additions & 1 deletion tests/features/test_regime_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
PriceStructureFeatures,
TrendIndicators,
MomentumIndicators,
VolatilityFeatures,
RegimeStateVector
)

Expand Down Expand Up @@ -268,4 +269,53 @@ def test_regime_state_vector_no_nans(self, sample_data):

state = rsv.calculate('SPY')

assert not state.isnull().any(), "State vector contains NaN values"
assert not state.isnull().any(), "State vector contains NaN values"


class TestVolatilityFeatures:
"""Verify live-signal volatility features replaced the hardcoded stubs."""

@pytest.fixture
def sample_data(self):
dates = pd.date_range('2022-01-01', periods=300, freq='D')
np.random.seed(7)
close = 100 + np.cumsum(np.random.normal(0, 1, 300))
high = close * (1 + np.abs(np.random.normal(0, 0.01, 300)))
low = close * (1 - np.abs(np.random.normal(0, 0.01, 300)))
return pd.DataFrame({
'Open': close, 'High': high, 'Low': low,
'Close': close, 'Volume': np.ones(300) * 1e6,
}, index=dates)

def _fake_vix(self, dates):
"""VIX that varies so we can assert it's not the constant 20.0."""
vix = pd.Series(np.linspace(15, 35, len(dates)), index=dates)
return pd.DataFrame({'vix': vix.values, 'vix_pct': (vix / 35).values}, index=dates)

def test_vix_level_varies(self, sample_data):
"""vix_level must not be a constant — it now comes from live ^VIX data."""
vf = VolatilityFeatures()
with patch.object(vf, '_get_vix_aligned', side_effect=self._fake_vix):
feats = vf.calculate('SPY', data=sample_data)
assert feats['vix_level'].nunique() > 1, "vix_level is still a constant stub"

def test_implied_volatility_varies(self, sample_data):
"""implied_volatility must not be the hardcoded 0.2 constant."""
vf = VolatilityFeatures()
with patch.object(vf, '_get_vix_aligned', side_effect=self._fake_vix):
feats = vf.calculate('SPY', data=sample_data)
assert feats['implied_volatility'].nunique() > 1, "implied_volatility is still a constant stub"

def test_iv_rank_varies(self, sample_data):
"""iv_rank must not be the hardcoded 0.5 constant."""
vf = VolatilityFeatures()
with patch.object(vf, '_get_vix_aligned', side_effect=self._fake_vix):
feats = vf.calculate('SPY', data=sample_data)
assert feats['iv_rank'].nunique() > 1, "iv_rank is still a constant stub"

def test_no_nans(self, sample_data):
"""Feature DataFrame must have no NaN after calculation."""
vf = VolatilityFeatures()
with patch.object(vf, '_get_vix_aligned', side_effect=self._fake_vix):
feats = vf.calculate('SPY', data=sample_data)
assert not feats.isnull().any().any()