"""Cycle classification rules (monthly / weekly).""" from __future__ import annotations from typing import Any from crypto_wyckoff.domain_models import WyckoffCycle 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 class MarkupCycleRule(WyckoffRule): rule_id = "cycle_markup" category = "cycle" timeframes = ("1M", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: close = _f(context, "close") ma20 = _f(context, "ma20") ma60 = _f(context, "ma60") ma120 = _f(context, "ma120") adx = _f(context, "adx") slope = _f(context, "ma60_slope") if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18: conf = min(95.0, 55 + adx + (10 if close > ma120 else 0)) return RuleHit( rule_id=self.rule_id, cycle=WyckoffCycle.MARKUP.value, confidence=conf, score=conf, reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"], metrics={"adx": adx, "slope": slope}, ) return None class MarkdownCycleRule(WyckoffRule): rule_id = "cycle_markdown" category = "cycle" timeframes = ("1M", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: close = _f(context, "close") ma20 = _f(context, "ma20") ma60 = _f(context, "ma60") ma120 = _f(context, "ma120") adx = _f(context, "adx") slope = _f(context, "ma60_slope") if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18: conf = min(95.0, 55 + adx + (10 if close < ma120 else 0)) return RuleHit( rule_id=self.rule_id, cycle=WyckoffCycle.MARKDOWN.value, confidence=conf, score=conf, reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"], metrics={"adx": adx}, ) return None class AccumulationCycleRule(WyckoffRule): rule_id = "cycle_accumulation" category = "cycle" timeframes = ("1M", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: adx = _f(context, "adx") range_pct = _f(context, "range_pct_60") close = _f(context, "close") ma120 = _f(context, "ma120") vol_trend = _f(context, "volume_trend") # Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution) if adx < 22 and range_pct < 0.28 and close <= ma120: conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0) return RuleHit( rule_id=self.rule_id, cycle=WyckoffCycle.ACCUMULATION.value, confidence=min(90.0, conf), score=min(90.0, conf), reasons=["低趋势强度区间震荡", "疑似吸筹区间"], metrics={"adx": adx, "range_pct_60": range_pct}, ) return None class DistributionCycleRule(WyckoffRule): rule_id = "cycle_distribution" category = "cycle" timeframes = ("1M", "1w") def evaluate(self, context: dict[str, Any]) -> RuleHit | None: adx = _f(context, "adx") range_pct = _f(context, "range_pct_60") close = _f(context, "close") ma120 = _f(context, "ma120") vol_trend = _f(context, "volume_trend") # Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation) if adx < 22 and range_pct < 0.28 and close > ma120: conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0) return RuleHit( rule_id=self.rule_id, cycle=WyckoffCycle.DISTRIBUTION.value, confidence=min(90.0, conf), score=min(90.0, conf), reasons=["高位低趋势震荡", "疑似派发区间"], metrics={"adx": adx, "range_pct_60": range_pct}, ) return None def build_rules() -> list[WyckoffRule]: # Order: trend cycles first (more decisive), then range cycles return [ MarkupCycleRule(), MarkdownCycleRule(), AccumulationCycleRule(), DistributionCycleRule(), ]