| """ |
| Technical Features β Compute all standard TA indicators using pandas-ta. |
| Returns a feature DataFrame that can be fed into the signal engine. |
| """ |
| import logging |
|
|
| import pandas as pd |
| import pandas_ta as ta |
|
|
| from config import SIGNAL_PARAMS as SP |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def compute_technical_features(df: pd.DataFrame, ticker: str = "") -> pd.DataFrame: |
| """ |
| Compute comprehensive technical indicators for a single ticker's OHLCV DataFrame. |
| |
| Args: |
| df: DataFrame with columns Open, High, Low, Close, Volume (DatetimeIndex) |
| ticker: For logging |
| |
| Returns: |
| DataFrame with original OHLCV + all computed features |
| """ |
| if df.empty or len(df) < 50: |
| logger.warning(f"{ticker}: Insufficient data ({len(df)} rows) for technical features") |
| return df |
|
|
| features = df.copy() |
|
|
| |
| features[f"sma_{SP['sma_short']}"] = ta.sma(features["Close"], length=SP["sma_short"]) |
| features[f"sma_{SP['sma_medium']}"] = ta.sma(features["Close"], length=SP["sma_medium"]) |
| features[f"sma_{SP['sma_long']}"] = ta.sma(features["Close"], length=SP["sma_long"]) |
| features[f"ema_{SP['ema_fast']}"] = ta.ema(features["Close"], length=SP["ema_fast"]) |
| features[f"ema_{SP['ema_slow']}"] = ta.ema(features["Close"], length=SP["ema_slow"]) |
|
|
| |
| macd = ta.macd(features["Close"], fast=SP["macd_fast"], slow=SP["macd_slow"], signal=SP["macd_signal"]) |
| if macd is not None: |
| features = pd.concat([features, macd], axis=1) |
|
|
| |
| adx = ta.adx(features["High"], features["Low"], features["Close"], length=14) |
| if adx is not None: |
| features = pd.concat([features, adx], axis=1) |
|
|
| |
| features["rsi_14"] = ta.rsi(features["Close"], length=14) |
| features["rsi_7"] = ta.rsi(features["Close"], length=7) |
|
|
| stoch = ta.stoch(features["High"], features["Low"], features["Close"]) |
| if stoch is not None: |
| features = pd.concat([features, stoch], axis=1) |
|
|
| features["willr"] = ta.willr(features["High"], features["Low"], features["Close"]) |
| features["roc_10"] = ta.roc(features["Close"], length=10) |
| features["roc_20"] = ta.roc(features["Close"], length=20) |
| features["cci_20"] = ta.cci(features["High"], features["Low"], features["Close"], length=20) |
|
|
| |
| bb = ta.bbands(features["Close"], length=SP["bb_period"], std=SP["bb_std"]) |
| if bb is not None: |
| features = pd.concat([features, bb], axis=1) |
|
|
| features["atr_14"] = ta.atr(features["High"], features["Low"], features["Close"], length=SP["atr_period"]) |
| features["atr_pct"] = (features["atr_14"] / features["Close"]) * 100 |
|
|
| kc = ta.kc(features["High"], features["Low"], features["Close"]) |
| if kc is not None: |
| features = pd.concat([features, kc], axis=1) |
|
|
| |
| features["obv"] = ta.obv(features["Close"], features["Volume"]) |
| features[f"vol_sma_{SP['volume_sma_period']}"] = ta.sma(features["Volume"], length=SP["volume_sma_period"]) |
| features["vol_ratio"] = features["Volume"] / features[f"vol_sma_{SP['volume_sma_period']}"] |
|
|
| |
| features["price_vs_sma20"] = (features["Close"] / features[f"sma_{SP['sma_short']}"]) - 1 |
| features["price_vs_sma50"] = (features["Close"] / features[f"sma_{SP['sma_medium']}"]) - 1 |
| features["price_vs_sma200"] = (features["Close"] / features[f"sma_{SP['sma_long']}"]) - 1 |
|
|
| |
| sma_s = features[f"sma_{SP['sma_short']}"] |
| sma_m = features[f"sma_{SP['sma_medium']}"] |
| features["golden_cross"] = ((sma_s > sma_m) & (sma_s.shift(1) <= sma_m.shift(1))).astype(int) |
| features["death_cross"] = ((sma_s < sma_m) & (sma_s.shift(1) >= sma_m.shift(1))).astype(int) |
|
|
| ema_f = features[f"ema_{SP['ema_fast']}"] |
| ema_sl = features[f"ema_{SP['ema_slow']}"] |
| features["ema_bullish_cross"] = ((ema_f > ema_sl) & (ema_f.shift(1) <= ema_sl.shift(1))).astype(int) |
|
|
| |
| lookback = SP["breakout_lookback"] |
| features["resistance"] = features["High"].rolling(lookback).max() |
| features["support"] = features["Low"].rolling(lookback).min() |
| features["at_resistance"] = (features["Close"] >= features["resistance"] * 0.98).astype(int) |
| features["at_support"] = (features["Close"] <= features["support"] * 1.02).astype(int) |
|
|
| |
| bb_upper = features.get(f"BBU_{SP['bb_period']}_{SP['bb_std']}") |
| bb_lower = features.get(f"BBL_{SP['bb_period']}_{SP['bb_std']}") |
| if bb_upper is not None and bb_lower is not None: |
| bb_width = bb_upper - bb_lower |
| features["bb_pct"] = (features["Close"] - bb_lower) / bb_width.replace(0, float("nan")) |
| features["bb_squeeze"] = (bb_width / features["Close"] * 100) |
|
|
| logger.info(f"{ticker}: Computed {len(features.columns) - len(df.columns)} technical features") |
| return features |
|
|
|
|
| def get_latest_features(features_df: pd.DataFrame) -> dict: |
| """Extract the latest row of features as a flat dict (for signal engine).""" |
| if features_df.empty: |
| return {} |
| last = features_df.iloc[-1] |
| return {k: (round(float(v), 4) if pd.notna(v) else None) for k, v in last.items()} |
|
|