File size: 10,017 Bytes
6114f21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | """
Technical Indicator Engineering Module
Calculates Momentum, Trend, Volatility, Volume, and Seasonality features
for ML/RL/DL model feature vectors.
Feature set (21 total):
Trend : EMA7, EMA21, EMA50, EMA200
Momentum : MACD_line, MACD_signal, MACD_diff, RSI
Volume : OBV
Volatility : BBH, BBL, BB_width, RealizedVol, ATR_norm
Return : Return, LogReturn
Regime : DistEMA200
Seasonality: DayOfWeek_sin, DayOfWeek_cos, Month_sin, Month_cos
"""
import logging
import numpy as np
import pandas as pd
import ta
from src.config import INDICATOR_PARAMS
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("TechnicalIndicators")
class TechnicalIndicatorEngine:
"""
Transforms raw OHLCV market data into rich feature vectors for model inference.
"""
@staticmethod
def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
Computes all 21 institutional quantitative features and returns a
cleaned DataFrame with warm-up NaN rows removed.
New features vs. original:
LogReturn : ln(P_t / P_{t-1}) β cleaner distributional properties
RealizedVol : 20-day rolling std(LogReturn) Γ β252 β annualised
DistEMA200 : (Adj Close - EMA200) / EMA200 β regime distance
ATR_norm : ATR(14) / Close β normalised intraday bar expansion
DayOfWeek_sin/cos : sine/cosine of ISO day-of-week (0=Mon β¦ 6=Sun)
Month_sin/cos : sine/cosine of calendar month (1β12)
Cyclical features are extracted from the DataFrame's DatetimeIndex
BEFORE reset_index(drop=True) to avoid losing timestamp information.
"""
if df.empty or len(df) < 50:
logger.warning("DataFrame is too small to calculate technical indicators.")
return df
data = df.copy()
try:
# ββ 1. Exponential Moving Averages βββββββββββββββββββββββββββββββ
data["EMA7"] = ta.trend.EMAIndicator(
close=data["Adj Close"], window=INDICATOR_PARAMS["ema_fast"], fillna=False
).ema_indicator()
data["EMA21"] = ta.trend.EMAIndicator(
close=data["Adj Close"], window=INDICATOR_PARAMS["ema_slow"], fillna=False
).ema_indicator()
data["EMA50"] = ta.trend.EMAIndicator(
close=data["Adj Close"], window=50, fillna=False
).ema_indicator()
data["EMA200"] = ta.trend.EMAIndicator(
close=data["Adj Close"], window=INDICATOR_PARAMS["ema_trend"], fillna=False
).ema_indicator()
# ββ 2. MACD ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
macd = ta.trend.MACD(
close=data["Adj Close"],
window_slow=INDICATOR_PARAMS["macd_slow"],
window_fast=INDICATOR_PARAMS["macd_fast"],
window_sign=INDICATOR_PARAMS["macd_signal"],
fillna=False,
)
data["MACD_line"] = macd.macd()
data["MACD_signal"] = macd.macd_signal()
data["MACD_diff"] = macd.macd_diff()
# ββ 3. RSI βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
data["RSI"] = ta.momentum.RSIIndicator(
close=data["Adj Close"],
window=INDICATOR_PARAMS["rsi_period"],
fillna=False,
).rsi()
# ββ 4. On-Balance Volume βββββββββββββββββββββββββββββββββββββββββ
data["OBV"] = ta.volume.OnBalanceVolumeIndicator(
close=data["Adj Close"], volume=data["Volume"], fillna=False
).on_balance_volume()
# ββ 5. Bollinger Bands βββββββββββββββββββββββββββββββββββββββββββ
bb = ta.volatility.BollingerBands(
close=data["Adj Close"],
window=INDICATOR_PARAMS["bollinger_window"],
window_dev=INDICATOR_PARAMS["bollinger_std"],
fillna=False,
)
data["BBH"] = bb.bollinger_hband_indicator()
data["BBL"] = bb.bollinger_lband_indicator()
data["BB_width"] = bb.bollinger_wband()
# ββ 6. Simple Percentage Return ββββββββββββββββββββββββββββββββββ
data["Return"] = data["Adj Close"].pct_change()
# ββ 7. Log Return ββββββββββββββββββββββββββββββββββββββββββββββββ
# ln(P_t / P_{t-1}) β better normality, additive across periods
data["LogReturn"] = np.log(
data["Adj Close"] / data["Adj Close"].shift(1)
)
# ββ 8. Historical Realized Volatility (annualised) βββββββββββββββ
# 20-day rolling Ο of log returns Γ β252
data["RealizedVol"] = (
data["LogReturn"].rolling(window=20).std() * np.sqrt(252)
)
# ββ 9. Distance from EMA200 (regime indicator) βββββββββββββββββββ
# Positive β price above trend; negative β below trend
data["DistEMA200"] = (data["Adj Close"] - data["EMA200"]) / data["EMA200"]
# ββ 10. Normalised Average True Range ββββββββββββββββββββββββββββ
# ATR(14) / Close β dimensionless intraday volatility measure
atr = ta.volatility.AverageTrueRange(
high=data["High"],
low=data["Low"],
close=data["Close"],
window=14,
fillna=False,
)
data["ATR"] = atr.average_true_range()
data["ATR_norm"] = data["ATR"] / data["Close"]
# ββ 11. Cyclical Date Features (from DatetimeIndex) ββββββββββββββ
# Extracted BEFORE reset_index(drop=True) to retain timestamp info.
# Sine/cosine encoding avoids discontinuities at day/month boundaries.
if isinstance(data.index, pd.DatetimeIndex):
dow = data.index.dayofweek.astype(float) # 0=Mon β¦ 6=Sun
month = data.index.month.astype(float) # 1 β¦ 12
data["DayOfWeek_sin"] = np.sin(2.0 * np.pi * dow / 7.0)
data["DayOfWeek_cos"] = np.cos(2.0 * np.pi * dow / 7.0)
data["Month_sin"] = np.sin(2.0 * np.pi * month / 12.0)
data["Month_cos"] = np.cos(2.0 * np.pi * month / 12.0)
elif "Date" in data.columns:
dt = pd.to_datetime(data["Date"])
dow = dt.dt.dayofweek.astype(float)
month = dt.dt.month.astype(float)
data["DayOfWeek_sin"] = np.sin(2.0 * np.pi * dow / 7.0)
data["DayOfWeek_cos"] = np.cos(2.0 * np.pi * dow / 7.0)
data["Month_sin"] = np.sin(2.0 * np.pi * month / 12.0)
data["Month_cos"] = np.cos(2.0 * np.pi * month / 12.0)
else:
# Fallback: zero-fill if no datetime information available
logger.warning(
"No DatetimeIndex or Date column found β "
"cyclical date features set to 0."
)
for col in ["DayOfWeek_sin", "DayOfWeek_cos", "Month_sin", "Month_cos"]:
data[col] = 0.0
# ββ 12. Clean warm-up NaN rows (long-window indicators: EMA200, RealizedVol)
clean_data = data.dropna().reset_index(drop=True)
# Drop the intermediate ATR column (ATR_norm is the normalised form used)
if "ATR" in clean_data.columns:
clean_data = clean_data.drop(columns=["ATR"])
logger.info(
f"Feature Engineering Complete. Shape after cleaning: {clean_data.shape}"
)
return clean_data
except Exception as exc:
logger.error(f"Error computing technical indicators: {exc}")
return df
# ββ Standalone Verification Block ββββββββββββββββββββββββββββββββββββββββββββ
if __name__ == "__main__":
from src.data.fetcher import MarketDataFetcher
raw_df = MarketDataFetcher.fetch_data(ticker="BTC-USD", period="2y", interval="1d")
processed_df = TechnicalIndicatorEngine.add_all_indicators(raw_df)
NEW_COLS = [
"LogReturn", "RealizedVol", "DistEMA200",
"ATR_norm",
"DayOfWeek_sin", "DayOfWeek_cos",
"Month_sin", "Month_cos",
]
print("\n--- Technical Indicator Engine Verification ---")
print(f"Total columns : {len(processed_df.columns)}")
print(f"Total rows : {len(processed_df)}")
print(f"New feature cols : {NEW_COLS}")
print()
print(processed_df[["Adj Close", "LogReturn", "RealizedVol", "DistEMA200", "ATR_norm"]].tail(3))
print()
print(processed_df[["DayOfWeek_sin", "DayOfWeek_cos", "Month_sin", "Month_cos"]].tail(3))
# Sanity: all new cols present and non-null in last row
for col in NEW_COLS:
assert col in processed_df.columns, f"MISSING: {col}"
assert not pd.isna(processed_df[col].iloc[-1]), f"NaN in last row: {col}"
print("\nAll new feature sanity checks PASSED.") |