feat(ECR-009): Crypto Wyckoff Screener 独立页(D/W/M)
移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user