Download src/features/htf_features.py from chen470/drl-trading-bot-dev2: direct link, hf CLI and curl.
- Browser
- Download file 58 kB
-
https://huggingface.co/spaces/chen470/drl-trading-bot-dev2/resolve/main/src/features/htf_features.py
- Command line
-
hf download hf://spaces/chen470/drl-trading-bot-dev2/src/features/htf_features.py
-
curl -L -o htf_features.py https://huggingface.co/spaces/chen470/drl-trading-bot-dev2/resolve/main/src/features/htf_features.py
58 kB
| """ | |
| Multi-Timeframe Hierarchical Feature Engine (HTFFeatureEngine) | |
| Computes 117 observation dimensions across 4 timeframes for a DRL trading agent: | |
| - 1D (20 features): macro trend & regime | |
| - 4H (25 features): swing structure & Smart Money Concepts | |
| - 1H (30 features): momentum & divergence | |
| - 15M (35 features): micro entry triggers & candle patterns | |
| - alignment (4 features): cross-TF cascade hierarchy signals | |
| - position state (3 features): handled externally by env | |
| Also provides HTFDataAligner for resampling a 15M DataFrame to all required TFs. | |
| """ | |
| import logging | |
| from typing import Dict, List, Optional, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Constants | |
| # --------------------------------------------------------------------------- | |
| MIN_BARS = 30 # minimum bars required before computing features | |
| N_1D = 20 | |
| N_4H = 25 | |
| N_1H = 30 | |
| N_15M = 35 | |
| N_ALIGN = 4 | |
| N_TOTAL = N_1D + N_4H + N_1H + N_15M + N_ALIGN # 114; env adds 3 for position | |
| # --------------------------------------------------------------------------- | |
| # Low-level helpers (pure numpy / pandas, no lookahead) | |
| # --------------------------------------------------------------------------- | |
| def _ema(series: np.ndarray, span: int) -> np.ndarray: | |
| """Exponential moving average via pandas EWM.""" | |
| return pd.Series(series).ewm(span=span, adjust=False).mean().values | |
| def _sma(series: np.ndarray, period: int) -> np.ndarray: | |
| return pd.Series(series).rolling(period).mean().values | |
| def _rsi(close: np.ndarray, period: int = 14) -> float: | |
| """Return RSI value at the last bar.""" | |
| if len(close) < period + 1: | |
| return 50.0 | |
| delta = np.diff(close) | |
| gain = np.where(delta > 0, delta, 0.0) | |
| loss = np.where(delta < 0, -delta, 0.0) | |
| avg_gain = pd.Series(gain).rolling(period).mean().values[-1] | |
| avg_loss = pd.Series(loss).rolling(period).mean().values[-1] | |
| if avg_loss < 1e-12: | |
| return 100.0 | |
| rs = avg_gain / avg_loss | |
| return 100.0 - 100.0 / (1.0 + rs) | |
| def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray: | |
| """Average True Range series.""" | |
| tr = np.maximum( | |
| high[1:] - low[1:], | |
| np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])), | |
| ) | |
| # Pad with first value so length matches close | |
| atr_series = pd.Series(tr).ewm(span=period, adjust=False).mean().values | |
| return np.concatenate([[atr_series[0]], atr_series]) | |
| def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> Tuple[float, float, float]: | |
| """Return (ADX, DI+, DI-) at last bar.""" | |
| n = len(close) | |
| if n < period + 2: | |
| return 20.0, 20.0, 20.0 | |
| # True Range | |
| tr = np.maximum( | |
| high[1:] - low[1:], | |
| np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])), | |
| ) | |
| # Directional movement | |
| up_move = np.diff(high) | |
| down_move = -np.diff(low) | |
| plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0) | |
| minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0) | |
| smooth_tr = pd.Series(tr).ewm(span=period, adjust=False).mean().values | |
| smooth_plus = pd.Series(plus_dm).ewm(span=period, adjust=False).mean().values | |
| smooth_minus = pd.Series(minus_dm).ewm(span=period, adjust=False).mean().values | |
| di_plus = 100.0 * smooth_plus[-1] / (smooth_tr[-1] + 1e-10) | |
| di_minus = 100.0 * smooth_minus[-1] / (smooth_tr[-1] + 1e-10) | |
| dx = 100.0 * abs(di_plus - di_minus) / (di_plus + di_minus + 1e-10) | |
| # Smooth DX to get ADX | |
| dx_series = 100.0 * np.abs( | |
| pd.Series(smooth_plus).values - pd.Series(smooth_minus).values | |
| ) / (pd.Series(smooth_plus).values + pd.Series(smooth_minus).values + 1e-10) | |
| adx_val = pd.Series(dx_series).ewm(span=period, adjust=False).mean().values[-1] | |
| return float(adx_val), float(di_plus), float(di_minus) | |
| def _macd(close: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9) -> Tuple[float, float, float]: | |
| """Return (macd_line, signal_line, histogram) at last bar.""" | |
| ema_fast = _ema(close, fast) | |
| ema_slow = _ema(close, slow) | |
| macd_line = ema_fast - ema_slow | |
| signal_line = pd.Series(macd_line).ewm(span=signal, adjust=False).mean().values | |
| hist = macd_line[-1] - signal_line[-1] | |
| return float(macd_line[-1]), float(signal_line[-1]), float(hist) | |
| def _stochastic(high: np.ndarray, low: np.ndarray, close: np.ndarray, k_period: int = 14, d_period: int = 3) -> Tuple[float, float]: | |
| """Return (K%, D%) at last bar, 0-100.""" | |
| if len(close) < k_period: | |
| return 50.0, 50.0 | |
| roll_high = pd.Series(high).rolling(k_period).max().values | |
| roll_low = pd.Series(low).rolling(k_period).min().values | |
| k = 100.0 * (close - roll_low) / (roll_high - roll_low + 1e-10) | |
| d = pd.Series(k).rolling(d_period).mean().values | |
| return float(k[-1]), float(d[-1]) | |
| def _bollinger(close: np.ndarray, period: int = 20, std_mult: float = 2.0) -> Tuple[float, float, float]: | |
| """Return (upper, mid, lower) at last bar.""" | |
| mid = np.nanmean(close[-period:]) | |
| std = np.nanstd(close[-period:]) | |
| return mid + std_mult * std, mid, mid - std_mult * std | |
| def _keltner(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20, mult: float = 1.5) -> Tuple[float, float, float]: | |
| """Return (upper, mid, lower) Keltner Channel at last bar.""" | |
| mid = _ema(close, period)[-1] | |
| atr_val = _atr(high, low, close, period)[-1] | |
| return mid + mult * atr_val, mid, mid - mult * atr_val | |
| def _compact_12_features(close: np.ndarray, high: np.ndarray, low: np.ndarray, | |
| volume: np.ndarray, opn: np.ndarray) -> np.ndarray: | |
| """ | |
| Compute the 12 compact structural features shared by 4H, 1H, and 15M methods. | |
| Identical logic to compute_mtf_features_at in mtf_env.py. | |
| """ | |
| feats = np.zeros(12, dtype=np.float32) | |
| # 1. EMA trend: EMA7 vs EMA14 | |
| ema7 = _ema(close, 7) | |
| ema14 = _ema(close, 14) | |
| feats[0] = np.clip((ema7[-1] - ema14[-1]) / (close[-1] * 0.01 + 1e-10), -3.0, 3.0) | |
| # 2. RSI 14 (normalised 0-1) | |
| feats[1] = _rsi(close, 14) / 100.0 | |
| # 3-5. Momentum (returns over 5, 10, 20 bars) | |
| for i, period in enumerate([5, 10, 20]): | |
| if len(close) > period: | |
| feats[2 + i] = np.clip((close[-1] / (close[-1 - period] + 1e-12) - 1.0) * 10.0, -3.0, 3.0) | |
| # 6. ATR ratio (current vs 50-bar avg) | |
| if len(close) > 2: | |
| tr = np.maximum( | |
| high[1:] - low[1:], | |
| np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])), | |
| ) | |
| atr_14 = pd.Series(tr).ewm(span=14, adjust=False).mean().values[-1] | |
| atr_avg = np.nanmean(tr[-50:]) if len(tr) >= 50 else np.nanmean(tr) | |
| feats[5] = np.clip(atr_14 / (atr_avg + 1e-10) - 1.0, -2.0, 2.0) | |
| # 7. Volume trend (current vs 20-bar average) | |
| vol_avg = np.nanmean(volume[-20:]) if len(volume) >= 20 else np.nanmean(volume) | |
| feats[6] = np.clip(volume[-1] / (vol_avg + 1e-10) - 1.0, -3.0, 3.0) | |
| # 8. MACD state (normalised histogram) | |
| _, _, hist = _macd(close) | |
| feats[7] = np.clip(hist / (close[-1] * 0.001 + 1e-10), -3.0, 3.0) | |
| # 9. Bollinger position | |
| bb_upper, bb_mid, bb_lower = _bollinger(close) | |
| bb_half_width = (bb_upper - bb_mid) + 1e-10 | |
| feats[8] = np.clip((close[-1] - bb_mid) / (2.0 * bb_half_width), -1.5, 1.5) | |
| # 10. Support/resistance position (within 20-bar range) | |
| recent_high = np.max(high[-20:]) | |
| recent_low = np.min(low[-20:]) | |
| price_range = recent_high - recent_low | |
| if price_range > 0: | |
| feats[9] = (close[-1] - recent_low) / price_range # 0-1 | |
| # 11. Candle body ratio (last bar) | |
| hl_range = high[-1] - low[-1] | |
| if hl_range > 0: | |
| feats[10] = (close[-1] - opn[-1]) / hl_range # -1 to 1 | |
| # 12. Trend strength (DI+ - DI- proxy) | |
| if len(close) >= 14: | |
| up_moves = np.diff(high[-15:]) | |
| dn_moves = -np.diff(low[-15:]) | |
| di_plus_raw = np.nanmean(np.where(up_moves > 0, up_moves, 0.0)[-14:]) | |
| di_minus_raw = np.nanmean(np.where(dn_moves > 0, dn_moves, 0.0)[-14:]) | |
| if (di_plus_raw + di_minus_raw) > 0: | |
| feats[11] = np.clip( | |
| (di_plus_raw - di_minus_raw) / (di_plus_raw + di_minus_raw), -1.0, 1.0 | |
| ) | |
| return feats | |
| # --------------------------------------------------------------------------- | |
| # HTFFeatureEngine | |
| # --------------------------------------------------------------------------- | |
| class HTFFeatureEngine: | |
| """ | |
| Hierarchical Multi-Timeframe Feature Engine. | |
| Computes features for four timeframes (1D, 4H, 1H, 15M) plus cross-TF | |
| alignment signals. Total: 114 features (env appends 3 position features). | |
| Usage | |
| ----- | |
| engine = HTFFeatureEngine() | |
| f_1d = engine.compute_1d_features(df_1d, at_idx) # 20 | |
| f_4h = engine.compute_4h_features(df_4h, at_idx) # 25 | |
| f_1h = engine.compute_1h_features(df_1h, at_idx) # 30 | |
| f_15m = engine.compute_15m_features(df_15m, at_idx) # 35 | |
| align = engine.compute_alignment(f_1d[19], f_4h[24], f_1h[29]) # 4 | |
| obs = np.concatenate([f_1d, f_4h, f_1h, f_15m, align]) # 114 | |
| """ | |
| def __init__(self, swing_lookback: int = 10, ob_proximity_pct: float = 0.005): | |
| """ | |
| Parameters | |
| ---------- | |
| swing_lookback : int | |
| Bars used to detect local swing highs/lows. | |
| ob_proximity_pct : float | |
| Fractional distance to consider price "near" an order block. | |
| """ | |
| self.swing_lookback = swing_lookback | |
| self.ob_proximity_pct = ob_proximity_pct | |
| # ------------------------------------------------------------------ | |
| # 1D Features (20) | |
| # ------------------------------------------------------------------ | |
| def compute_1d_features(self, df_1d: pd.DataFrame, at_idx: int) -> np.ndarray: | |
| """ | |
| Compute 20 daily (macro trend) features. | |
| Parameters | |
| ---------- | |
| df_1d : Full daily OHLCV DataFrame (DatetimeIndex). | |
| at_idx : Index of the current bar (inclusive). | |
| Returns | |
| ------- | |
| np.ndarray of shape (20,), dtype float32. | |
| """ | |
| out = np.zeros(N_1D, dtype=np.float32) | |
| d = df_1d.iloc[: at_idx + 1] | |
| if len(d) < MIN_BARS: | |
| return out | |
| close = d["close"].values.astype(np.float64) | |
| high = d["high"].values.astype(np.float64) | |
| low = d["low"].values.astype(np.float64) | |
| opn = d["open"].values.astype(np.float64) | |
| volume = d["volume"].values.astype(np.float64) | |
| try: | |
| # 1. sma_trend: (sma20 - sma50) / sma50 * 100 | |
| sma20 = _sma(close, 20) | |
| sma50 = _sma(close, 50) | |
| if not np.isnan(sma50[-1]) and sma50[-1] > 0: | |
| out[0] = np.clip((sma20[-1] - sma50[-1]) / sma50[-1] * 100.0, -3.0, 3.0) | |
| # 2. sma200_dist: (close - sma200) / sma200 * 100 | |
| sma200 = _sma(close, 200) | |
| if not np.isnan(sma200[-1]) and sma200[-1] > 0: | |
| out[1] = np.clip((close[-1] - sma200[-1]) / sma200[-1] * 100.0, -5.0, 5.0) | |
| # 3. adx: ADX(14) / 100 | |
| adx_val, di_plus, di_minus = _adx(high, low, close, 14) | |
| out[2] = np.clip(adx_val / 100.0, 0.0, 1.0) | |
| # 4. adx_trend: (DI+ - DI-) / (DI+ + DI-) | |
| denom = di_plus + di_minus + 1e-10 | |
| out[3] = np.clip((di_plus - di_minus) / denom, -1.0, 1.0) | |
| # 5. macro_rsi: RSI(21) / 100 | |
| out[4] = np.clip(_rsi(close, 21) / 100.0, 0.0, 1.0) | |
| # 6. monthly_return: pct change over 20 bars | |
| if len(close) > 20: | |
| out[5] = np.clip(close[-1] / (close[-21] + 1e-12) - 1.0, -0.5, 0.5) | |
| # 7. weekly_return: pct change over 5 bars | |
| if len(close) > 5: | |
| out[6] = np.clip(close[-1] / (close[-6] + 1e-12) - 1.0, -0.3, 0.3) | |
| # 8. vol_regime: current volume / 50-bar avg - 1 | |
| vol_avg_50 = np.nanmean(volume[-50:]) if len(volume) >= 50 else np.nanmean(volume) | |
| out[7] = np.clip(volume[-1] / (vol_avg_50 + 1e-10) - 1.0, -2.0, 2.0) | |
| # 9. atr_regime: current ATR / 50-bar avg ATR - 1 | |
| atr_series = _atr(high, low, close, 14) | |
| atr_avg_50 = np.nanmean(atr_series[-50:]) if len(atr_series) >= 50 else np.nanmean(atr_series) | |
| out[8] = np.clip(atr_series[-1] / (atr_avg_50 + 1e-10) - 1.0, -2.0, 2.0) | |
| # 10. higher_high: 1 if current high > prev 5-bar high else -1 | |
| prev5_high = np.max(high[-6:-1]) if len(high) >= 6 else high[-1] | |
| out[9] = 1.0 if high[-1] > prev5_high else -1.0 | |
| # 11. higher_low: 1 if current low > prev 5-bar low else -1 | |
| prev5_low = np.min(low[-6:-1]) if len(low) >= 6 else low[-1] | |
| out[10] = 1.0 if low[-1] > prev5_low else -1.0 | |
| # 12. price_vs_range: position within 20-bar range, centered on 0 | |
| lo20 = np.min(low[-20:]) | |
| hi20 = np.max(high[-20:]) | |
| rng20 = hi20 - lo20 | |
| if rng20 > 0: | |
| out[11] = np.clip((close[-1] - lo20) / rng20 - 0.5, -0.5, 0.5) | |
| # 13. ema_stack: sign of (ema9 - ema21), normalised to {-1, 0, 1} | |
| ema9 = _ema(close, 9) | |
| ema21 = _ema(close, 21) | |
| out[12] = float(np.sign(ema9[-1] - ema21[-1])) | |
| # 14. trend_maturity: bars since last EMA9/EMA21 crossover, normalised | |
| crossovers = np.where(np.diff(np.sign(ema9[1:] - ema21[1:])))[0] | |
| if len(crossovers) > 0: | |
| bars_since = len(close) - 1 - crossovers[-1] | |
| out[13] = np.clip(bars_since / 60.0, 0.0, 1.0) | |
| else: | |
| out[13] = 1.0 # very mature trend β no recent crossover | |
| # 15. ichimoku_cloud_pos: simplified cloud position | |
| # Tenkan: (9-bar high + 9-bar low) / 2 | |
| # Kijun : (26-bar high + 26-bar low) / 2 | |
| # Senkou A: (tenkan + kijun) / 2 (lagged 26) | |
| if len(close) >= 52: | |
| tenkan = (np.max(high[-9:]) + np.min(low[-9:])) / 2.0 | |
| kijun = (np.max(high[-26:]) + np.min(low[-26:])) / 2.0 | |
| senkou_a = (tenkan + kijun) / 2.0 | |
| senkou_b = (np.max(high[-52:]) + np.min(low[-52:])) / 2.0 | |
| cloud_top = max(senkou_a, senkou_b) | |
| cloud_bot = min(senkou_a, senkou_b) | |
| if close[-1] > cloud_top: | |
| out[14] = 0.0 # above cloud (bullish) | |
| elif close[-1] < cloud_bot: | |
| out[14] = -1.0 # below cloud (bearish) | |
| else: | |
| out[14] = -0.5 # inside cloud (neutral) | |
| # 16. wyckoff_phase: simplified phase detection | |
| out[15] = self._detect_wyckoff_phase_simple(close, volume, high, low) | |
| # 17. vol_expansion: volume > 1.5x 20-bar avg β mapped to {0, 1} | |
| vol_avg_20 = np.nanmean(volume[-20:]) if len(volume) >= 20 else np.nanmean(volume) | |
| out[16] = 1.0 if volume[-1] > 1.5 * vol_avg_20 else 0.0 | |
| # 18. doji_day: abs(close-open)/(high-low) < 0.1 | |
| body_frac = abs(close[-1] - opn[-1]) / (high[-1] - low[-1] + 1e-10) | |
| out[17] = 1.0 if body_frac < 0.1 else 0.0 | |
| # 19. range_compression: ATR / 20-day price range, 0-1 | |
| price_range_20 = np.max(high[-20:]) - np.min(low[-20:]) | |
| if price_range_20 > 0: | |
| out[18] = np.clip(atr_series[-1] / price_range_20, 0.0, 1.0) | |
| # 20. daily_trend_score: weighted macro bias [-1, 1] | |
| trend_direction = float(np.sign(sma20[-1] - sma50[-1])) if not np.isnan(sma50[-1]) else 0.0 | |
| rsi_bias = (out[4] - 0.5) * 2.0 # normalise RSI to [-1,1] | |
| adx_directional = out[3] | |
| hh_hl_score = (out[9] + out[10]) / 2.0 # avg of HH and HL signals | |
| out[19] = np.clip( | |
| 0.30 * trend_direction | |
| + 0.25 * adx_directional | |
| + 0.25 * rsi_bias | |
| + 0.20 * hh_hl_score, | |
| -1.0, | |
| 1.0, | |
| ) | |
| except Exception as exc: | |
| logger.debug("1D feature error: %s", exc) | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| # ------------------------------------------------------------------ | |
| # 4H Features (25) | |
| # ------------------------------------------------------------------ | |
| def compute_4h_features(self, df_4h: pd.DataFrame, at_idx: int) -> np.ndarray: | |
| """ | |
| Compute 25 4-hourly (swing structure) features. | |
| Features 1-12 follow the compact structure of compute_mtf_features_at. | |
| Features 13-25 cover Smart Money Concepts: BOS, CHOCH, Order Blocks, | |
| FVGs, swing distances, structure trend, liquidity pools. | |
| Parameters | |
| ---------- | |
| df_4h : Full 4H OHLCV DataFrame. | |
| at_idx : Current bar index (inclusive). | |
| Returns | |
| ------- | |
| np.ndarray of shape (25,), dtype float32. | |
| """ | |
| out = np.zeros(N_4H, dtype=np.float32) | |
| d = df_4h.iloc[: at_idx + 1] | |
| if len(d) < MIN_BARS: | |
| return out | |
| close = d["close"].values.astype(np.float64) | |
| high = d["high"].values.astype(np.float64) | |
| low = d["low"].values.astype(np.float64) | |
| volume = d["volume"].values.astype(np.float64) | |
| opn = d["open"].values.astype(np.float64) | |
| try: | |
| # Features 0-11: compact 12 structural features | |
| out[:12] = _compact_12_features(close, high, low, volume, opn) | |
| # ATR for normalisation | |
| atr_series = _atr(high, low, close, 14) | |
| atr_val = atr_series[-1] | |
| # Swing points | |
| sh_idx, sl_idx = self._find_swing_points(high, low) | |
| # 13. smc_bos: Break of Structure | |
| out[12] = self._detect_bos(close, high, low, sh_idx, sl_idx) | |
| # 14. smc_choch: Change of Character | |
| out[13] = self._detect_choch(close, high, low, sh_idx, sl_idx) | |
| # Order blocks | |
| bull_obs, bear_obs = self._find_order_blocks(opn, close, high, low) | |
| # 15. bullish_ob: near bullish OB within ob_proximity_pct | |
| out[14] = self._near_order_block(close[-1], bull_obs, self.ob_proximity_pct) | |
| # 16. bearish_ob: near bearish OB | |
| out[15] = self._near_order_block(close[-1], bear_obs, self.ob_proximity_pct) | |
| # FVGs (Fair Value Gaps) | |
| bull_fvgs, bear_fvgs = self._find_fvgs(high, low) | |
| # 17. bullish_fvg | |
| out[16] = 1.0 if len(bull_fvgs) > 0 else 0.0 | |
| # 18. bearish_fvg | |
| out[17] = 1.0 if len(bear_fvgs) > 0 else 0.0 | |
| # 19. swing_high_dist: normalised by ATR | |
| if len(sh_idx) > 0: | |
| last_sh = high[sh_idx[-1]] | |
| out[18] = np.clip((last_sh - close[-1]) / (atr_val + 1e-10), -5.0, 5.0) | |
| # else remains 0 | |
| # 20. swing_low_dist | |
| if len(sl_idx) > 0: | |
| last_sl = low[sl_idx[-1]] | |
| out[19] = np.clip((close[-1] - last_sl) / (atr_val + 1e-10), -5.0, 5.0) | |
| # 21. structure_trend: HH/HL=1, LH/LL=-1, ranging=0 | |
| out[20] = self._detect_structure_trend(high, low, sh_idx, sl_idx) | |
| # 22. ob_zone_strength: count of OBs in current zone (0-1) | |
| total_obs = len(bull_obs) + len(bear_obs) | |
| out[21] = np.clip(total_obs / 10.0, 0.0, 1.0) | |
| # 23. liquidity_above: swing high cluster count (normalised) | |
| out[22] = np.clip(len(sh_idx) / 10.0, 0.0, 1.0) | |
| # 24. liquidity_below: swing low cluster count (normalised) | |
| out[23] = np.clip(len(sl_idx) / 10.0, 0.0, 1.0) | |
| # 25. 4h_trend_score: weighted composite [-1,1] | |
| bos_bias = out[12] # -1/0/1 | |
| structure_bias = out[20] # -1/0/1 | |
| ema_bias = np.clip(out[0] / 3.0, -1.0, 1.0) | |
| rsi_bias = (out[1] - 0.5) * 2.0 | |
| fvg_bias = out[16] - out[17] # bull fvg - bear fvg | |
| out[24] = np.clip( | |
| 0.30 * bos_bias | |
| + 0.25 * structure_bias | |
| + 0.20 * ema_bias | |
| + 0.15 * rsi_bias | |
| + 0.10 * fvg_bias, | |
| -1.0, | |
| 1.0, | |
| ) | |
| except Exception as exc: | |
| logger.debug("4H feature error: %s", exc) | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| # ------------------------------------------------------------------ | |
| # 1H Features (30) | |
| # ------------------------------------------------------------------ | |
| def compute_1h_features(self, df_1h: pd.DataFrame, at_idx: int) -> np.ndarray: | |
| """ | |
| Compute 30 hourly (momentum) features. | |
| Features 1-12: compact structural features shared across TFs. | |
| Features 13-30: divergence, Wyckoff events, Stochastic, pivot levels, | |
| momentum composite. | |
| Parameters | |
| ---------- | |
| df_1h : Full 1H OHLCV DataFrame. | |
| at_idx : Current bar index (inclusive). | |
| Returns | |
| ------- | |
| np.ndarray of shape (30,), dtype float32. | |
| """ | |
| out = np.zeros(N_1H, dtype=np.float32) | |
| d = df_1h.iloc[: at_idx + 1] | |
| if len(d) < MIN_BARS: | |
| return out | |
| close = d["close"].values.astype(np.float64) | |
| high = d["high"].values.astype(np.float64) | |
| low = d["low"].values.astype(np.float64) | |
| volume = d["volume"].values.astype(np.float64) | |
| opn = d["open"].values.astype(np.float64) | |
| try: | |
| # 0-11: compact structural features | |
| out[:12] = _compact_12_features(close, high, low, volume, opn) | |
| # RSI series for divergence | |
| rsi_vals = self._rsi_series(close, 14) | |
| # 13. macd_divergence | |
| _, _, macd_hist_series = self._macd_hist_series(close) | |
| out[12] = self._detect_momentum_divergence(close, macd_hist_series, lookback=20) | |
| # 14. rsi_divergence: price lower low but RSI higher low | |
| out[13] = self._detect_rsi_divergence(close, rsi_vals, lookback=20) | |
| # 15. volume_climax: volume > 3x 20-bar avg | |
| vol_avg_20 = np.nanmean(volume[-20:]) if len(volume) >= 20 else np.nanmean(volume) | |
| out[14] = 1.0 if volume[-1] > 3.0 * vol_avg_20 else 0.0 | |
| # Wyckoff events | |
| spring, upthrust, climax = self._detect_wyckoff_events(close, high, low, volume) | |
| # 16. wyckoff_spring | |
| out[15] = 1.0 if spring else 0.0 | |
| # 17. wyckoff_upthrust | |
| out[16] = 1.0 if upthrust else 0.0 | |
| # 18. wyckoff_climax: 1=selling climax, -1=buying climax | |
| out[17] = float(climax) | |
| # 19-20. Stochastic K, D (normalised 0-1) | |
| k, d_val = _stochastic(high, low, close, k_period=14, d_period=3) | |
| out[18] = np.clip(k / 100.0, 0.0, 1.0) | |
| out[19] = np.clip(d_val / 100.0, 0.0, 1.0) | |
| # 21. stoch_state: OB=1, OS=-1, neutral=0 | |
| if k > 80.0: | |
| out[20] = 1.0 | |
| elif k < 20.0: | |
| out[20] = -1.0 | |
| else: | |
| out[20] = 0.0 | |
| # ATR for pivot proximity | |
| atr_series = _atr(high, low, close, 14) | |
| atr_val = atr_series[-1] | |
| # 22-23. Pivot high/low proximity (classical pivot = prev high/low midpoints) | |
| if len(close) >= 3: | |
| prev_high = np.max(high[-10:-1]) if len(high) >= 10 else high[-1] | |
| prev_low = np.min(low[-10:-1]) if len(low) >= 10 else low[-1] | |
| dist_to_res = abs(close[-1] - prev_high) / (atr_val + 1e-10) | |
| dist_to_sup = abs(close[-1] - prev_low) / (atr_val + 1e-10) | |
| out[21] = 1.0 if dist_to_res < 0.5 else 0.0 # within 0.5 ATR of resistance | |
| out[22] = 1.0 if dist_to_sup < 0.5 else 0.0 # within 0.5 ATR of support | |
| # 24. momentum_1h: ROC(5) clipped [-3, 3] | |
| if len(close) > 5: | |
| out[23] = np.clip((close[-1] / (close[-6] + 1e-12) - 1.0) * 100.0, -3.0, 3.0) | |
| # 25. ema_ribbon: (ema9 - ema21 - ema55) / close * 100 | |
| ema9 = _ema(close, 9) | |
| ema21 = _ema(close, 21) | |
| ema55 = _ema(close, 55) | |
| out[24] = np.clip( | |
| (ema9[-1] - ema21[-1] - ema55[-1]) / (close[-1] + 1e-10) * 100.0, -5.0, 5.0 | |
| ) | |
| # 26. vol_delta_proxy: candle direction strength -1 to 1 | |
| hl_rng = high[-1] - low[-1] | |
| out[25] = (close[-1] - opn[-1]) / (hl_rng + 1e-10) if hl_rng > 0 else 0.0 | |
| out[25] = np.clip(out[25], -1.0, 1.0) | |
| # 27. trend_strength: abs(sma20 slope) normalised by price | |
| sma20 = _sma(close, 20) | |
| if not np.isnan(sma20[-1]) and not np.isnan(sma20[-2]): | |
| slope = abs(sma20[-1] - sma20[-2]) / (close[-1] + 1e-10) * 100.0 | |
| out[26] = np.clip(slope, 0.0, 1.0) | |
| # 28. consecutive_bars: count of same-direction closes, normalised [-1,1] | |
| out[27] = self._consecutive_direction(close, max_count=10) | |
| # 29. bb_squeeze: BB width < historical avg β 0/1 | |
| _, bb_mid, _ = _bollinger(close, 20) | |
| bb_std_now = np.nanstd(close[-20:]) | |
| bb_width_hist = pd.Series(close).rolling(20).std().values | |
| bb_avg_width = np.nanmean(bb_width_hist[-100:]) if len(bb_width_hist) >= 100 else np.nanmean(bb_width_hist) | |
| out[28] = 1.0 if bb_std_now < bb_avg_width * 0.75 else 0.0 | |
| # 30. 1h_momentum_score: weighted composite [-1,1] | |
| macd_bias = np.clip(out[12], -1.0, 1.0) | |
| stoch_bias = out[20] # -1/0/1 | |
| rsi_bias = (out[1] - 0.5) * 2.0 | |
| consec_bias = out[27] | |
| vol_bias = out[25] | |
| out[29] = np.clip( | |
| 0.25 * macd_bias | |
| + 0.25 * rsi_bias | |
| + 0.20 * stoch_bias | |
| + 0.15 * consec_bias | |
| + 0.15 * vol_bias, | |
| -1.0, | |
| 1.0, | |
| ) | |
| except Exception as exc: | |
| logger.debug("1H feature error: %s", exc) | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| # ------------------------------------------------------------------ | |
| # 15M Features (35) | |
| # ------------------------------------------------------------------ | |
| def compute_15m_features(self, df_15m: pd.DataFrame, at_idx: int) -> np.ndarray: | |
| """ | |
| Compute 35 15-minute (entry trigger) features. | |
| Features 1-12: compact structural features. | |
| Features 13-35: micro RSI, MACD histogram, candle patterns, wick ratios, | |
| volume spikes, scalp momentum, bar type flags, Keltner | |
| position, price acceleration, ATR percentile, entry score. | |
| Parameters | |
| ---------- | |
| df_15m : Full 15M OHLCV DataFrame. | |
| at_idx : Current bar index (inclusive). | |
| Returns | |
| ------- | |
| np.ndarray of shape (35,), dtype float32. | |
| """ | |
| out = np.zeros(N_15M, dtype=np.float32) | |
| d = df_15m.iloc[: at_idx + 1] | |
| if len(d) < MIN_BARS: | |
| return out | |
| close = d["close"].values.astype(np.float64) | |
| high = d["high"].values.astype(np.float64) | |
| low = d["low"].values.astype(np.float64) | |
| volume = d["volume"].values.astype(np.float64) | |
| opn = d["open"].values.astype(np.float64) | |
| try: | |
| # 0-11: compact structural features | |
| out[:12] = _compact_12_features(close, high, low, volume, opn) | |
| # ATR | |
| atr_series = _atr(high, low, close, 14) | |
| atr_val = atr_series[-1] | |
| # 13. micro_rsi: RSI(9)/100 | |
| out[12] = np.clip(_rsi(close, 9) / 100.0, 0.0, 1.0) | |
| # 14. micro_macd_hist: MACD histogram normalised by price | |
| _, _, hist = _macd(close, fast=5, slow=13, signal=4) | |
| out[13] = np.clip(hist / (close[-1] * 0.001 + 1e-10), -3.0, 3.0) | |
| # 15. candle_pattern: composite [-1,1] (normalised from multi-class) | |
| out[14] = self._detect_candle_pattern(opn, high, low, close) | |
| # 16. wick_ratio_up: upper wick / (high - low) | |
| hl_rng = high[-1] - low[-1] | |
| if hl_rng > 0: | |
| body_top = max(opn[-1], close[-1]) | |
| body_bot = min(opn[-1], close[-1]) | |
| upper_wick = high[-1] - body_top | |
| lower_wick = body_bot - low[-1] | |
| out[15] = np.clip(upper_wick / (hl_rng + 1e-10), 0.0, 1.0) | |
| out[16] = np.clip(lower_wick / (hl_rng + 1e-10), 0.0, 1.0) | |
| out[17] = np.clip((close[-1] - opn[-1]) / (hl_rng + 1e-10), -1.0, 1.0) | |
| # else all remain 0 | |
| # 19. volume_spike: volume / 20-bar avg - 1 | |
| vol_avg_20 = np.nanmean(volume[-20:]) if len(volume) >= 20 else np.nanmean(volume) | |
| out[18] = np.clip(volume[-1] / (vol_avg_20 + 1e-10) - 1.0, -2.0, 3.0) | |
| # 20. micro_sr_pos: position between nearest micro S/R (0-1) | |
| micro_res = np.max(high[-10:]) | |
| micro_sup = np.min(low[-10:]) | |
| micro_rng = micro_res - micro_sup | |
| if micro_rng > 0: | |
| out[19] = np.clip((close[-1] - micro_sup) / micro_rng, 0.0, 1.0) | |
| # 21. breakout_strength: (close - prev range high) / ATR | |
| prev_range_high = np.max(high[-11:-1]) if len(high) >= 11 else high[-1] | |
| prev_range_low = np.min(low[-11:-1]) if len(low) >= 11 else low[-1] | |
| if close[-1] > prev_range_high: | |
| out[20] = np.clip((close[-1] - prev_range_high) / (atr_val + 1e-10), 0.0, 5.0) | |
| # 22. breakdown_strength | |
| if close[-1] < prev_range_low: | |
| out[21] = np.clip((prev_range_low - close[-1]) / (atr_val + 1e-10), 0.0, 5.0) | |
| # 23. scalp_momentum: ema3/ema8 - 1 normalised | |
| ema3 = _ema(close, 3) | |
| ema8 = _ema(close, 8) | |
| out[22] = np.clip((ema3[-1] / (ema8[-1] + 1e-10) - 1.0) * 100.0, -3.0, 3.0) | |
| # 24. recent_range: ATR / close * 100 (%) | |
| out[23] = np.clip(atr_val / (close[-1] + 1e-10) * 100.0, 0.0, 5.0) | |
| # 25. open_interest_proxy: institutional accumulation proxy | |
| # Approximated by trending volume with price consolidation | |
| out[24] = self._oi_proxy(close, volume) | |
| # 26. tick_direction: last 3 bar close directions, normalised [-1,1] | |
| if len(close) >= 4: | |
| dirs = np.sign(np.diff(close[-4:])) # 3 diffs | |
| out[25] = np.clip(np.mean(dirs), -1.0, 1.0) | |
| # 27. pin_bar_bull: close near high + long lower wick | |
| if hl_rng > 0: | |
| close_vs_high = (high[-1] - close[-1]) / (hl_rng + 1e-10) | |
| body_size = abs(close[-1] - opn[-1]) / (hl_rng + 1e-10) | |
| lower_wick_ratio = (min(opn[-1], close[-1]) - low[-1]) / (hl_rng + 1e-10) | |
| out[26] = 1.0 if (close_vs_high < 0.2 and lower_wick_ratio > 0.5 and body_size < 0.4) else 0.0 | |
| # 28. pin_bar_bear: close near low + long upper wick | |
| close_vs_low = (close[-1] - low[-1]) / (hl_rng + 1e-10) | |
| upper_wick_ratio = (high[-1] - max(opn[-1], close[-1])) / (hl_rng + 1e-10) | |
| out[27] = 1.0 if (close_vs_low < 0.2 and upper_wick_ratio > 0.5 and body_size < 0.4) else 0.0 | |
| # 29. inside_bar | |
| if len(high) >= 2: | |
| out[28] = 1.0 if (high[-1] < high[-2] and low[-1] > low[-2]) else 0.0 | |
| # 30. outside_bar | |
| if len(high) >= 2: | |
| out[29] = 1.0 if (high[-1] > high[-2] and low[-1] < low[-2]) else 0.0 | |
| # 31. keltner_pos: (close - mid) / (upper - mid) | |
| kelt_upper, kelt_mid, _ = _keltner(high, low, close, 20, 1.5) | |
| kelt_half = (kelt_upper - kelt_mid) + 1e-10 | |
| out[30] = np.clip((close[-1] - kelt_mid) / kelt_half, -1.5, 1.5) | |
| # 32. micro_trend: avg (close-open)/ATR for last 5 bars | |
| if len(close) >= 5: | |
| micro_trend_vals = (close[-5:] - opn[-5:]) / (atr_val + 1e-10) | |
| out[31] = np.clip(np.mean(micro_trend_vals), -3.0, 3.0) | |
| # 33. price_acceleration: change in ROC | |
| if len(close) >= 7: | |
| roc_now = close[-1] / (close[-4] + 1e-12) - 1.0 | |
| roc_prev = close[-4] / (close[-7] + 1e-12) - 1.0 | |
| out[32] = np.clip((roc_now - roc_prev) * 100.0, -3.0, 3.0) | |
| # 34. atr_percentile: current ATR vs 100-bar history, 0-1 | |
| if len(atr_series) >= 10: | |
| hist_atr = atr_series[-min(100, len(atr_series)):] | |
| pct = np.sum(hist_atr <= atr_val) / len(hist_atr) | |
| out[33] = float(pct) | |
| # 35. 15m_entry_score: weighted entry trigger composite [-1,1] | |
| rsi_bias = (out[12] - 0.5) * 2.0 | |
| macd_bias = np.clip(out[13] / 3.0, -1.0, 1.0) | |
| candle_bias = np.clip(out[14], -1.0, 1.0) | |
| vol_spike_bias = np.clip(out[18] / 3.0, -1.0, 1.0) | |
| tick_bias = out[25] | |
| breakout_net = out[20] - out[21] # breakout - breakdown | |
| out[34] = np.clip( | |
| 0.20 * rsi_bias | |
| + 0.20 * macd_bias | |
| + 0.15 * candle_bias | |
| + 0.15 * tick_bias | |
| + 0.15 * vol_spike_bias | |
| + 0.15 * breakout_net, | |
| -1.0, | |
| 1.0, | |
| ) | |
| except Exception as exc: | |
| logger.debug("15M feature error: %s", exc) | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| # ------------------------------------------------------------------ | |
| # Alignment Features (4) | |
| # ------------------------------------------------------------------ | |
| def compute_alignment( | |
| self, sig_1d: float, sig_4h: float, sig_1h: float | |
| ) -> np.ndarray: | |
| """ | |
| Compute 4 cross-TF alignment features. | |
| Encodes the cascade hierarchy: when all TF trend scores align bullish | |
| (each = +1), overall_alignment = 1.0 (strongest entry signal). | |
| Parameters | |
| ---------- | |
| sig_1d : Daily trend score (feature index 19 of compute_1d_features). | |
| sig_4h : 4H trend score (feature index 24 of compute_4h_features). | |
| sig_1h : 1H momentum score (feature index 29 of compute_1h_features). | |
| Returns | |
| ------- | |
| np.ndarray of shape (4,), dtype float32. | |
| """ | |
| out = np.zeros(N_ALIGN, dtype=np.float32) | |
| def _agree(a: float, b: float) -> float: | |
| """Agreement metric: +1 both bull, -1 both bear, 0 mixed.""" | |
| if a > 0.1 and b > 0.1: | |
| return 1.0 | |
| if a < -0.1 and b < -0.1: | |
| return -1.0 | |
| return 0.0 | |
| out[0] = _agree(sig_1d, sig_4h) # align_1d_4h | |
| out[1] = _agree(sig_4h, sig_1h) # align_4h_1h | |
| out[2] = _agree(sig_1h, sig_1h) # align_1h_15m placeholder (15m score not passed here) | |
| out[3] = float(np.clip(np.mean(out[:3]), -1.0, 1.0)) # overall_alignment | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| def compute_alignment_full( | |
| self, sig_1d: float, sig_4h: float, sig_1h: float, sig_15m: float | |
| ) -> np.ndarray: | |
| """ | |
| Compute 4 cross-TF alignment features with all four signals. | |
| Parameters | |
| ---------- | |
| sig_1d : Daily trend score. | |
| sig_4h : 4H trend score. | |
| sig_1h : 1H momentum score. | |
| sig_15m : 15M entry score. | |
| Returns | |
| ------- | |
| np.ndarray of shape (4,), dtype float32. | |
| """ | |
| out = np.zeros(N_ALIGN, dtype=np.float32) | |
| def _agree(a: float, b: float) -> float: | |
| if a > 0.1 and b > 0.1: | |
| return 1.0 | |
| if a < -0.1 and b < -0.1: | |
| return -1.0 | |
| return 0.0 | |
| out[0] = _agree(sig_1d, sig_4h) # align_1d_4h | |
| out[1] = _agree(sig_4h, sig_1h) # align_4h_1h | |
| out[2] = _agree(sig_1h, sig_15m) # align_1h_15m | |
| out[3] = float(np.clip(np.mean(out[:3]), -1.0, 1.0)) | |
| return np.nan_to_num(out, nan=0.0, posinf=1.0, neginf=-1.0).astype(np.float32) | |
| # ------------------------------------------------------------------ | |
| # Feature name catalogue | |
| # ------------------------------------------------------------------ | |
| def get_feature_names(self) -> List[str]: | |
| """Return ordered list of all 114 feature names (excludes 3 env position feats).""" | |
| names_1d = [ | |
| "1d_sma_trend", "1d_sma200_dist", "1d_adx", "1d_adx_trend", | |
| "1d_macro_rsi", "1d_monthly_return", "1d_weekly_return", | |
| "1d_vol_regime", "1d_atr_regime", "1d_higher_high", "1d_higher_low", | |
| "1d_price_vs_range", "1d_ema_stack", "1d_trend_maturity", | |
| "1d_ichimoku_cloud_pos", "1d_wyckoff_phase", "1d_vol_expansion", | |
| "1d_doji_day", "1d_range_compression", "1d_daily_trend_score", | |
| ] | |
| names_4h = [ | |
| "4h_ema_trend", "4h_rsi", "4h_mom_5", "4h_mom_10", "4h_mom_20", | |
| "4h_atr_ratio", "4h_vol_trend", "4h_macd_hist", "4h_bb_pos", | |
| "4h_sr_pos", "4h_body_ratio", "4h_trend_strength", | |
| "4h_smc_bos", "4h_smc_choch", "4h_bullish_ob", "4h_bearish_ob", | |
| "4h_bullish_fvg", "4h_bearish_fvg", "4h_swing_high_dist", | |
| "4h_swing_low_dist", "4h_structure_trend", "4h_ob_zone_strength", | |
| "4h_liquidity_above", "4h_liquidity_below", "4h_trend_score", | |
| ] | |
| names_1h = [ | |
| "1h_ema_trend", "1h_rsi", "1h_mom_5", "1h_mom_10", "1h_mom_20", | |
| "1h_atr_ratio", "1h_vol_trend", "1h_macd_hist", "1h_bb_pos", | |
| "1h_sr_pos", "1h_body_ratio", "1h_trend_strength", | |
| "1h_macd_divergence", "1h_rsi_divergence", "1h_volume_climax", | |
| "1h_wyckoff_spring", "1h_wyckoff_upthrust", "1h_wyckoff_climax", | |
| "1h_stoch_k", "1h_stoch_d", "1h_stoch_state", | |
| "1h_pivot_high", "1h_pivot_low", "1h_momentum_roc5", | |
| "1h_ema_ribbon", "1h_vol_delta_proxy", "1h_trend_strength_slope", | |
| "1h_consecutive_bars", "1h_bb_squeeze", "1h_momentum_score", | |
| ] | |
| names_15m = [ | |
| "15m_ema_trend", "15m_rsi", "15m_mom_5", "15m_mom_10", "15m_mom_20", | |
| "15m_atr_ratio", "15m_vol_trend", "15m_macd_hist", "15m_bb_pos", | |
| "15m_sr_pos", "15m_body_ratio", "15m_trend_strength", | |
| "15m_micro_rsi", "15m_micro_macd_hist", "15m_candle_pattern", | |
| "15m_wick_ratio_up", "15m_wick_ratio_down", "15m_body_strength", | |
| "15m_volume_spike", "15m_micro_sr_pos", | |
| "15m_breakout_strength", "15m_breakdown_strength", | |
| "15m_scalp_momentum", "15m_recent_range", "15m_oi_proxy", | |
| "15m_tick_direction", "15m_pin_bar_bull", "15m_pin_bar_bear", | |
| "15m_inside_bar", "15m_outside_bar", "15m_keltner_pos", | |
| "15m_micro_trend", "15m_price_acceleration", "15m_atr_percentile", | |
| "15m_entry_score", | |
| ] | |
| names_align = [ | |
| "align_1d_4h", "align_4h_1h", "align_1h_15m", "overall_alignment", | |
| ] | |
| return names_1d + names_4h + names_1h + names_15m + names_align | |
| # ------------------------------------------------------------------ | |
| # Private helpers | |
| # ------------------------------------------------------------------ | |
| def _find_swing_points( | |
| self, high: np.ndarray, low: np.ndarray | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """Return indices of recent swing highs and lows.""" | |
| lb = self.swing_lookback | |
| n = len(high) | |
| if n < 2 * lb + 1: | |
| return np.array([], dtype=int), np.array([], dtype=int) | |
| sh_idx: List[int] = [] | |
| sl_idx: List[int] = [] | |
| for i in range(lb, n - lb): | |
| if high[i] == np.max(high[max(0, i - lb): i + lb + 1]): | |
| sh_idx.append(i) | |
| if low[i] == np.min(low[max(0, i - lb): i + lb + 1]): | |
| sl_idx.append(i) | |
| return np.array(sh_idx, dtype=int), np.array(sl_idx, dtype=int) | |
| def _detect_bos( | |
| self, | |
| close: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| sh_idx: np.ndarray, | |
| sl_idx: np.ndarray, | |
| ) -> float: | |
| """Detect Break of Structure: +1 bullish, -1 bearish, 0 none.""" | |
| if len(sh_idx) < 2 or len(sl_idx) < 2: | |
| return 0.0 | |
| # Bullish BOS: close breaks above last swing high | |
| last_sh = high[sh_idx[-1]] | |
| if close[-1] > last_sh: | |
| return 1.0 | |
| # Bearish BOS: close breaks below last swing low | |
| last_sl = low[sl_idx[-1]] | |
| if close[-1] < last_sl: | |
| return -1.0 | |
| return 0.0 | |
| def _detect_choch( | |
| self, | |
| close: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| sh_idx: np.ndarray, | |
| sl_idx: np.ndarray, | |
| ) -> float: | |
| """ | |
| Change of Character: reversal of prevailing structure. | |
| Bullish (+1): price in downtrend creates LH/LL, then breaks a prior SH. | |
| Bearish (-1): price in uptrend creates HH/HL, then breaks a prior SL. | |
| """ | |
| if len(sh_idx) < 3 or len(sl_idx) < 3: | |
| return 0.0 | |
| # Bullish CHOCH: last two swing lows are lower (downtrend) but price now | |
| # breaks above the most recent swing high (shift in character) | |
| sl_vals = low[sl_idx[-2:]] | |
| if sl_vals[-1] < sl_vals[-2]: # lower lows = downtrend | |
| if close[-1] > high[sh_idx[-1]]: | |
| return 1.0 | |
| # Bearish CHOCH | |
| sh_vals = high[sh_idx[-2:]] | |
| if sh_vals[-1] > sh_vals[-2]: # higher highs = uptrend | |
| if close[-1] < low[sl_idx[-1]]: | |
| return -1.0 | |
| return 0.0 | |
| def _find_order_blocks( | |
| self, | |
| opn: np.ndarray, | |
| close: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| lookback: int = 30, | |
| ) -> Tuple[List[float], List[float]]: | |
| """ | |
| Identify bullish and bearish order blocks as mid-points. | |
| A bullish OB is the last bearish candle before a strong bullish move. | |
| A bearish OB is the last bullish candle before a strong bearish move. | |
| Returns lists of mid-price levels. | |
| """ | |
| bull_obs: List[float] = [] | |
| bear_obs: List[float] = [] | |
| n = len(close) | |
| start = max(0, n - lookback) | |
| for i in range(start, n - 2): | |
| body_i = close[i] - opn[i] | |
| body_i1 = close[i + 1] - opn[i + 1] | |
| move = abs(close[i + 2] - close[i + 1]) if (i + 2) < n else 0.0 | |
| atr_proxy = np.mean(high[start:] - low[start:]) + 1e-10 | |
| # Bullish OB: bearish candle (i) then strong bullish push (i+1) | |
| if body_i < 0 and body_i1 > 0 and move > atr_proxy * 0.5: | |
| bull_obs.append((high[i] + low[i]) / 2.0) | |
| # Bearish OB: bullish candle (i) then strong bearish push (i+1) | |
| if body_i > 0 and body_i1 < 0 and move > atr_proxy * 0.5: | |
| bear_obs.append((high[i] + low[i]) / 2.0) | |
| return bull_obs, bear_obs | |
| def _near_order_block( | |
| self, price: float, ob_levels: List[float], proximity_pct: float | |
| ) -> float: | |
| """Return 1 if price is within proximity_pct of any OB level.""" | |
| for lvl in ob_levels: | |
| if abs(price - lvl) / (lvl + 1e-10) < proximity_pct: | |
| return 1.0 | |
| return 0.0 | |
| def _find_fvgs( | |
| self, high: np.ndarray, low: np.ndarray, lookback: int = 20 | |
| ) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]: | |
| """ | |
| Fair Value Gaps (imbalance zones). | |
| Bullish FVG: low[i+2] > high[i] β gap up (unfilled bullish imbalance). | |
| Bearish FVG: high[i+2] < low[i] β gap down. | |
| Returns recent unfilled FVGs as (top, bottom) tuples. | |
| """ | |
| bull_fvgs: List[Tuple[float, float]] = [] | |
| bear_fvgs: List[Tuple[float, float]] = [] | |
| n = len(high) | |
| start = max(0, n - lookback) | |
| for i in range(start, n - 2): | |
| if low[i + 2] > high[i]: | |
| # Check if current price is inside (unfilled) | |
| gap_top = low[i + 2] | |
| gap_bot = high[i] | |
| bull_fvgs.append((gap_top, gap_bot)) | |
| if high[i + 2] < low[i]: | |
| gap_top = low[i] | |
| gap_bot = high[i + 2] | |
| bear_fvgs.append((gap_top, gap_bot)) | |
| return bull_fvgs, bear_fvgs | |
| def _detect_structure_trend( | |
| self, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| sh_idx: np.ndarray, | |
| sl_idx: np.ndarray, | |
| ) -> float: | |
| """Return +1 (HH/HL), -1 (LH/LL), 0 (ranging).""" | |
| if len(sh_idx) < 2 or len(sl_idx) < 2: | |
| return 0.0 | |
| last_sh = high[sh_idx[-1]] | |
| prev_sh = high[sh_idx[-2]] | |
| last_sl = low[sl_idx[-1]] | |
| prev_sl = low[sl_idx[-2]] | |
| higher_high = last_sh > prev_sh | |
| higher_low = last_sl > prev_sl | |
| lower_high = last_sh < prev_sh | |
| lower_low = last_sl < prev_sl | |
| if higher_high and higher_low: | |
| return 1.0 | |
| if lower_high and lower_low: | |
| return -1.0 | |
| return 0.0 | |
| def _detect_wyckoff_phase_simple( | |
| self, | |
| close: np.ndarray, | |
| volume: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| ) -> float: | |
| """ | |
| Simplified Wyckoff phase detection normalised to [-1, 1]. | |
| 0=unknown(0), 1=accumulation(0.5), 2=markup(1), 3=distribution(-0.5), 4=markdown(-1). | |
| Heuristic based on price trend + volume trend. | |
| """ | |
| if len(close) < 20: | |
| return 0.0 | |
| price_trend = (close[-1] - close[-20]) / (abs(close[-20]) + 1e-10) | |
| vol_trend = np.nanmean(volume[-5:]) / (np.nanmean(volume[-20:]) + 1e-10) - 1.0 | |
| price_range = np.max(high[-20:]) - np.min(low[-20:]) | |
| price_mid = (np.max(high[-20:]) + np.min(low[-20:])) / 2.0 | |
| pos_in_range = (close[-1] - np.min(low[-20:])) / (price_range + 1e-10) | |
| if price_trend > 0.03 and close[-1] > price_mid: | |
| return 1.0 # markup | |
| if price_trend < -0.03 and close[-1] < price_mid: | |
| return -1.0 # markdown | |
| if pos_in_range < 0.4 and vol_trend < 0: | |
| return 0.5 # accumulation (low in range, decreasing volume) | |
| if pos_in_range > 0.6 and vol_trend < 0: | |
| return -0.5 # distribution (high in range, decreasing volume) | |
| return 0.0 | |
| def _detect_wyckoff_events( | |
| self, | |
| close: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| volume: np.ndarray, | |
| ) -> Tuple[bool, bool, int]: | |
| """ | |
| Detect spring, upthrust, and climax at the current bar. | |
| Returns | |
| ------- | |
| (spring, upthrust, climax) where climax β {-1, 0, 1}. | |
| """ | |
| if len(close) < 20: | |
| return False, False, 0 | |
| support = np.min(low[-20:-1]) | |
| resistance = np.max(high[-20:-1]) | |
| vol_avg = np.nanmean(volume[-20:]) | |
| vol_spike = volume[-1] > (vol_avg * 2.0) | |
| price_range = high[-1] - low[-1] | |
| range_avg = np.nanmean(high[-20:] - low[-20:]) | |
| close_near_low = (close[-1] - low[-1]) / (price_range + 1e-10) < 0.3 | |
| close_near_high = (close[-1] - low[-1]) / (price_range + 1e-10) > 0.7 | |
| wide_range = price_range > range_avg * 1.5 | |
| # Spring: breaks below support but closes above it | |
| spring = bool(low[-1] < support and close[-1] > support) | |
| # Upthrust: breaks above resistance but closes below it | |
| upthrust = bool(high[-1] > resistance and close[-1] < resistance) | |
| # Climax: high volume + wide range | |
| climax = 0 | |
| if vol_spike and wide_range: | |
| if close_near_low: | |
| climax = 1 # selling climax | |
| elif close_near_high: | |
| climax = -1 # buying climax | |
| return spring, upthrust, climax | |
| def _rsi_series(self, close: np.ndarray, period: int = 14) -> np.ndarray: | |
| """RSI series (one value per bar).""" | |
| n = len(close) | |
| rsi_out = np.full(n, 50.0) | |
| if n < period + 1: | |
| return rsi_out | |
| delta = np.diff(close) | |
| gain = np.where(delta > 0, delta, 0.0) | |
| loss = np.where(delta < 0, -delta, 0.0) | |
| avg_gain = pd.Series(gain).ewm(span=period, adjust=False).mean().values | |
| avg_loss = pd.Series(loss).ewm(span=period, adjust=False).mean().values | |
| rs = avg_gain / (avg_loss + 1e-12) | |
| rsi_vals = 100.0 - 100.0 / (1.0 + rs) | |
| rsi_out[1:] = rsi_vals | |
| return rsi_out | |
| def _macd_hist_series( | |
| self, close: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9 | |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| """Full MACD series arrays.""" | |
| ema_fast = _ema(close, fast) | |
| ema_slow = _ema(close, slow) | |
| macd_line = ema_fast - ema_slow | |
| signal_line = pd.Series(macd_line).ewm(span=signal, adjust=False).mean().values | |
| hist = macd_line - signal_line | |
| return macd_line, signal_line, hist | |
| def _detect_momentum_divergence( | |
| self, close: np.ndarray, hist: np.ndarray, lookback: int = 20 | |
| ) -> float: | |
| """ | |
| Detect MACD divergence over lookback bars. | |
| Bullish: price lower low, MACD histogram higher low β +1. | |
| Bearish: price higher high, MACD histogram lower high β -1. | |
| """ | |
| if len(close) < lookback: | |
| return 0.0 | |
| price_window = close[-lookback:] | |
| hist_window = hist[-lookback:] | |
| price_min_idx = int(np.argmin(price_window)) | |
| price_max_idx = int(np.argmax(price_window)) | |
| # Bullish divergence: recent bar's price is near the low, hist is not | |
| if price_min_idx < lookback - 5: | |
| if price_window[-1] < price_window[price_min_idx] * 1.005: | |
| if hist_window[-1] > hist_window[price_min_idx]: | |
| return 1.0 | |
| # Bearish divergence | |
| if price_max_idx < lookback - 5: | |
| if price_window[-1] > price_window[price_max_idx] * 0.995: | |
| if hist_window[-1] < hist_window[price_max_idx]: | |
| return -1.0 | |
| return 0.0 | |
| def _detect_rsi_divergence( | |
| self, close: np.ndarray, rsi: np.ndarray, lookback: int = 20 | |
| ) -> float: | |
| """ | |
| Detect RSI divergence. | |
| Bullish: price lower low but RSI higher low β +1. | |
| Bearish: price higher high but RSI lower high β -1. | |
| """ | |
| if len(close) < lookback: | |
| return 0.0 | |
| price_window = close[-lookback:] | |
| rsi_window = rsi[-lookback:] | |
| # Find prior significant low | |
| price_low_idx = int(np.argmin(price_window[:-3])) | |
| if price_window[-1] < price_window[price_low_idx] * 1.002: | |
| if rsi_window[-1] > rsi_window[price_low_idx]: | |
| return 1.0 | |
| # Find prior significant high | |
| price_high_idx = int(np.argmax(price_window[:-3])) | |
| if price_window[-1] > price_window[price_high_idx] * 0.998: | |
| if rsi_window[-1] < rsi_window[price_high_idx]: | |
| return -1.0 | |
| return 0.0 | |
| def _detect_candle_pattern( | |
| self, | |
| opn: np.ndarray, | |
| high: np.ndarray, | |
| low: np.ndarray, | |
| close: np.ndarray, | |
| ) -> float: | |
| """ | |
| Detect candle pattern at last bar, returning normalised value in [-1, 1]. | |
| hammer=1, shooting_star=-1, doji=0, engulfing_bull=0.75, engulfing_bear=-0.75. | |
| Multiple patterns β use highest absolute priority. | |
| """ | |
| if len(close) < 2: | |
| return 0.0 | |
| hl = high[-1] - low[-1] | |
| if hl < 1e-10: | |
| return 0.0 | |
| body = close[-1] - opn[-1] | |
| body_abs = abs(body) | |
| body_frac = body_abs / hl | |
| upper_wick = high[-1] - max(close[-1], opn[-1]) | |
| lower_wick = min(close[-1], opn[-1]) - low[-1] | |
| # Engulfing patterns (highest priority) | |
| if len(close) >= 2: | |
| prev_body = close[-2] - opn[-2] | |
| prev_body_abs = abs(prev_body) | |
| # Bullish engulfing | |
| if prev_body < 0 and body > 0 and body_abs > prev_body_abs * 1.1: | |
| return 0.75 | |
| # Bearish engulfing | |
| if prev_body > 0 and body < 0 and body_abs > prev_body_abs * 1.1: | |
| return -0.75 | |
| # Doji | |
| if body_frac < 0.1: | |
| return 0.0 | |
| # Hammer: small body at top, long lower wick | |
| if lower_wick > 2.0 * body_abs and upper_wick < 0.3 * body_abs and close[-1] > opn[-1]: | |
| return 1.0 | |
| # Shooting star: small body at bottom, long upper wick | |
| if upper_wick > 2.0 * body_abs and lower_wick < 0.3 * body_abs and close[-1] < opn[-1]: | |
| return -1.0 | |
| # Bullish / bearish candle by body direction | |
| return float(np.sign(body)) * body_frac | |
| def _consecutive_direction(self, close: np.ndarray, max_count: int = 10) -> float: | |
| """ | |
| Count consecutive same-direction closes, normalised to [-1, 1]. | |
| Positive: consecutive up bars, negative: consecutive down bars. | |
| """ | |
| if len(close) < 2: | |
| return 0.0 | |
| diffs = np.sign(np.diff(close)) | |
| direction = diffs[-1] | |
| if direction == 0.0: | |
| return 0.0 | |
| count = 0 | |
| for d in reversed(diffs): | |
| if d == direction: | |
| count += 1 | |
| else: | |
| break | |
| return float(np.clip(direction * count / max_count, -1.0, 1.0)) | |
| def _oi_proxy(self, close: np.ndarray, volume: np.ndarray, lookback: int = 20) -> float: | |
| """ | |
| Institutional accumulation proxy via OBV-like volume-price agreement. | |
| Rising price + rising volume = accumulation (+1). | |
| Falling price + rising volume = distribution (-1). | |
| """ | |
| if len(close) < lookback + 1: | |
| return 0.0 | |
| price_change = close[-1] - close[-lookback] | |
| vol_trend = np.nanmean(volume[-5:]) / (np.nanmean(volume[-lookback:]) + 1e-10) - 1.0 | |
| if price_change > 0 and vol_trend > 0: | |
| return np.clip(vol_trend, 0.0, 1.0) | |
| if price_change < 0 and vol_trend > 0: | |
| return np.clip(-vol_trend, -1.0, 0.0) | |
| return 0.0 | |
| # --------------------------------------------------------------------------- | |
| # HTFDataAligner | |
| # --------------------------------------------------------------------------- | |
| class HTFDataAligner: | |
| """ | |
| Resamples a 15-minute OHLCV DataFrame to 1H, 4H, and 1D timeframes. | |
| The 15M DataFrame must have a DatetimeIndex. All resampled frames use | |
| standard OHLCV aggregation (open=first, high=max, low=min, close=last, | |
| volume=sum) and bars with all-NaN OHLCV are dropped. | |
| Usage | |
| ----- | |
| aligner = HTFDataAligner() | |
| frames = aligner.align_timestamps(df_15m) | |
| # frames['15m'], frames['1h'], frames['4h'], frames['1d'] | |
| parent_idx = aligner.get_parent_idx(frames['15m'], frames['1h'], child_idx=100) | |
| """ | |
| _RESAMPLE_RULES: Dict[str, str] = { | |
| "15m": "15min", | |
| "1h": "1h", | |
| "4h": "4h", | |
| "1d": "1D", | |
| } | |
| _OHLCV_AGG = { | |
| "open": "first", | |
| "high": "max", | |
| "low": "min", | |
| "close": "last", | |
| "volume": "sum", | |
| } | |
| def align_timestamps(self, df_15m: pd.DataFrame) -> Dict[str, pd.DataFrame]: | |
| """ | |
| Resample df_15m to all four standard timeframes. | |
| Parameters | |
| ---------- | |
| df_15m : DataFrame with DatetimeIndex and OHLCV columns. | |
| Returns | |
| ------- | |
| dict with keys '15m', '1h', '4h', '1d' β DataFrames. | |
| """ | |
| if not isinstance(df_15m.index, pd.DatetimeIndex): | |
| raise ValueError("df_15m must have a DatetimeIndex.") | |
| cols = [c for c in ["open", "high", "low", "close", "volume"] if c in df_15m.columns] | |
| if not cols: | |
| raise ValueError("df_15m must contain at least one OHLCV column.") | |
| agg = {c: self._OHLCV_AGG[c] for c in cols if c in self._OHLCV_AGG} | |
| frames: Dict[str, pd.DataFrame] = {"15m": df_15m.copy()} | |
| for key in ("1h", "4h", "1d"): | |
| rule = self._RESAMPLE_RULES[key] | |
| resampled = df_15m.resample(rule).agg(agg).dropna(how="all") | |
| frames[key] = resampled | |
| logger.debug( | |
| "HTFDataAligner: 15m=%d 1h=%d 4h=%d 1d=%d bars", | |
| len(frames["15m"]), | |
| len(frames["1h"]), | |
| len(frames["4h"]), | |
| len(frames["1d"]), | |
| ) | |
| return frames | |
| def get_parent_idx( | |
| self, | |
| df_child: pd.DataFrame, | |
| df_parent: pd.DataFrame, | |
| child_idx: int, | |
| ) -> int: | |
| """ | |
| Return the parent-frame index corresponding to df_child.iloc[child_idx]. | |
| Uses a searchsorted lookup β O(log n) per call. | |
| Parameters | |
| ---------- | |
| df_child : Child timeframe DataFrame (e.g. 15M). | |
| df_parent : Parent timeframe DataFrame (e.g. 1H). | |
| child_idx : Integer position in df_child. | |
| Returns | |
| ------- | |
| int : Integer position in df_parent of the most recent parent bar | |
| whose timestamp is <= df_child.index[child_idx]. | |
| Returns 0 if no parent bar precedes the child timestamp. | |
| """ | |
| child_ts = df_child.index[child_idx] | |
| # searchsorted gives insertion point; subtract 1 to get last bar <= child_ts | |
| pos = df_parent.index.searchsorted(child_ts, side="right") - 1 | |
| return int(max(0, pos)) | |