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