"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot).""" from __future__ import annotations from typing import Any import numpy as np from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame def _sma(arr: np.ndarray, n: int) -> float: if len(arr) < n: return float(arr[-1]) if len(arr) else 0.0 return float(np.mean(arr[-n:])) def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float: if len(close) < 2: return 0.0 prev_close = close[:-1] tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close))) if len(tr) < n: return float(np.mean(tr)) if len(tr) else 0.0 return float(np.mean(tr[-n:])) def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float: """Simplified ADX approximation.""" if len(close) < n + 2: return 15.0 up = high[1:] - high[:-1] down = low[:-1] - low[1:] plus_dm = np.where((up > down) & (up > 0), up, 0.0) minus_dm = np.where((down > up) & (down > 0), down, 0.0) tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1]))) atr = np.mean(tr[-n:]) or 1e-9 plus_di = 100 * np.mean(plus_dm[-n:]) / atr minus_di = 100 * np.mean(minus_dm[-n:]) / atr denom = plus_di + minus_di if denom < 1e-9: return 10.0 dx = 100 * abs(plus_di - minus_di) / denom return float(min(60.0, dx)) def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]: """Compute technical snapshot dict from OHLCV (no I/O).""" if frame.empty or len(frame) < 5: return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)} close = np.asarray(frame.close, dtype=float) high = np.asarray(frame.high, dtype=float) low = np.asarray(frame.low, dtype=float) volume = np.asarray(frame.volume, dtype=float) open_ = np.asarray(frame.open, dtype=float) ma20 = _sma(close, 20) ma60 = _sma(close, 60) ma120 = _sma(close, min(120, len(close))) atr = _atr(high, low, close, 14) vol_ma20 = _sma(volume, 20) or 1e-9 volume_ratio = float(volume[-1] / vol_ma20) look = min(60, len(close)) window_h = high[-look:] window_l = low[-look:] range_high = float(np.max(window_h)) range_low = float(np.min(window_l)) rng = max(range_high - range_low, 1e-9) range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0 range_position = float((close[-1] - range_low) / rng) # Spring / UTAD hints pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0 # if previous bars broke below and last close back in range prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2]) pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1])) close_back_in_range = 1.0 if close[-1] >= range_low else 0.0 reclaim_speed = 0.0 if pierce_below > 0 and close[-1] >= range_low: reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2) pierce_above = max(0.0, (high[-1] - range_high) / close[-1]) fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0 breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0 # pullback hold: close near ma20 from above after being higher pullback_hold = 0.0 if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5: pullback_hold = 0.8 ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60 ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9) # volume trend: recent 10 vs prior 10 if len(volume) >= 20: volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0) else: volume_trend = 0.0 bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9)) bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0 gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0 after_strength = 0.0 if len(close) >= 4 and close[-3] > close[-4]: after_strength = 0.7 spring_score_hint = 0.0 if pierce_below >= 0.002 and close_back_in_range: spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20) utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0 # swing swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1]) swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1]) return { "ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame), "close": float(close[-1]), "open": float(open_[-1]), "high": float(high[-1]), "low": float(low[-1]), "volume": float(volume[-1]), "ma20": ma20, "ma60": ma60, "ma120": ma120, "ma60_slope": float(ma60_slope), "atr": atr, "adx": _adx(high, low, close), "volume_ma20": float(vol_ma20), "volume_ratio": volume_ratio, "volume_trend": volume_trend, "range_high": range_high, "range_low": range_low, "range_pct_60": range_pct_60, "range_position": range_position, "pierce_below_range": pierce_below, "pierce_above_range": pierce_above, "close_back_in_range": close_back_in_range, "reclaim_speed": reclaim_speed, "fail_back_into_range": fail_back, "breakout_above_range": breakout_above, "pullback_hold": pullback_hold, "bar_range_atr": bar_range_atr, "bounce_from_low": bounce_from_low, "gap_up_pct": gap_up_pct, "after_strength": after_strength, "spring_score_hint": spring_score_hint, "utad_score_hint": utad_score_hint, "swing_high": swing_high, "swing_low": swing_low, "trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None, } # Minimum bars before a timeframe is considered usable (no cross-TF borrow) _MIN_BARS = {"1d": 40, "1w": 26, "1M": 18} class FeatureEngine: """Pure Feature Engine — no database access.""" name = "Feature" version = "1.0.0" def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult: tf = timeframe or (frame.timeframe if frame else "1d") min_bars = _MIN_BARS.get(tf, 30) if frame is None or frame.empty or len(frame) < min_bars: bars = 0 if frame is None or frame.empty else len(frame) return EngineResult( name=self.name, version=self.version, confidence=10.0, score=10.0, reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"], warnings=["insufficient_features"], metrics={"bars": bars, "min_bars": min_bars}, payload={ "ts_code": getattr(frame, "ts_code", ""), "timeframe": tf, "bars": bars, "insufficient": True, }, ) snap = compute_feature_snapshot(frame) snap["insufficient"] = False conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5) warnings = [] if snap.get("bars", 0) < 60: warnings.append("bars偏少,特征可靠性中等") return EngineResult( name=self.name, version=self.version, confidence=conf, score=conf, reasons=[f"computed {snap.get('bars', 0)} bars {tf}"], warnings=warnings, metrics={"bars": snap.get("bars", 0)}, payload=snap, )