File size: 10,441 Bytes
d53dc44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""

ml/features.py β€” Multi-timeframe feature engineering for ML models.



Produces a flat feature vector per bar from multiple timeframes.

All features are causal (no lookahead) and normalised for tree models.

"""
from __future__ import annotations

import numpy as np
import pandas as pd

from signals.technical import (
    compute_rsi, compute_macd, compute_bollinger_bands,
    compute_vwap, compute_atr, compute_ema,
)


# ═══════════════════════════════════════════════════════════════════════════════
#  SINGLE-TIMEFRAME FEATURES
# ═══════════════════════════════════════════════════════════════════════════════

def _safe_pct(a: pd.Series, b: pd.Series) -> pd.Series:
    """(a - b) / b, with NaN-safe division."""
    return (a - b) / b.replace(0, np.nan)


def compute_bar_features(df: pd.DataFrame, prefix: str = "") -> pd.DataFrame:
    """Compute technical features from OHLCV bars.



    Returns DataFrame with ~30 features, aligned to df.index.

    All features use only past data (no lookahead).

    """
    close = df["close"].astype(float)
    high = df["high"].astype(float)
    low = df["low"].astype(float)
    volume = df["volume"].astype(float)

    feats = pd.DataFrame(index=df.index)
    p = prefix

    # ── Returns (multiple lookbacks) ──
    for lb in [1, 3, 5, 10, 20]:
        feats[f"{p}ret_{lb}"] = close.pct_change(lb)

    # ── Volatility ──
    atr = compute_atr(df, 14)
    feats[f"{p}atr_pct"] = atr / close  # ATR as % of price
    feats[f"{p}atr_ratio"] = atr / atr.rolling(50).mean()  # current vs avg ATR
    feats[f"{p}realised_vol_20"] = close.pct_change().rolling(20).std()

    # ── RSI ──
    rsi = compute_rsi(close, 14)
    feats[f"{p}rsi"] = rsi / 100.0  # normalise to [0, 1]
    feats[f"{p}rsi_slope"] = (rsi - rsi.shift(3)) / 100.0

    # ── MACD ──
    macd_line, macd_signal, macd_hist = compute_macd(close)
    feats[f"{p}macd_hist_norm"] = macd_hist / close  # normalised
    feats[f"{p}macd_crossover"] = np.sign(macd_hist) - np.sign(macd_hist.shift(1))

    # ── Bollinger Bands ──
    bb_upper, bb_middle, bb_lower = compute_bollinger_bands(close)
    bb_range = (bb_upper - bb_lower).replace(0, np.nan)
    feats[f"{p}bb_pct_b"] = (close - bb_lower) / bb_range  # %B
    feats[f"{p}bb_width"] = bb_range / bb_middle  # bandwidth

    # ── VWAP distance (skip if not intraday) ──
    try:
        vwap = compute_vwap(df)
        feats[f"{p}vwap_dist"] = _safe_pct(close, vwap)
    except Exception:
        feats[f"{p}vwap_dist"] = 0.0

    # ── EMAs ──
    ema20 = compute_ema(close, 20)
    ema50 = compute_ema(close, 50)
    feats[f"{p}ema20_dist"] = _safe_pct(close, ema20)
    feats[f"{p}ema50_dist"] = _safe_pct(close, ema50)
    feats[f"{p}ema_spread"] = _safe_pct(ema20, ema50)

    # ── Volume features ──
    vol_ma = volume.rolling(50).mean()
    feats[f"{p}vol_ratio"] = volume / vol_ma.replace(0, np.nan)
    feats[f"{p}vol_trend"] = (volume.rolling(5).mean()
                              / volume.rolling(20).mean().replace(0, np.nan))

    # ── Candle patterns (normalised) ──
    bar_range = (high - low).replace(0, np.nan)
    feats[f"{p}body_pct"] = (close - df["open"].astype(float)) / bar_range
    feats[f"{p}upper_wick"] = (high - close.clip(upper=high)) / bar_range
    feats[f"{p}lower_wick"] = (close.clip(lower=low) - low) / bar_range

    return feats


# ═══════════════════════════════════════════════════════════════════════════════
#  DAILY REGIME FEATURES (for regime classifier)
# ═══════════════════════════════════════════════════════════════════════════════

def compute_regime_features(daily_df: pd.DataFrame) -> pd.DataFrame:
    """Compute features specifically designed for regime classification.



    Returns DataFrame indexed by daily_df.index with ~20 features.

    """
    close = daily_df["close"].astype(float)
    high = daily_df["high"].astype(float)
    low = daily_df["low"].astype(float)
    volume = daily_df["volume"].astype(float)

    feats = pd.DataFrame(index=daily_df.index)

    # ── Trend features ──
    for w in [5, 10, 20, 50]:
        feats[f"ret_{w}d"] = close.pct_change(w)
        ema = compute_ema(close, w)
        feats[f"ema{w}_dist"] = _safe_pct(close, ema)

    # ── Trend slope (linear regression slope over window) ──
    for w in [10, 20]:
        x = np.arange(w, dtype=float)
        x_mean = x.mean()
        x_var = ((x - x_mean) ** 2).sum()
        slopes = close.rolling(w).apply(
            lambda y: np.sum((x - x_mean) * (y - y.mean())) / x_var
            if len(y) == w else 0.0, raw=True
        )
        feats[f"slope_{w}d"] = slopes / close  # normalised

    # ── Volatility ──
    atr = compute_atr(daily_df, 14)
    feats["atr_pct"] = atr / close
    feats["vol_20d"] = close.pct_change().rolling(20).std()
    feats["vol_ratio"] = (close.pct_change().rolling(5).std()
                          / close.pct_change().rolling(20).std().replace(0, np.nan))

    # ── Momentum ──
    rsi = compute_rsi(close, 14)
    feats["rsi"] = rsi / 100.0
    _, _, macd_hist = compute_macd(close)
    feats["macd_hist_norm"] = macd_hist / close

    # ── Volume ──
    vol_ma = volume.rolling(20).mean()
    feats["vol_ratio_20d"] = volume / vol_ma.replace(0, np.nan)

    # ── Range ──
    feats["daily_range_pct"] = (high - low) / close

    return feats


# ═══════════════════════════════════════════════════════════════════════════════
#  MULTI-TIMEFRAME FEATURE MERGE
# ═══════════════════════════════════════════════════════════════════════════════

def merge_multi_tf_features(

    intraday_df: pd.DataFrame,

    daily_df: pd.DataFrame | None = None,

    hourly_df: pd.DataFrame | None = None,

) -> pd.DataFrame:
    """Build feature matrix by computing features on each timeframe

    and forward-filling higher-TF features onto intraday bars.



    Returns: DataFrame indexed like intraday_df with all features.

    """
    # Intraday features
    feats = compute_bar_features(intraday_df, prefix="intra_")

    # Daily features β€” forward-fill onto intraday
    if daily_df is not None and len(daily_df) >= 50:
        daily_feats = compute_bar_features(daily_df, prefix="daily_")
        # Map daily features to intraday by date
        if hasattr(daily_feats.index, 'date'):
            daily_feats.index = daily_feats.index.date
        daily_feats = daily_feats[~daily_feats.index.duplicated(keep='last')]

        intra_dates = (intraday_df.index.date if hasattr(intraday_df.index, 'date')
                       else pd.to_datetime(intraday_df.index).date)
        for col in daily_feats.columns:
            feats[col] = daily_feats[col].reindex(intra_dates).values

    # Hourly features β€” forward-fill onto intraday
    if hourly_df is not None and len(hourly_df) >= 50:
        hourly_feats = compute_bar_features(hourly_df, prefix="hourly_")
        # Reindex to intraday via asof merge (latest hourly bar <= intraday time)
        hourly_feats = hourly_feats.sort_index()
        feats_sorted = feats.sort_index()
        for col in hourly_feats.columns:
            merged = pd.merge_asof(
                feats_sorted[[feats_sorted.columns[0]]],
                hourly_feats[[col]],
                left_index=True, right_index=True,
                direction="backward",
            )
            feats[col] = merged[col].values

    return feats


# ═══════════════════════════════════════════════════════════════════════════════
#  LABEL GENERATION (for supervised learning)
# ═══════════════════════════════════════════════════════════════════════════════

def make_return_labels(

    df: pd.DataFrame,

    horizon: int = 6,

    threshold_pct: float = 0.15,

) -> pd.Series:
    """Create classification labels from forward returns.



    Args:

        horizon: bars to look forward (6 bars = 30min on 5m TF)

        threshold_pct: minimum move to count as UP/DOWN (in %)



    Returns: Series of {1 = up, -1 = down, 0 = flat} aligned to df.index.

    """
    close = df["close"].astype(float)
    fwd_ret = close.shift(-horizon) / close - 1.0
    fwd_pct = fwd_ret * 100

    labels = pd.Series(0, index=df.index, dtype=int)
    labels[fwd_pct > threshold_pct] = 1
    labels[fwd_pct < -threshold_pct] = -1
    return labels


def make_regime_labels(daily_df: pd.DataFrame, window: int = 20) -> pd.Series:
    """Create regime labels for daily bars (0=SIDEWAYS, 1=BULL, 2=BEAR).



    Uses same logic as rule-based detector for ground truth.

    """
    close = daily_df["close"].astype(float)
    rolling_ret = close.pct_change(window).fillna(0) * 100

    labels = pd.Series(0, index=daily_df.index, dtype=int)  # SIDEWAYS
    labels[rolling_ret > 2.0] = 1   # BULL
    labels[rolling_ret < -2.0] = 2  # BEAR
    return labels


REGIME_INT_TO_STR = {0: "SIDEWAYS", 1: "BULL", 2: "BEAR"}
REGIME_STR_TO_INT = {"SIDEWAYS": 0, "BULL": 1, "BEAR": 2}