Spaces:
Running
Running
| """ | |
| Technical indicator calculations for stock OHLCV DataFrames. | |
| All functions operate in-place and return the enriched DataFrame. | |
| """ | |
| import logging | |
| import numpy as np | |
| import pandas as pd | |
| logger = logging.getLogger(__name__) | |
| def add_moving_averages(df: pd.DataFrame) -> pd.DataFrame: | |
| """Add MA5/10/20/60/120/240 columns based on closing price.""" | |
| for period in (5, 10, 20, 60, 120, 240): | |
| df[f"ma{period}"] = df["close"].rolling(window=period, min_periods=period).mean() | |
| return df | |
| def add_ma_cross_signals(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Add golden/death cross boolean features for MA5xMA20 and MA20xMA60. | |
| Golden cross = short MA crosses above long MA (1 on crossover day, else 0). | |
| Death cross = short MA crosses below long MA. | |
| """ | |
| for short, long in ((5, 20), (20, 60)): | |
| ma_s = df.get(f"ma{short}") | |
| ma_l = df.get(f"ma{long}") | |
| if ma_s is None or ma_l is None: | |
| df[f"golden_cross_{short}_{long}"] = np.nan | |
| df[f"death_cross_{short}_{long}"] = np.nan | |
| continue | |
| above = (ma_s > ma_l).fillna(False).astype(bool) | |
| prev_above = above.shift(1).fillna(False).astype(bool) | |
| df[f"golden_cross_{short}_{long}"] = (above & ~prev_above).astype(float) | |
| df[f"death_cross_{short}_{long}"] = (~above & prev_above).astype(float) | |
| return df | |
| def add_ma_bull_alignment(df: pd.DataFrame) -> pd.DataFrame: | |
| mas = [df.get(f"ma{p}") for p in (5, 10, 20, 60)] | |
| if any(m is None for m in mas): | |
| df["ma_bull_alignment"] = 0.0 | |
| df["ma_bear_alignment"] = 0.0 | |
| df["ma_alignment_days"] = 0.0 | |
| return df | |
| ma5, ma10, ma20, ma60 = mas | |
| bull = ((ma5 > ma10) & (ma10 > ma20) & (ma20 > ma60)).astype(float) | |
| bear = ((ma5 < ma10) & (ma10 < ma20) & (ma20 < ma60)).astype(float) | |
| df["ma_bull_alignment"] = bull | |
| df["ma_bear_alignment"] = bear | |
| state = bull - bear # 1, -1, or 0 | |
| group = state.ne(state.shift()).cumsum() | |
| days = state.groupby(group).cumcount().add(1).astype(float) | |
| df["ma_alignment_days"] = days * state | |
| return df | |
| def add_bias_rates(df: pd.DataFrame) -> pd.DataFrame: | |
| close = df.get("close", pd.Series(dtype=float)) | |
| for period in (20, 60): | |
| ma = df.get(f"ma{period}") | |
| if ma is not None: | |
| df[f"bias_{period}"] = ((close - ma) / ma.replace(0, np.nan)).fillna(0.0) | |
| return df | |
| def add_rsi(df: pd.DataFrame, period: int = 14) -> pd.DataFrame: | |
| """ | |
| Add RSI (Relative Strength Index) column. | |
| Uses Wilder's smoothing (EWM with alpha = 1/period). | |
| """ | |
| delta = df["close"].diff() | |
| gain = delta.clip(lower=0) | |
| loss = -delta.clip(upper=0) | |
| # Wilder's smoothing: adjust=False, com=period-1 → alpha = 1/(period) | |
| avg_gain = gain.ewm(com=period - 1, adjust=False, min_periods=period).mean() | |
| avg_loss = loss.ewm(com=period - 1, adjust=False, min_periods=period).mean() | |
| rs = avg_gain / avg_loss.replace(0, np.nan) | |
| df["rsi"] = 100 - (100 / (1 + rs)) | |
| return df | |
| def add_macd(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Add MACD line, signal line (9-EMA of MACD), and histogram. | |
| Standard parameters: fast=12, slow=26, signal=9. | |
| """ | |
| ema12 = df["close"].ewm(span=12, adjust=False).mean() | |
| ema26 = df["close"].ewm(span=26, adjust=False).mean() | |
| df["macd"] = ema12 - ema26 | |
| df["macd_signal"] = df["macd"].ewm(span=9, adjust=False).mean() | |
| df["macd_hist"] = df["macd"] - df["macd_signal"] | |
| return df | |
| def add_bollinger_bands( | |
| df: pd.DataFrame, period: int = 20, std: float = 2.0 | |
| ) -> pd.DataFrame: | |
| """ | |
| Add Bollinger Bands: middle (SMA), upper (SMA + 2σ), lower (SMA - 2σ). | |
| Also computes %B = (close - lower) / (upper - lower). | |
| """ | |
| sma = df["close"].rolling(window=period, min_periods=period).mean() | |
| rolling_std = df["close"].rolling(window=period, min_periods=period).std() | |
| df["bb_middle"] = sma | |
| df["bb_upper"] = sma + std * rolling_std | |
| df["bb_lower"] = sma - std * rolling_std | |
| band_width = df["bb_upper"] - df["bb_lower"] | |
| df["bb_pct_b"] = (df["close"] - df["bb_lower"]) / band_width.replace(0, np.nan) | |
| return df | |
| def add_kd(df: pd.DataFrame, period: int = 9) -> pd.DataFrame: | |
| """ | |
| Add Stochastic Oscillator K and D lines. | |
| K = 3-period SMA of raw %K (popular Taiwan variant uses 3,3 smoothing). | |
| D = 3-period SMA of K. | |
| Raw %K = (close - lowest_low(period)) / (highest_high(period) - lowest_low(period)) * 100 | |
| """ | |
| low_min = df["low"].rolling(window=period, min_periods=period).min() | |
| high_max = df["high"].rolling(window=period, min_periods=period).max() | |
| range_ = (high_max - low_min).replace(0, np.nan) | |
| raw_k = (df["close"] - low_min) / range_ * 100 | |
| # Smooth K and D with 3-period SMA (Taiwan convention) | |
| df["k"] = raw_k.rolling(window=3, min_periods=1).mean() | |
| df["d"] = df["k"].rolling(window=3, min_periods=1).mean() | |
| return df | |
| def add_volume_indicators(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Add VolumeMA20 and volume_ratio (volume / VolumeMA20). | |
| """ | |
| df["volume_ma20"] = df["volume"].rolling(window=20, min_periods=20).mean() | |
| df["volume_ratio"] = df["volume"] / df["volume_ma20"].replace(0, np.nan) | |
| return df | |
| def add_atr(df: pd.DataFrame, period: int = 14) -> pd.DataFrame: | |
| """ | |
| Add ATR (Average True Range) and atr_ratio (ATR / close). | |
| True Range = max(H-L, |H-Prev_Close|, |L-Prev_Close|) — accounts for gaps. | |
| atr_ratio normalises ATR to price level. | |
| """ | |
| prev_close = df["close"].shift(1) | |
| tr1 = df["high"] - df["low"] | |
| tr2 = (df["high"] - prev_close).abs() | |
| tr3 = (df["low"] - prev_close).abs() | |
| tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) | |
| df["atr"] = tr.rolling(window=period, min_periods=period).mean() | |
| df["atr_ratio"] = df["atr"] / df["close"].replace(0, np.nan) | |
| return df | |
| def add_obv(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Add OBV (On-Balance Volume) and obv_trend (5-day normalised slope). | |
| OBV = cumulative sum of volume * sign(close change). | |
| obv_trend = 5-day simple diff of OBV, divided by mean OBV to normalise. | |
| """ | |
| close_change = df["close"].diff() | |
| direction = np.sign(close_change).fillna(0) | |
| df["obv"] = (df["volume"] * direction).cumsum() | |
| # 5-day slope: simple difference normalised by rolling mean absolute OBV | |
| obv_diff = df["obv"].diff(5) | |
| obv_scale = df["obv"].abs().rolling(window=20, min_periods=5).mean().replace(0, np.nan) | |
| df["obv_trend"] = obv_diff / obv_scale | |
| return df | |
| def add_volatility_regime(df: pd.DataFrame, period: int = 20) -> pd.DataFrame: | |
| """Add rolling return volatility as a market regime indicator.""" | |
| returns = df["close"].pct_change() | |
| df["volatility_20d"] = returns.rolling(window=period, min_periods=period).std() | |
| return df | |
| def add_cross_asset_tw( | |
| df: pd.DataFrame, | |
| taiex_close: "pd.Series | None", | |
| usdtwd_close: "pd.Series | None", | |
| sox_close: "pd.Series | None" = None, | |
| tnx_close: "pd.Series | None" = None, | |
| ) -> pd.DataFrame: | |
| """ | |
| Add Taiwan cross-asset features to df: | |
| - taiex_return_5d : TAIEX 5-day return | |
| - taiex_ma20_ratio : TAIEX close / 20-day MA | |
| - usdtwd_return_5d : USD/TWD 5-day change | |
| - sox_ret_1d : SOX prior-day return (non-leaking; US closes before TW opens) | |
| - sox_ret_5d : SOX 5-day return | |
| - sox_ma20_ratio : SOX close / SOX 20-day MA | |
| - tnx_level : US 10Y yield level (%) | |
| - tnx_change_5d : 5-day change in yield | |
| All Series are indexed by 'YYYY-MM-DD' strings from fetch_cross_asset_tw(). | |
| Pass None for any series to get NaN columns (neutral-imputed later). | |
| """ | |
| date_col = df["date"].astype(str) if "date" in df.columns else None | |
| nan_col = pd.Series(np.nan, index=df.index) | |
| if taiex_close is not None and date_col is not None: | |
| taiex_dict = taiex_close.to_dict() | |
| aligned = date_col.map(taiex_dict).astype(float) | |
| df["taiex_return_5d"] = aligned.pct_change(5) | |
| df["taiex_return_20d"] = aligned.pct_change(20) | |
| taiex_ma20 = aligned.rolling(20, min_periods=20).mean() | |
| taiex_ma200 = aligned.rolling(200, min_periods=100).mean() | |
| df["taiex_ma20_ratio"] = aligned / taiex_ma20.replace(0, np.nan) | |
| df["taiex_ma200_ratio"] = aligned / taiex_ma200.replace(0, np.nan) | |
| else: | |
| df["taiex_return_5d"] = nan_col | |
| df["taiex_return_20d"] = nan_col | |
| df["taiex_ma20_ratio"] = nan_col | |
| df["taiex_ma200_ratio"] = nan_col | |
| if usdtwd_close is not None and date_col is not None: | |
| fx_dict = usdtwd_close.to_dict() | |
| aligned_fx = date_col.map(fx_dict).astype(float) | |
| df["usdtwd_return_5d"] = aligned_fx.pct_change(5) | |
| else: | |
| df["usdtwd_return_5d"] = nan_col | |
| if sox_close is not None and date_col is not None: | |
| sox_dict = sox_close.to_dict() | |
| aligned_sox = date_col.map(sox_dict).astype(float) | |
| df["sox_ret_1d"] = aligned_sox.pct_change(1).shift(1) # prior-day return, non-leaking | |
| df["sox_ret_5d"] = aligned_sox.pct_change(5) | |
| sox_ma20 = aligned_sox.rolling(20, min_periods=20).mean() | |
| df["sox_ma20_ratio"] = aligned_sox / sox_ma20.replace(0, np.nan) | |
| else: | |
| df["sox_ret_1d"] = nan_col | |
| df["sox_ret_5d"] = nan_col | |
| df["sox_ma20_ratio"] = nan_col | |
| if tnx_close is not None and date_col is not None: | |
| tnx_dict = tnx_close.to_dict() | |
| aligned_tnx = date_col.map(tnx_dict).astype(float) | |
| df["tnx_level"] = aligned_tnx | |
| df["tnx_change_5d"] = aligned_tnx.diff(5) | |
| else: | |
| df["tnx_level"] = nan_col | |
| df["tnx_change_5d"] = nan_col | |
| return df | |
| def add_amihud_illiquidity(df: pd.DataFrame) -> pd.DataFrame: | |
| close = df.get("close", pd.Series(dtype=float)) | |
| volume = df.get("volume", pd.Series(dtype=float)) | |
| ret = close.pct_change().abs() | |
| dollar_vol = (close * volume).replace(0, np.nan) | |
| # Raw Amihud: |return| / dollar_volume (in billions to normalize scale) | |
| amihud_raw = (ret / (dollar_vol / 1e9)).fillna(0.0) | |
| # Rolling 20-day average — stable estimate | |
| df["amihud_illiquidity"] = amihud_raw.rolling(20, min_periods=5).mean().fillna(0.0) | |
| # Z-score vs 60-day rolling mean/std — relative illiquidity | |
| roll_mean = df["amihud_illiquidity"].rolling(60, min_periods=20).mean() | |
| roll_std = df["amihud_illiquidity"].rolling(60, min_periods=20).std().replace(0, np.nan) | |
| df["amihud_zscore"] = ((df["amihud_illiquidity"] - roll_mean) / roll_std).fillna(0.0).clip(-3, 3) | |
| return df | |
| def add_fracdiff_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Add fractionally differenced log(close) features at d=0.35 and d=0.40. | |
| Standard AFML sign convention: w_0=1, w_k = -w_{k-1}*(d-k+1)/k | |
| (d=1 recovers first difference). Applied to log(close) for scale-invariance. | |
| Output is z-scored with a 60-day rolling window to remove price-level trend | |
| and make the feature stationary and cross-stock comparable. | |
| window=30 lags; first window-1 rows padded with 0.0. | |
| Pure numpy — no external fracdiff package required. | |
| """ | |
| try: | |
| log_close = np.log(df["close"].values.astype(float)) | |
| n = len(log_close) | |
| window = 30 | |
| zscore_window = 60 | |
| for d, col in [(0.35, "fracdiff_close_35"), (0.40, "fracdiff_close_40")]: | |
| weights = np.empty(window) | |
| weights[0] = 1.0 | |
| for k in range(1, window): | |
| weights[k] = -weights[k - 1] * (d - k + 1) / k | |
| full = np.convolve(log_close, weights) | |
| result = full[0:n].copy() | |
| result[:window - 1] = 0.0 | |
| # Rolling z-score to remove price-level trend | |
| series = pd.Series(result) | |
| roll_mean = series.rolling(zscore_window, min_periods=zscore_window).mean() | |
| roll_std = series.rolling(zscore_window, min_periods=zscore_window).std().replace(0, np.nan) | |
| zscored = ((series - roll_mean) / roll_std).fillna(0.0).clip(-3, 3).values | |
| df[col] = zscored | |
| except Exception: | |
| df["fracdiff_close_35"] = 0.0 | |
| df["fracdiff_close_40"] = 0.0 | |
| return df | |
| def add_calendar_features(df: pd.DataFrame) -> pd.DataFrame: | |
| dates = pd.to_datetime(df["date"]) | |
| dow = dates.dt.dayofweek # 0=Mon … 4=Fri | |
| month = dates.dt.month | |
| dom = dates.dt.day | |
| df["dow_sin"] = np.sin(2 * np.pi * dow / 5) | |
| df["dow_cos"] = np.cos(2 * np.pi * dow / 5) | |
| df["month_sin"] = np.sin(2 * np.pi * month / 12) | |
| df["month_cos"] = np.cos(2 * np.pi * month / 12) | |
| df["is_options_expiry_week"] = ((dom >= 15) & (dom <= 21)).astype(int) | |
| df["is_earnings_season"] = month.isin([3, 4, 8, 9]).astype(int) | |
| return df | |
| def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Apply all technical indicators to the DataFrame. | |
| Operates on a copy to avoid mutating the caller's data. | |
| Returns the enriched DataFrame (NaN rows are NOT dropped here — | |
| let the predictor handle that). | |
| """ | |
| df = df.copy() | |
| try: | |
| df = add_moving_averages(df) | |
| except Exception as exc: | |
| logger.warning("add_moving_averages failed: %s", exc) | |
| try: | |
| df = add_ma_cross_signals(df) | |
| except Exception as exc: | |
| logger.warning("add_ma_cross_signals failed: %s", exc) | |
| try: | |
| df = add_ma_bull_alignment(df) | |
| except Exception as exc: | |
| logger.warning("add_ma_bull_alignment failed: %s", exc) | |
| try: | |
| df = add_bias_rates(df) | |
| except Exception as exc: | |
| logger.warning("add_bias_rates failed: %s", exc) | |
| try: | |
| df = add_rsi(df) | |
| except Exception as exc: | |
| logger.warning("add_rsi failed: %s", exc) | |
| try: | |
| df = add_macd(df) | |
| except Exception as exc: | |
| logger.warning("add_macd failed: %s", exc) | |
| try: | |
| df = add_bollinger_bands(df) | |
| except Exception as exc: | |
| logger.warning("add_bollinger_bands failed: %s", exc) | |
| try: | |
| df = add_kd(df) | |
| except Exception as exc: | |
| logger.warning("add_kd failed: %s", exc) | |
| try: | |
| df = add_volume_indicators(df) | |
| except Exception as exc: | |
| logger.warning("add_volume_indicators failed: %s", exc) | |
| try: | |
| df = add_atr(df) | |
| except Exception as exc: | |
| logger.warning("add_atr failed: %s", exc) | |
| try: | |
| df = add_obv(df) | |
| except Exception as exc: | |
| logger.warning("add_obv failed: %s", exc) | |
| try: | |
| df = add_volatility_regime(df) | |
| except Exception as exc: | |
| logger.warning("add_volatility_regime failed: %s", exc) | |
| try: | |
| df = add_amihud_illiquidity(df) | |
| except Exception as exc: | |
| logger.warning("add_amihud_illiquidity failed: %s", exc) | |
| df = add_fracdiff_features(df) | |
| try: | |
| df = add_calendar_features(df) | |
| except Exception as exc: | |
| logger.warning("add_calendar_features failed: %s", exc) | |
| return df | |