"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/...""" from __future__ import annotations from typing import Any from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase from crypto_wyckoff.rules.base import RuleHit, WyckoffRule def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float: v = ctx.get("features", {}).get(key, default) try: return float(v) if v is not None else default except (TypeError, ValueError): return default def _cycle(ctx: dict[str, Any]) -> str: return (ctx.get("cycle") or {}).get("cycle") or "" def _phase(ctx: dict[str, Any]) -> str: return (ctx.get("phase") or {}).get("phase") or "" class SpringRule(WyckoffRule): rule_id = "event_spring" category = "event" timeframes = ("1d",) def evaluate(self, context: dict[str, Any]) -> RuleHit | None: cycle = _cycle(context) if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.MARKUP.value): # Allow spring only in accumulative contexts; Decision will filter MTF if cycle == WyckoffCycle.DISTRIBUTION.value: pass # still detect for facts but lower confidence pierce = _f(context, "pierce_below_range") reclaim = _f(context, "reclaim_speed") vol_ratio = _f(context, "volume_ratio") close_in_range = _f(context, "close_back_in_range") if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3: strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5)) return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.SPRING.value, confidence=strength, score=strength, reasons=[ f"跌破区间后收回 (pierce={pierce:.3%})", f"回收速度={reclaim:.2f}", f"量比={vol_ratio:.2f}", ], metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio}, ) return None class TestRule(WyckoffRule): rule_id = "event_test" category = "event" timeframes = ("1d", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: pos = _f(context, "range_position") vol_ratio = _f(context, "volume_ratio") near_low = pos < 0.2 if near_low and vol_ratio < 0.85: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.TEST.value, confidence=68.0, score=65.0, reasons=["低位缩量回测"], ) return None class SOSRule(WyckoffRule): rule_id = "event_sos" category = "event" timeframes = ("1d", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: breakout = _f(context, "breakout_above_range") vol_ratio = _f(context, "volume_ratio") close = _f(context, "close") ma20 = _f(context, "ma20") if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20: conf = min(95.0, 70 + vol_ratio * 8) return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.SOS.value, confidence=conf, score=conf, reasons=["放量突破区间上沿 (SOS)"], metrics={"vol_ratio": vol_ratio}, ) return None class LPSRule(WyckoffRule): rule_id = "event_lps" category = "event" timeframes = ("1d", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: # Pullback hold above broken range / MA20 after prior strength pullback = _f(context, "pullback_hold") vol_ratio = _f(context, "volume_ratio") above_ma = _f(context, "close") > _f(context, "ma20") if pullback >= 0.5 and above_ma and vol_ratio <= 1.1: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.LPS.value, confidence=74.0, score=76.0, reasons=["突破后缩量回踩支撑 (LPS)"], ) return None class SCRule(WyckoffRule): rule_id = "event_sc" category = "event" timeframes = ("1w", "1d") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: vol_ratio = _f(context, "volume_ratio") bar_range = _f(context, "bar_range_atr") pos = _f(context, "range_position") if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.SC.value, confidence=72.0, score=70.0, reasons=["低位放量宽幅,疑似 Selling Climax"], ) return None class ARRule(WyckoffRule): rule_id = "event_ar" category = "event" timeframes = ("1w", "1d") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: # Automatic rally: bounce from lows bounce = _f(context, "bounce_from_low") if bounce >= 0.04: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.AR.value, confidence=65.0, score=62.0, reasons=["低点后自动反弹 (AR)"], ) return None class STRule(WyckoffRule): rule_id = "event_st" category = "event" timeframes = ("1w", "1d") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: pos = _f(context, "range_position") vol_ratio = _f(context, "volume_ratio") if 0.15 < pos < 0.45 and vol_ratio < 1.0: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.ST.value, confidence=60.0, score=58.0, reasons=["次级测试 (ST)"], ) return None class UTADRule(WyckoffRule): rule_id = "event_utad" category = "event" timeframes = ("1w", "1d") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: cycle = _cycle(context) pierce_up = _f(context, "pierce_above_range") fail = _f(context, "fail_back_into_range") if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value, WyckoffCycle.MARKUP.value): if pierce_up >= 0.002 and fail >= 0.5: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.UTAD.value, confidence=76.0, score=74.0, reasons=["冲高失败回到区间 (UTAD)"], ) return None class JumpRule(WyckoffRule): rule_id = "event_jump" category = "event" timeframes = ("1d",) def evaluate(self, context: dict[str, Any]) -> RuleHit | None: gap = _f(context, "gap_up_pct") vol_ratio = _f(context, "volume_ratio") if gap >= 0.03 and vol_ratio >= 1.3: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.JUMP.value, confidence=70.0, score=72.0, reasons=["放量向上跳跃 (Jump)"], ) return None class BackupRule(WyckoffRule): rule_id = "event_backup" category = "event" timeframes = ("1d",) def evaluate(self, context: dict[str, Any]) -> RuleHit | None: pullback = _f(context, "pullback_hold") after_jump = _f(context, "after_strength") if after_jump >= 0.5 and pullback >= 0.5: return RuleHit( rule_id=self.rule_id, event=WyckoffEvent.BACKUP.value, confidence=68.0, score=70.0, reasons=["跳跃后回踩 (Backup)"], ) return None def build_rules() -> list[WyckoffRule]: return [ SpringRule(), UTADRule(), SOSRule(), LPSRule(), SCRule(), JumpRule(), BackupRule(), TestRule(), ARRule(), STRule(), ]