""" AHAD QUANT — Feature Engineering — Forex Edition (matches train_ai_v2.py exactly) Generates 62 features from 1h OHLCV candle data for Forex pairs. Key differences vs crypto version: - funding_rate → swap_rate (overnight rollover expressed as decimal) - oi_change_pct → tick_volume_change_pct (Forex has no OI; tick volume used) - oi_value → tick_volume_value - btc_correlation_20 → base_pair_correlation_20 (correlation with dominant pair, e.g. EURUSD) - fear_greed_index → sentiment_index (any 0-100 sentiment proxy; defaults to 50) - liquidation_pressure → momentum_pressure (candle momentum proxy) - funding_oi_weighted → swap_volume_weighted All formulas are identical — only semantic meaning of inputs changes. Pass swap_rates via funding_map={t_sec: rate}, sentiment via fear_greed_map. If not provided, defaults produce neutral 0.0 values (safe for both training and live). CRITICAL: Do NOT change any formula without updating train.py simultaneously. """ import numpy as np from datetime import datetime # ─── Feature names (must match FEATURE_NAMES_1H in train_ai_v2.py) ────────── FEATURE_NAMES = [ 'swap_rate', 'swap_rate_delta_1h', 'swap_rate_delta_4h', 'swap_rate_delta_8h', 'tick_volume_change_pct', 'price_change_pct', 'volume', 'volume_ma_ratio', 'volume_spike_3x', 'price_vs_vwap', 'high_low_range', 'close_vs_open', 'rsi_14', 'price_ma_20_ratio', 'volume_change_pct', 'tick_volume_value', 'swap_abs', 'candle_body_ratio', 'atr_14', 'base_pair_correlation_20', 'hour_of_day', 'day_of_week', 'dist_from_24h_high', 'dist_from_24h_low', 'consecutive_green', 'consecutive_red', 'rsi_divergence', 'price_momentum_3', 'price_momentum_7', 'volume_momentum_3', 'ema_12_26_diff', # Order flow features 'order_flow_ratio', # bid/ask imbalance proxy from candle structure 'momentum_pressure', # taker momentum proxy (long-short imbalance estimate) 'rsi_4h', # RSI calculated on 4h aggregated candles 'obi_proxy', # candle-based order book imbalance proxy 'cvd_5', # cumulative volume delta 5 candles 'cvd_20', # cumulative volume delta 20 candles 'obi_momentum', # OBI change over 3 candles # V7.1 features 'sentiment_index', # market sentiment proxy (0-100, normalized to 0-1) 'swap_volume_weighted', # swap rate * tick_volume magnitude # V8.3 advanced features 'price_skewness_24', # rolling skewness of returns 'price_kurtosis_24', # rolling kurtosis (fat tail detection) 'linear_trend_slope_24', # OLS slope normalized by price 'close_stddev_ratio', # short/long volatility ratio 'area_ratio_24', # price position vs recent history [-1,+1] 'range_position_48', # where in 48h range (0=bottom, 1=top) 'atr_ratio_6_48', # short/long ATR ratio (breakout detector) 'volume_price_trend', # cumulative volume-adjusted price changes 'first_loc_max_24', # where did max occur in last 24 candles 'longest_strike_below', # consecutive candles below rolling mean # V9.0 multi-timeframe features 'sma_4h_ratio', # price vs 4h SMA ratio 'momentum_4h', # 4h momentum (aggregated) 'daily_return', # daily aggregated return 'daily_range', # daily aggregated high-low range 'daily_volume_ratio', # daily volume vs 5-day avg 'weekly_momentum', # 7-day momentum # V10.0 L2 orderbook proxies 'book_imbalance_proxy', # (close - low) / (high - low) — buy pressure proxy 'depth_ratio_proxy', # volume / avg_volume_20 — order flow depth proxy 'large_order_proxy', # max(high-low) / ATR — large order detection 'book_pressure_proxy', # (close - open) / (high - low) — candle body directional pressure 'spread_proxy', # (high - low) / close * 100 — spread proxy from range 'flow_intensity', # abs(close - open) * volume — price impact * volume ] NUM_FEATURES = 62 assert len(FEATURE_NAMES) == NUM_FEATURES, f"Expected {NUM_FEATURES} features, got {len(FEATURE_NAMES)}" # ─── Helpers (identical formulas to train_ai_v2.py) ───────────────────────── def _compute_rsi(closes, period=14): """RSI using simple rolling mean of gains/losses — matches train_ai_v2.py compute_rsi().""" rsi = np.full_like(closes, 50.0, dtype=float) pc = np.diff(closes, prepend=closes[0]) for i in range(period, len(closes)): g = np.maximum(pc[i - period + 1:i + 1], 0).mean() lo = np.maximum(-pc[i - period + 1:i + 1], 0).mean() rsi[i] = 100.0 - (100.0 / (1.0 + g / lo)) if lo != 0 else 100.0 return rsi def _compute_rsi_4h(closes_1h): """RSI on 4h timeframe by aggregating 1h candles — matches train_ai_v2.py compute_rsi_4h().""" n = len(closes_1h) closes_4h = [] for i in range(3, n, 4): closes_4h.append(closes_1h[i]) if len(closes_4h) < 20: return np.full(n, 50.0) closes_4h = np.array(closes_4h) rsi_4h = _compute_rsi(closes_4h, period=14) result = np.full(n, 50.0) for idx_4h in range(len(rsi_4h)): start_1h = idx_4h * 4 end_1h = min(start_1h + 4, n) for j in range(start_1h, end_1h): result[j] = rsi_4h[idx_4h] return result def _compute_atr(highs, lows, closes, period=14): """ATR using simple moving average — matches train_ai_v2.py compute_atr().""" atr = np.zeros_like(closes, dtype=float) for i in range(1, len(closes)): tr = max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]), abs(lows[i] - closes[i - 1])) atr[i] = tr result = np.zeros_like(closes, dtype=float) for i in range(period, len(closes)): result[i] = atr[i - period:i].mean() return result def _compute_ema(data, period): """EMA — matches train_ai_v2.py compute_ema().""" ema = np.zeros_like(data, dtype=float) ema[0] = data[0] k = 2.0 / (period + 1) for i in range(1, len(data)): ema[i] = data[i] * k + ema[i - 1] * (1 - k) return ema def _count_consecutive(closes): """Consecutive green/red candles — matches train_ai_v2.py count_consecutive().""" green = np.zeros(len(closes), dtype=float) red = np.zeros(len(closes), dtype=float) for i in range(1, len(closes)): if closes[i] > closes[i - 1]: green[i] = green[i - 1] + 1 red[i] = 0 elif closes[i] < closes[i - 1]: red[i] = red[i - 1] + 1 green[i] = 0 return green, red def _compute_multi_timeframe(opens, highs, lows, closes, volumes): """Multi-timeframe features — matches train_ai_v2.py compute_multi_timeframe().""" n = len(closes) sma_4h_ratio = np.zeros(n, dtype=float) momentum_4h = np.zeros(n, dtype=float) daily_return = np.zeros(n, dtype=float) daily_range = np.zeros(n, dtype=float) daily_volume_ratio = np.zeros(n, dtype=float) weekly_momentum = np.zeros(n, dtype=float) # Build 4h closes closes_4h = [] for i in range(3, n, 4): closes_4h.append(closes[i]) closes_4h = np.array(closes_4h) if closes_4h else np.array([0.0]) # 4h SMA with period 5 (=20h lookback) sma_4h_arr = np.zeros(len(closes_4h), dtype=float) for j in range(5, len(closes_4h)): sma_4h_arr[j] = closes_4h[j - 5:j].mean() # 4h momentum mom_4h_arr = np.zeros(len(closes_4h), dtype=float) for j in range(1, len(closes_4h)): if closes_4h[j - 1] > 0: mom_4h_arr[j] = (closes_4h[j] - closes_4h[j - 1]) / closes_4h[j - 1] * 100 # Expand 4h features back to 1h for idx_4h in range(len(closes_4h)): start_1h = idx_4h * 4 end_1h = min(start_1h + 4, n) for j in range(start_1h, end_1h): if sma_4h_arr[idx_4h] > 0: sma_4h_ratio[j] = (closes[j] - sma_4h_arr[idx_4h]) / sma_4h_arr[idx_4h] * 100 momentum_4h[j] = mom_4h_arr[idx_4h] # Daily features (aggregate every 24 candles) for i in range(24, n): if closes[i - 24] > 0: daily_return[i] = (closes[i] - closes[i - 24]) / closes[i - 24] * 100 dh = highs[i - 24:i].max() dl = lows[i - 24:i].min() if closes[i] > 0: daily_range[i] = (dh - dl) / closes[i] * 100 vol_24h = volumes[i - 24:i].sum() if i >= 144: vol_5d_avg = volumes[i - 144:i - 24].sum() / 5.0 daily_volume_ratio[i] = vol_24h / vol_5d_avg if vol_5d_avg > 0 else 1.0 else: daily_volume_ratio[i] = 1.0 # Weekly momentum for i in range(168, n): if closes[i - 168] > 0: weekly_momentum[i] = (closes[i] - closes[i - 168]) / closes[i - 168] * 100 return sma_4h_ratio, momentum_4h, daily_return, daily_range, daily_volume_ratio, weekly_momentum # ─── Main function ───────────────────────────────────────────────────────── def build_features( candles: list[dict], *, btc_closes: np.ndarray | None = None, funding_map: dict | None = None, taker_buy_volumes: np.ndarray | None = None, taker_ratio_map: dict | None = None, fear_greed_map: dict | None = None, ) -> np.ndarray: """ Build a (N, 62) feature matrix from OHLCV candle data. Matches train_ai_v2.py build_features_1h() exactly. Parameters ---------- candles : list[dict] List of candle dicts with keys: t, o, h, l, c, v OR Binance kline arrays [open_time, open, high, low, close, volume, ...] btc_closes : np.ndarray | None Dominant pair close prices aligned to same timestamps (e.g. EURUSD). Used to compute base_pair_correlation_20. If None, correlation is set to 0. funding_map : dict | None Dict mapping timestamp_sec -> swap_rate (overnight rollover as decimal). If None, all swap features default to 0.0. taker_buy_volumes : np.ndarray | None Taker buy base volume per candle. If None, order_flow_ratio defaults to 0.5 (neutral). taker_ratio_map : dict | None Dict mapping timestamp_ms -> taker long/short ratio. If None, liquidation_pressure defaults to 0.0 (neutral). fear_greed_map : dict | None Dict mapping timestamp_sec (hour-aligned) -> sentiment_index (0-100). If None, sentiment_index defaults to 0.5 (neutral, i.e. 50/100). Returns ------- np.ndarray of shape (N, 62) Feature matrix. First ~26 rows may have incomplete lookback; the caller should use only rows from index 26+ onward. """ # ── Parse candles ── times, opens, highs, lows, closes, volumes = [], [], [], [], [], [] taker_buy_vols_parsed = [] for c in candles: if isinstance(c, list): # Binance kline array format times.append(c[0]) opens.append(float(c[1])) highs.append(float(c[2])) lows.append(float(c[3])) closes.append(float(c[4])) volumes.append(float(c[5])) taker_buy_vols_parsed.append(float(c[9]) if len(c) > 9 else 0.0) else: t = c.get('t', c.get('T', 0)) if isinstance(t, str): t = int(t) times.append(t) opens.append(float(c.get('o', 0))) highs.append(float(c.get('h', 0))) lows.append(float(c.get('l', 0))) closes.append(float(c.get('c', 0))) volumes.append(float(c.get('v', c.get('vlm', 0)))) taker_buy_vols_parsed.append(0.0) c_times = np.array(times, dtype=float) opens = np.array(opens, dtype=float) highs = np.array(highs, dtype=float) lows = np.array(lows, dtype=float) closes = np.array(closes, dtype=float) volumes = np.array(volumes, dtype=float) n = len(closes) # Taker buy volumes (for order_flow_ratio) if taker_buy_volumes is not None: tbv = taker_buy_volumes else: tbv = np.array(taker_buy_vols_parsed, dtype=float) # ── Order flow ratio: taker_buy_volume / total_volume ── order_flow = np.zeros(n, dtype=float) for i in range(n): if volumes[i] > 0 and tbv[i] > 0: order_flow[i] = tbv[i] / volumes[i] else: order_flow[i] = 0.5 # neutral default # ── Funding map (default empty) ── # funding_map maps timestamp_sec -> funding_rate # If not provided, all funding features will be 0.0 if funding_map is None: funding_map = {} # ── Taker ratio map (default empty) ── if taker_ratio_map is None: taker_ratio_map = {} # ── Fear & Greed map (default empty) ── # If not provided, fear_greed_index defaults to 50 (neutral) -> 0.5 normalized if fear_greed_map is None: fear_greed_map = {} # ── Compute indicators ── rsi = _compute_rsi(closes) rsi_4h = _compute_rsi_4h(closes) atr = _compute_atr(highs, lows, closes) ema12 = _compute_ema(closes, 12) ema26 = _compute_ema(closes, 26) cons_green, cons_red = _count_consecutive(closes) # Multi-timeframe features mtf_sma_4h_ratio, mtf_momentum_4h, mtf_daily_return, mtf_daily_range, \ mtf_daily_volume_ratio, mtf_weekly_momentum = _compute_multi_timeframe( opens, highs, lows, closes, volumes) # ── Rolling indicators (20-period) ── vol_ma = np.ones(n, dtype=float) price_ma = np.full(n, np.nan, dtype=float) vwap = np.full(n, np.nan, dtype=float) typical = (highs + lows + closes) / 3.0 for i in range(20, n): vm = volumes[i - 20:i].mean() vol_ma[i] = vm if vm > 0 else 1.0 price_ma[i] = closes[i - 20:i].mean() vs = volumes[i - 20:i].sum() vwap[i] = (typical[i - 20:i] * volumes[i - 20:i]).sum() / vs if vs > 0 else closes[i] # ── BTC closes for correlation ── # btc_closes should be aligned array same length as closes, or None # If it's a dict (timestamp -> price), caller should convert before passing # ── Build feature rows (matches train_ai_v2.py build_features_1h loop) ── feature_matrix = np.zeros((n, NUM_FEATURES), dtype=np.float64) for i in range(n): t_ms = int(c_times[i]) t_sec = int(t_ms / 1000) if t_ms > 1e12 else int(t_ms) # --- Funding rates and deltas --- fr = funding_map.get(t_sec, 0.0) fr_1h_ago = funding_map.get(t_sec - 3600, 0.0) fr_4h_ago = funding_map.get(t_sec - 14400, 0.0) fr_8h_ago = funding_map.get(t_sec - 28800, 0.0) fr_delta_1h = fr - fr_1h_ago fr_delta_4h = fr - fr_4h_ago fr_delta_8h = fr - fr_8h_ago # --- OI proxy --- oi_cur = volumes[i] * closes[i] oi_prev = volumes[i - 1] * closes[i - 1] if (i > 0 and closes[i - 1] > 0) else 1.0 oi_chg = ((oi_cur - oi_prev) / oi_prev * 100) if oi_prev > 0 else 0.0 # --- Price change % --- price_chg = ((closes[i] - closes[i - 1]) / closes[i - 1] * 100) if (i > 0 and closes[i - 1] > 0) else 0.0 # --- Volume / MA ratio --- vmr = volumes[i] / vol_ma[i] if vol_ma[i] > 0 else 1.0 # --- Volume spike 3x --- vol_spike = 1.0 if vmr > 3.0 else 0.0 # --- Price vs VWAP (rolling 20-period) --- pvw = ((closes[i] - vwap[i]) / vwap[i] * 100) if (not np.isnan(vwap[i]) and vwap[i] > 0) else 0.0 # --- High-Low range --- hlr = ((highs[i] - lows[i]) / closes[i] * 100) if closes[i] > 0 else 0.0 # --- Close vs Open --- cvo = ((closes[i] - opens[i]) / opens[i] * 100) if opens[i] > 0 else 0.0 # --- Price vs MA(20) --- pma = ((closes[i] - price_ma[i]) / price_ma[i] * 100) if (not np.isnan(price_ma[i]) and price_ma[i] > 0) else 0.0 # --- Volume change % --- vol_chg = ((volumes[i] - volumes[i - 1]) / volumes[i - 1] * 100) if (i > 0 and volumes[i - 1] > 0) else 0.0 # --- Candle body ratio --- cbr = abs(closes[i] - opens[i]) / (highs[i] - lows[i]) if (highs[i] - lows[i]) > 0 else 0.0 # --- ATR normalized --- atr_norm = (atr[i] / closes[i] * 100) if closes[i] > 0 else 0.0 # --- BTC correlation --- btc_corr = 0.0 if btc_closes is not None and len(btc_closes) == n and i >= 20: coin_rets = [] btc_rets = [] for j in range(i - 19, i + 1): if j > 0 and closes[j - 1] > 0: coin_rets.append((closes[j] - closes[j - 1]) / closes[j - 1]) bc = btc_closes[j] bc_prev = btc_closes[j - 1] if bc_prev > 0 and bc > 0: btc_rets.append((bc - bc_prev) / bc_prev) else: btc_rets.append(0) if len(coin_rets) >= 10: cr = np.array(coin_rets) br = np.array(btc_rets) if cr.std() > 0 and br.std() > 0: btc_corr = np.corrcoef(cr, br)[0, 1] if np.isnan(btc_corr): btc_corr = 0.0 # --- Time features --- hour = 0 dow = 0 try: dt = datetime.utcfromtimestamp(t_sec) hour = dt.hour dow = dt.weekday() except Exception: pass # --- Distance from 24h high/low --- window_24 = min(24, i) high_24 = highs[i - window_24:i + 1].max() low_24 = lows[i - window_24:i + 1].min() dist_high = ((closes[i] - high_24) / high_24 * 100) if high_24 > 0 else 0.0 dist_low = ((closes[i] - low_24) / low_24 * 100) if low_24 > 0 else 0.0 # --- Consecutive green/red --- # Already computed vectorized above # --- RSI divergence --- rsi_div = 0.0 if i >= 5: price_dir = closes[i] - closes[i - 5] rsi_dir = rsi[i] - rsi[i - 5] if price_dir > 0 and rsi_dir < -3: rsi_div = -1.0 elif price_dir < 0 and rsi_dir > 3: rsi_div = 1.0 # --- Momentum --- mom_3 = ((closes[i] - closes[i - 3]) / closes[i - 3] * 100) if (i >= 3 and closes[i - 3] > 0) else 0.0 mom_7 = ((closes[i] - closes[i - 7]) / closes[i - 7] * 100) if (i >= 7 and closes[i - 7] > 0) else 0.0 vol_mom_3 = ((volumes[i] - volumes[i - 3]) / volumes[i - 3] * 100) if (i >= 3 and volumes[i - 3] > 0) else 0.0 ema_diff = ((ema12[i] - ema26[i]) / ema26[i] * 100) if ema26[i] > 0 else 0.0 # --- Order flow ratio --- oflow = order_flow[i] # --- Liquidation pressure --- # taker_ratio_map: timestamp_ms -> buySellRatio # Centered so 0 = neutral (ratio - 1.0) liq_pressure = taker_ratio_map.get(t_ms, taker_ratio_map.get(t_sec * 1000, 1.0)) liq_pressure = liq_pressure - 1.0 # --- RSI 4h --- rsi4h = rsi_4h[i] # --- OBI proxy --- obi = (closes[i] - lows[i]) / (highs[i] - lows[i]) if (highs[i] - lows[i]) > 0 else 0.5 # --- CVD proxy --- cvd_5_val = sum( volumes[max(i - 4, 0):i + 1] * ( 2 * ((closes[max(i - 4, 0):i + 1] - lows[max(i - 4, 0):i + 1]) / np.maximum(highs[max(i - 4, 0):i + 1] - lows[max(i - 4, 0):i + 1], 1e-10)) - 1 ) ) cvd_20_val = sum( volumes[max(i - 19, 0):i + 1] * ( 2 * ((closes[max(i - 19, 0):i + 1] - lows[max(i - 19, 0):i + 1]) / np.maximum(highs[max(i - 19, 0):i + 1] - lows[max(i - 19, 0):i + 1], 1e-10)) - 1 ) ) avg_vol = volumes[max(i - 19, 0):i + 1].mean() cvd_5_norm = cvd_5_val / avg_vol if avg_vol > 0 else 0 cvd_20_norm = cvd_20_val / avg_vol if avg_vol > 0 else 0 # --- OBI momentum --- obi_prev = (closes[max(i - 3, 0)] - lows[max(i - 3, 0)]) / (highs[max(i - 3, 0)] - lows[max(i - 3, 0)]) \ if (highs[max(i - 3, 0)] - lows[max(i - 3, 0)]) > 0 else 0.5 obi_momentum = obi - obi_prev # --- Fear & Greed Index --- # Defaults to 50 (neutral) if not available, normalized to 0-1 fg_val = 50 if fear_greed_map: t_hour = (t_sec // 3600) * 3600 fg_val = fear_greed_map.get(t_hour, fear_greed_map.get(t_hour - 3600, 50)) fear_greed_norm = fg_val / 100.0 # --- Funding * OI weighted --- funding_oi = fr * (oi_cur / 1e6) if oi_cur > 0 else 0.0 # --- V8.3 Advanced Features --- # 1. Rolling skewness of returns (24 candles) if i >= 24: rets_24 = np.diff(closes[i - 24:i + 1]) / closes[i - 24:i] _mean = rets_24.mean() _std = rets_24.std() price_skew = float(((rets_24 - _mean) ** 3).mean() / (_std ** 3)) if _std > 1e-10 else 0.0 else: price_skew = 0.0 # 2. Rolling kurtosis of returns (24 candles) if i >= 24: price_kurt = float(((rets_24 - _mean) ** 4).mean() / (_std ** 4) - 3.0) if _std > 1e-10 else 0.0 else: price_kurt = 0.0 # 3. Linear trend slope (24 candles, normalized) if i >= 24: _x = np.arange(24) _y = closes[i - 23:i + 1] _slope = np.polyfit(_x, _y, 1)[0] trend_slope = _slope / closes[i] * 100 if closes[i] > 0 else 0.0 else: trend_slope = 0.0 # 4. Short/long volatility ratio if i >= 48: rets_s = np.diff(closes[i - 6:i + 1]) / closes[i - 6:i] rets_l = np.diff(closes[i - 48:i + 1]) / closes[i - 48:i] std_s = rets_s.std() std_l = rets_l.std() stddev_ratio = std_s / std_l if std_l > 1e-10 else 1.0 else: stddev_ratio = 1.0 # 5. Area ratio (price position vs recent 24 candles) if i >= 24: _window = closes[i - 23:i + 1] _level = closes[i] _diff = _window - _level _total = np.sum(np.abs(_diff)) area_ratio = (2 * np.sum(np.maximum(_diff, 0)) / _total - 1) if _total > 0 else 0.0 else: area_ratio = 0.0 # 6. Range position (where in 48h range, 0=bottom 1=top) if i >= 48: h48 = highs[i - 48:i + 1].max() l48 = lows[i - 48:i + 1].min() range_pos = (closes[i] - l48) / (h48 - l48) if (h48 - l48) > 0 else 0.5 else: range_pos = 0.5 # 7. ATR ratio short/long (breakout detector) if i >= 48: atr_short = np.mean([ max(highs[j] - lows[j], abs(highs[j] - closes[j - 1]), abs(lows[j] - closes[j - 1])) for j in range(max(1, i - 5), i + 1) ]) atr_long = np.mean([ max(highs[j] - lows[j], abs(highs[j] - closes[j - 1]), abs(lows[j] - closes[j - 1])) for j in range(max(1, i - 47), i + 1) ]) atr_ratio = atr_short / atr_long if atr_long > 0 else 1.0 else: atr_ratio = 1.0 # 8. Volume-price trend (normalized) if i >= 24: vpt = sum( volumes[j] * ((closes[j] - closes[j - 1]) / closes[j - 1]) for j in range(max(1, i - 23), i + 1) if closes[j - 1] > 0 ) vpt_norm = vpt / avg_vol if avg_vol > 0 else 0.0 else: vpt_norm = 0.0 # 9. First location of max in 24 candles (0=start, 1=end) if i >= 24: first_loc_max = float(np.argmax(closes[i - 23:i + 1])) / 23.0 else: first_loc_max = 0.5 # 10. Longest strike below mean (24 candles) if i >= 24: _win = closes[i - 23:i + 1] _wmean = _win.mean() _below = _win < _wmean max_run = 0 cur_run = 0 for b in _below: if b: cur_run += 1 max_run = max(max_run, cur_run) else: cur_run = 0 longest_below = max_run / 24.0 else: longest_below = 0.0 # --- V10.0 L2 Orderbook Proxies --- book_imb = (closes[i] - lows[i]) / (highs[i] - lows[i]) if (highs[i] - lows[i]) > 0 else 0.5 depth_ratio = volumes[i] / vol_ma[i] if vol_ma[i] > 0 else 1.0 large_order = (highs[i] - lows[i]) / (atr[i] if atr[i] > 0 else 1e-10) book_pressure = (closes[i] - opens[i]) / (highs[i] - lows[i] + 0.001) if (highs[i] - lows[i]) > 0 else 0.0 spread_proxy = (highs[i] - lows[i]) / closes[i] * 100 if closes[i] > 0 else 0.0 flow_intensity = abs(closes[i] - opens[i]) * volumes[i] # ── Assemble row (order MUST match FEATURE_NAMES) ── feature_matrix[i] = [ fr, fr_delta_1h, fr_delta_4h, fr_delta_8h, oi_chg, price_chg, volumes[i], vmr, vol_spike, pvw, hlr, cvo, rsi[i], pma, vol_chg, oi_cur, abs(fr), cbr, atr_norm, btc_corr, hour, dow, dist_high, dist_low, cons_green[i], cons_red[i], rsi_div, mom_3, mom_7, vol_mom_3, ema_diff, # Order flow oflow, liq_pressure, rsi4h, obi, cvd_5_norm, cvd_20_norm, obi_momentum, fear_greed_norm, funding_oi, # V8.3 advanced price_skew, price_kurt, trend_slope, stddev_ratio, area_ratio, range_pos, atr_ratio, vpt_norm, first_loc_max, longest_below, # V9.0 multi-timeframe mtf_sma_4h_ratio[i], mtf_momentum_4h[i], mtf_daily_return[i], mtf_daily_range[i], mtf_daily_volume_ratio[i], mtf_weekly_momentum[i], # V10.0 L2 orderbook proxies book_imb, depth_ratio, large_order, book_pressure, spread_proxy, flow_intensity, ] # Replace any NaN with 0.0 for safety np.nan_to_num(feature_matrix, copy=False, nan=0.0, posinf=0.0, neginf=0.0) return feature_matrix