641 lines
26 KiB
Python
641 lines
26 KiB
Python
"""
|
|
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
|