自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
7.8 KiB
Python
207 lines
7.8 KiB
Python
"""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,
|
|
)
|