Spaces:
Running
Running
File size: 14,986 Bytes
457ced4 cdead71 457ced4 cdead71 0cadca0 457ced4 bf4ba2a 0feab1a bf4ba2a 0feab1a bf4ba2a 0feab1a bb9657d b250588 bb9657d b250588 bb9657d e610a2f bb9657d e610a2f bb9657d b250588 bb9657d 44f651e cbde68f 3404590 457ced4 cdead71 0cadca0 457ced4 bf4ba2a 0feab1a 44f651e cbde68f 3404590 457ced4 | 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 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | """
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
|