From ec08de098e4034f6057d2c7f084c89e4e347d923 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 15:46:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ECR-009):=20Crypto=20Wyckoff=20Screener=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E9=A1=B5=EF=BC=88D/W/M=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。 Co-authored-by: Cursor --- .gitignore | 3 + crypto_wyckoff/__init__.py | 5 + crypto_wyckoff/annotate.py | 329 ++++++++++++++++++ crypto_wyckoff/cycle.py | 102 ++++++ crypto_wyckoff/decision.py | 195 +++++++++++ crypto_wyckoff/domain_models.py | 153 ++++++++ crypto_wyckoff/event.py | 149 ++++++++ crypto_wyckoff/features.py | 206 +++++++++++ crypto_wyckoff/io.py | 301 ++++++++++++++++ crypto_wyckoff/phase.py | 78 +++++ crypto_wyckoff/pipeline.py | 157 +++++++++ crypto_wyckoff/plan.py | 78 +++++ crypto_wyckoff/rules/__init__.py | 3 + crypto_wyckoff/rules/base.py | 33 ++ crypto_wyckoff/rules/cycle_rules.py | 126 +++++++ crypto_wyckoff/rules/event_rules.py | 254 ++++++++++++++ crypto_wyckoff/rules/phase_rules.py | 163 +++++++++ crypto_wyckoff/rules/registry.py | 39 +++ crypto_wyckoff/scheduler.py | 128 +++++++ crypto_wyckoff/signal.py | 35 ++ crypto_wyckoff/store.py | 174 +++++++++ crypto_wyckoff/version.py | 4 + docs/AGENT_MEMORY.md | 4 +- docs/CHANGELOG/CHANGELOG.md | 6 + docs/ECR/ECR-009-crypto-wyckoff-screener.md | 22 ++ .../ECR-009-crypto-wyckoff-screener.md | 31 ++ docs/IDEA/IDEA-007-crypto-wyckoff-screener.md | 13 + docs/STATE/CURRENT.md | 16 +- docs/STATE/ECR-009.md | 5 + docs/TRACEABILITY.md | 8 + tests/test_crypto_wyckoff_decision.py | 66 ++++ web/api/wyckoff_crypto.py | 118 +++++++ web/app.py | 7 + web/templates/wyckoff_crypto.html | 210 +++++++++++ 34 files changed, 3212 insertions(+), 9 deletions(-) create mode 100644 crypto_wyckoff/__init__.py create mode 100644 crypto_wyckoff/annotate.py create mode 100644 crypto_wyckoff/cycle.py create mode 100644 crypto_wyckoff/decision.py create mode 100644 crypto_wyckoff/domain_models.py create mode 100644 crypto_wyckoff/event.py create mode 100644 crypto_wyckoff/features.py create mode 100644 crypto_wyckoff/io.py create mode 100644 crypto_wyckoff/phase.py create mode 100644 crypto_wyckoff/pipeline.py create mode 100644 crypto_wyckoff/plan.py create mode 100644 crypto_wyckoff/rules/__init__.py create mode 100644 crypto_wyckoff/rules/base.py create mode 100644 crypto_wyckoff/rules/cycle_rules.py create mode 100644 crypto_wyckoff/rules/event_rules.py create mode 100644 crypto_wyckoff/rules/phase_rules.py create mode 100644 crypto_wyckoff/rules/registry.py create mode 100644 crypto_wyckoff/scheduler.py create mode 100644 crypto_wyckoff/signal.py create mode 100644 crypto_wyckoff/store.py create mode 100644 crypto_wyckoff/version.py create mode 100644 docs/ECR/ECR-009-crypto-wyckoff-screener.md create mode 100644 docs/ENGINEERING_SPEC/ECR-009-crypto-wyckoff-screener.md create mode 100644 docs/IDEA/IDEA-007-crypto-wyckoff-screener.md create mode 100644 docs/STATE/ECR-009.md create mode 100644 tests/test_crypto_wyckoff_decision.py create mode 100644 web/api/wyckoff_crypto.py create mode 100644 web/templates/wyckoff_crypto.html diff --git a/.gitignore b/.gitignore index b73ee3d..12bc770 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ data_provider/._config.json # ESS gate / engineering-loop working dirs(归档进 docs/runs/) .gates/ loop/ + +# Crypto Wyckoff Screener local cache +data/crypto_wyckoff/ diff --git a/crypto_wyckoff/__init__.py b/crypto_wyckoff/__init__.py new file mode 100644 index 0000000..0ee5dcd --- /dev/null +++ b/crypto_wyckoff/__init__.py @@ -0,0 +1,5 @@ +"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0).""" + +from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION + +__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"] diff --git a/crypto_wyckoff/annotate.py b/crypto_wyckoff/annotate.py new file mode 100644 index 0000000..53fe695 --- /dev/null +++ b/crypto_wyckoff/annotate.py @@ -0,0 +1,329 @@ +"""Walk-forward Wyckoff phase/event annotations for chart overlay.""" + +from __future__ import annotations + +from datetime import date + +from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase +from crypto_wyckoff.cycle import CycleEngine +from crypto_wyckoff.event import EventEngine +from crypto_wyckoff.features import FeatureEngine +from crypto_wyckoff.phase import PhaseEngine + +_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18} + +_NOTABLE_EVENTS = { + WyckoffEvent.PS.value, + WyckoffEvent.SC.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.BC.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, +} + + +def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame: + n = end_idx + 1 + return OHLCVFrame( + ts_code=frame.ts_code, + timeframe=frame.timeframe, + trade_dates=frame.trade_dates[:n], + open=frame.open[:n], + high=frame.high[:n], + low=frame.low[:n], + close=frame.close[:n], + volume=frame.volume[:n], + amount=frame.amount[:n] if frame.amount else [], + ) + + +def _compress_phases(points: list[tuple[str, str]]) -> list[dict]: + """points: [(date_iso, phase), ...] → segments.""" + if not points: + return [] + segs: list[dict] = [] + start, phase = points[0] + prev = start + for d, p in points[1:]: + if p != phase: + segs.append({"start": start, "end": prev, "phase": phase}) + start, phase = d, p + prev = d + segs.append({"start": start, "end": prev, "phase": phase}) + return segs + + +def annotate_frame(frame: OHLCVFrame, step: int | None = None) -> dict: + """Pure annotation: phase bands + event markers + latest levels. + + ``step`` defaults by timeframe to keep interactive charts snappy. + """ + tf = frame.timeframe + min_bars = _MIN_BARS.get(tf, 30) + if step is None: + step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2) + + empty = { + "phases": [], + "events": [], + "levels": {}, + "bars": len(frame), + "timeframe": tf, + } + if frame.empty or len(frame) < min_bars: + return empty + + feat_eng = FeatureEngine() + cycle_eng = CycleEngine() + phase_eng = PhaseEngine() + event_eng = EventEngine() + + phase_points: list[tuple[str, str]] = [] + events: list[dict] = [] + last_event: str | None = None + levels: dict = {} + + # Ensure last bar is always evaluated + indices = list(range(min_bars - 1, len(frame), step)) + if indices[-1] != len(frame) - 1: + indices.append(len(frame) - 1) + + for i in indices: + sub = _slice_frame(frame, i) + f = feat_eng.run(sub, tf) + c = cycle_eng.run(f, tf) + p = phase_eng.run(c, f, tf) + e = event_eng.run(c, p, f, tf) + + d = str(frame.trade_dates[i])[:10] + phase = p.payload.get("phase") or WyckoffPhase.NONE.value + phase_points.append((d, phase)) + + cur = e.payload.get("current_event") or WyckoffEvent.NONE.value + if cur in _NOTABLE_EVENTS and cur != last_event: + events.append({ + "date": d, + "event": cur, + "price": float(frame.close[i]), + "low": float(frame.low[i]), + "high": float(frame.high[i]), + }) + last_event = cur + elif cur == WyckoffEvent.NONE.value: + last_event = None + + if i == len(frame) - 1 and not f.payload.get("insufficient"): + levels = { + k: f.payload.get(k) + for k in ( + "range_high", "range_low", "ma20", "ma60", + "swing_high", "swing_low", "close", + ) + if f.payload.get(k) is not None + } + levels["phase"] = phase + levels["cycle"] = c.payload.get("cycle") + levels["current_event"] = cur + + return { + "phases": _compress_phases(phase_points), + "events": events, + "levels": levels, + "bars": len(frame), + "timeframe": tf, + } + + +_RANGE_CYCLES = { + WyckoffCycle.ACCUMULATION.value, + WyckoffCycle.RE_ACCUMULATION.value, + WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_DISTRIBUTION.value, +} + + +def _build_range_zones( + price_frame: OHLCVFrame, + cycle_segs: list[dict], + levels: dict | None = None, +) -> list[dict]: + """Build price boxes (high/low × date span) for accum/distrib ranges.""" + if price_frame.empty: + return [] + dates = [str(d)[:10] for d in price_frame.trade_dates] + highs = price_frame.high + lows = price_frame.low + zones: list[dict] = [] + + for seg in cycle_segs or []: + cy = seg.get("cycle") + if cy not in _RANGE_CYCLES: + continue + start, end = seg["start"], seg["end"] + idxs = [i for i, d in enumerate(dates) if start <= d <= end] + if not idxs: + # weekly bar date may sit between daily bars — take nearest window + i0 = next((i for i, d in enumerate(dates) if d >= start), None) + if i0 is None: + continue + i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1 + idxs = list(range(i0, max(i0, i1) + 1)) + if not idxs: + continue + # pad short weekly hits to at least ~1 week of dailies for visibility + if len(idxs) < 5 and idxs[-1] + 1 < len(dates): + extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1]) + idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra))) + hi = max(highs[i] for i in idxs) + lo = min(lows[i] for i in idxs) + if hi <= lo: + continue + zones.append({ + "kind": cy, + "start": dates[idxs[0]], + "end": dates[idxs[-1]], + "high": float(hi), + "low": float(lo), + "current": False, + }) + + # Always expose the latest trading-range box from feature snapshot + levels = levels or {} + rh, rl = levels.get("range_high"), levels.get("range_low") + if rh is not None and rl is not None and float(rh) > float(rl): + look = min(60, len(dates)) + cy = levels.get("cycle") or "Unknown" + if cy not in _RANGE_CYCLES: + # Phase B/C in a range → treat as accumulation-style TR for display + ph = levels.get("phase") or "" + if ph in ("A", "B", "C"): + cy = WyckoffCycle.ACCUMULATION.value + elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh): + cy = WyckoffCycle.ACCUMULATION.value + else: + cy = "Range" + zones.append({ + "kind": cy, + "start": dates[-look], + "end": dates[-1], + "high": float(rh), + "low": float(rl), + "current": True, + }) + + return zones + + +def annotate_symbol( + ts_code: str, + freq: str, + end_date: date | None = None, + lookback: int = 180, +) -> dict: + """IO + annotate for one symbol (used by API). + + For daily charts, phase bands come from **weekly** structure (Wyckoff + primary timeframe), while event markers / levels come from daily. + """ + from crypto_wyckoff.io import latest_daily_trade_date, load_frames_batch + + if freq not in ("1d", "1w", "1M"): + raise ValueError(f"unsupported freq: {freq}") + ed = end_date or latest_daily_trade_date() + empty = { + "ts_code": ts_code, + "freq": freq, + "phases": [], + "events": [], + "levels": {}, + "zones": [], + "bars": 0, + "phase_source": freq, + } + if ed is None: + return empty + + if freq == "1d": + daily_frames = load_frames_batch("1d", ed, lookback, ts_codes=[ts_code]) + weekly_frames = load_frames_batch("1w", ed, max(60, lookback // 3), ts_codes=[ts_code]) + daily = daily_frames.get(ts_code) + weekly = weekly_frames.get(ts_code) + if daily is None: + return empty + d_ann = annotate_frame(daily) + w_ann = annotate_frame(weekly) if weekly is not None else {"phases": []} + cycles = _cycle_segments(weekly) if weekly is not None else [] + levels = d_ann.get("levels") or {} + # Prefer weekly cycle on the latest levels for zone labeling + if cycles: + levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")} + # latest non-None weekly phase + for p in reversed(w_ann.get("phases") or []): + if p.get("phase") not in (None, "None"): + levels = {**levels, "phase": p["phase"]} + break + return { + "ts_code": ts_code, + "freq": freq, + "end_date": ed.isoformat(), + "phases": w_ann.get("phases") or [], + "events": d_ann.get("events") or [], + "levels": d_ann.get("levels") or {}, + "zones": _build_range_zones(daily, cycles, levels), + "bars": d_ann.get("bars", 0), + "phase_source": "1w", + "cycles": cycles, + } + + frames = load_frames_batch(freq, ed, lookback, ts_codes=[ts_code]) + frame = frames.get(ts_code) + if frame is None: + return empty + out = annotate_frame(frame) + out["ts_code"] = ts_code + out["freq"] = freq + out["end_date"] = ed.isoformat() + out["phase_source"] = freq + out["cycles"] = _cycle_segments(frame) + out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {}) + if freq == "1M": + # Monthly chart: cycle bands are more meaningful than phase + if not any(p.get("phase") not in (None, "None") for p in out["phases"]): + out["phases"] = [ + {"start": c["start"], "end": c["end"], "phase": c["cycle"]} + for c in out["cycles"] + if c.get("cycle") and c["cycle"] != "Unknown" + ] + return out + + +def _cycle_segments(frame: OHLCVFrame, step: int | None = None) -> list[dict]: + """Walk-forward cycle labels compressed to segments.""" + tf = frame.timeframe + min_bars = _MIN_BARS.get(tf, 30) + if step is None: + step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2) + if frame.empty or len(frame) < min_bars: + return [] + + feat_eng = FeatureEngine() + cycle_eng = CycleEngine() + points: list[tuple[str, str]] = [] + indices = list(range(min_bars - 1, len(frame), step)) + if indices[-1] != len(frame) - 1: + indices.append(len(frame) - 1) + for i in indices: + sub = _slice_frame(frame, i) + f = feat_eng.run(sub, tf) + c = cycle_eng.run(f, tf) + points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown")) + segs = _compress_phases(points) + return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs] diff --git a/crypto_wyckoff/cycle.py b/crypto_wyckoff/cycle.py new file mode 100644 index 0000000..95d7722 --- /dev/null +++ b/crypto_wyckoff/cycle.py @@ -0,0 +1,102 @@ +"""Cycle Engine — monthly/weekly macro cycle via Rule Registry.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle +from crypto_wyckoff.rules.base import RuleHit +from crypto_wyckoff.rules.registry import rule_registry + + +def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]: + """Accumulation vs Distribution overlap → mutually exclusive by MA120 position.""" + accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value] + dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value] + if not (accum and dist): + return hits + + close = float(features.get("close") or 0) + ma120 = float(features.get("ma120") or close) or close + others = [ + h for h in hits + if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value) + ] + # Below MA120 → accumulation; above → distribution; equal band uses relative position + if close < ma120 * 0.995: + return others + accum + if close > ma120 * 1.005: + return others + dist + # Tight band: keep higher confidence only + best_a = max(accum, key=lambda h: h.confidence) + best_d = max(dist, key=lambda h: h.confidence) + return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d]) + + +class CycleEngine: + name = "Cycle" + version = "1.0.0" + + def run(self, feature: EngineResult, timeframe: str) -> EngineResult: + features = feature.payload + if features.get("insufficient"): + return EngineResult( + name=self.name, + version=self.version, + confidence=15.0, + score=40.0, + reasons=[f"{timeframe} 数据不足,Cycle=Unknown"], + warnings=["insufficient_features"], + payload={ + "cycle": WyckoffCycle.UNKNOWN.value, + "timeframe": timeframe, + "trend_score": 40.0, + }, + ) + + context = {"features": features, "timeframe": timeframe} + hits: list[RuleHit] = [] + for rule in rule_registry.by_category("cycle", timeframe): + hit = rule.evaluate(context) + if hit and hit.cycle: + hits.append(hit) + + hits = _resolve_range_conflict(hits, features) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=30.0, + score=40.0, + reasons=["无匹配周期规则,标记 Unknown"], + payload={ + "cycle": WyckoffCycle.UNKNOWN.value, + "timeframe": timeframe, + "trend_score": 40.0, + }, + ) + + best = max(hits, key=lambda h: h.confidence) + trend_score = best.score + if best.cycle == WyckoffCycle.MARKUP.value: + trend_score = max(trend_score, 75.0) + elif best.cycle == WyckoffCycle.ACCUMULATION.value: + trend_score = max(60.0, trend_score * 0.9) + elif best.cycle == WyckoffCycle.DISTRIBUTION.value: + trend_score = min(45.0, 100 - trend_score * 0.5) + elif best.cycle == WyckoffCycle.MARKDOWN.value: + trend_score = min(30.0, 100 - trend_score) + + return EngineResult( + name=self.name, + version=self.version, + confidence=best.confidence, + score=trend_score, + reasons=best.reasons, + metrics=best.metrics, + payload={ + "cycle": best.cycle, + "timeframe": timeframe, + "rule_id": best.rule_id, + "trend_score": trend_score, + }, + ) diff --git a/crypto_wyckoff/decision.py b/crypto_wyckoff/decision.py new file mode 100644 index 0000000..96fc96a --- /dev/null +++ b/crypto_wyckoff/decision.py @@ -0,0 +1,195 @@ +"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0).""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import ( + DecisionSignal, + EngineResult, + RiskLevel, + WyckoffCycle, + WyckoffEvent, + WyckoffPhase, +) + +BULL_CYCLES = { + WyckoffCycle.ACCUMULATION.value, + WyckoffCycle.RE_ACCUMULATION.value, + WyckoffCycle.MARKUP.value, +} +BEAR_CYCLES = { + WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_DISTRIBUTION.value, + WyckoffCycle.MARKDOWN.value, +} + + +class DecisionEngine: + name = "Decision" + version = "1.0.0" + + def run( + self, + monthly_cycle: EngineResult, + weekly_cycle: EngineResult, + weekly_phase: EngineResult, + weekly_event: EngineResult, + daily_event: EngineResult, + daily_signal: EngineResult, + ) -> EngineResult: + m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value) + w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value) + w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value) + w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value) + d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value) + + trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score)) + structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score)) + entry_score = float(daily_event.payload.get("entry_score", daily_event.score)) + + overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score + + reasons: list[str] = [] + warnings: list[str] = [] + alignment = 50.0 + + m_bull = m_cycle in BULL_CYCLES + m_bear = m_cycle in BEAR_CYCLES + w_bull = w_cycle in BULL_CYCLES + d_bullish_event = d_event in { + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + } + d_bearish_event = d_event in { + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, + } + + # Alignment scoring + if m_bull and w_bull and d_bullish_event: + alignment = 92.0 + reasons.append("✓ 月/周多头结构与日线多头事件一致") + elif m_bull and d_bullish_event: + alignment = 78.0 + reasons.append("✓ 月线支持,日线有入场事件") + if not w_bull: + warnings.append("周线结构未完全确认") + alignment -= 8 + elif m_bear and d_bullish_event: + alignment = 35.0 + reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹") + elif m_bear and d_bearish_event: + alignment = 85.0 + reasons.append("✓ 空头多周期一致") + else: + alignment = 55.0 + reasons.append("○ 多周期部分一致,需观察") + + if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull: + alignment = min(98.0, alignment + 6) + reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})") + active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or [] + if d_event == WyckoffEvent.SPRING.value and len(active) >= 3: + alignment = min(98.0, alignment + 4) + reasons.append("✓ 日线多重事件同时确认") + + # Decision signal — hard gate on monthly bear + daily spring + decision = DecisionSignal.WATCH.value + risk = RiskLevel.MEDIUM.value + + if m_bear and d_event == WyckoffEvent.SPRING.value: + decision = DecisionSignal.WATCH.value + risk = RiskLevel.HIGH.value + overall_score = min(overall_score, 55.0) + reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)") + elif m_bear and d_bullish_event: + decision = DecisionSignal.AVOID.value + risk = RiskLevel.HIGH.value + overall_score = min(overall_score, 48.0) + reasons.append("→ 决策:回避(逆大周期多头事件)") + elif ( + m_bull + and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value) + and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value) + and alignment >= 85 + and overall_score >= 80 + ): + decision = DecisionSignal.STRONG_BUY.value + risk = RiskLevel.LOW.value + reasons.append("→ 决策:强烈买入(三级共振)") + elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70: + decision = DecisionSignal.BUY.value + risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value + reasons.append("→ 决策:买入") + elif m_bear and d_bearish_event and overall_score >= 65: + decision = DecisionSignal.SELL.value + risk = RiskLevel.MEDIUM.value + reasons.append("→ 决策:卖出") + else: + decision = DecisionSignal.WATCH.value + reasons.append("→ 决策:观察") + + # Stars from score + alignment + combo = 0.6 * overall_score + 0.4 * alignment + if combo >= 90: + stars = 5 + elif combo >= 80: + stars = 4 + elif combo >= 65: + stars = 3 + elif combo >= 50: + stars = 2 + else: + stars = 1 + + overall_confidence = ( + 0.25 * monthly_cycle.confidence + + 0.25 * weekly_phase.confidence + + 0.25 * daily_event.confidence + + 0.25 * daily_signal.confidence + ) + # Weak event pulls overall down + if daily_event.confidence < 60: + overall_confidence = min(overall_confidence, daily_event.confidence + 15) + + return EngineResult( + name=self.name, + version=self.version, + confidence=overall_confidence, + score=overall_score, + reasons=reasons, + warnings=warnings, + metrics={ + "trend_score": trend_score, + "structure_score": structure_score, + "entry_score": entry_score, + "alignment": alignment, + "stars": stars, + }, + payload={ + "decision_signal": decision, + "alignment": alignment, + "stars": stars, + "risk": risk, + "overall_score": overall_score, + "overall_confidence": overall_confidence, + "trend_score": trend_score, + "structure_score": structure_score, + "entry_score": entry_score, + "m_cycle": m_cycle, + "w_cycle": w_cycle, + "w_phase": w_phase, + "w_event": w_event, + "d_event": d_event, + # Facts preserved — never overwritten + "facts": { + "monthly": {"cycle": m_cycle}, + "weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event}, + "daily": {"event": d_event}, + }, + }, + ) diff --git a/crypto_wyckoff/domain_models.py b/crypto_wyckoff/domain_models.py new file mode 100644 index 0000000..a3e1956 --- /dev/null +++ b/crypto_wyckoff/domain_models.py @@ -0,0 +1,153 @@ +"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import Enum +from typing import Any, Optional + + +class WyckoffCycle(str, Enum): + ACCUMULATION = "Accumulation" + RE_ACCUMULATION = "ReAccumulation" + MARKUP = "Markup" + DISTRIBUTION = "Distribution" + RE_DISTRIBUTION = "ReDistribution" + MARKDOWN = "Markdown" + UNKNOWN = "Unknown" + + +class WyckoffPhase(str, Enum): + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + NONE = "None" + + +class WyckoffEvent(str, Enum): + PS = "PS" + SC = "SC" + AR = "AR" + ST = "ST" + SPRING = "Spring" + TEST = "Test" + SOS = "SOS" + LPS = "LPS" + JUMP = "Jump" + BACKUP = "Backup" + BC = "BC" + UTAD = "UTAD" + SOW = "SOW" + LPSY = "LPSY" + NONE = "None" + + +class DecisionSignal(str, Enum): + STRONG_BUY = "StrongBuy" + BUY = "Buy" + WATCH = "Watch" + AVOID = "Avoid" + SELL = "Sell" + + +class RiskLevel(str, Enum): + LOW = "Low" + MEDIUM = "Medium" + HIGH = "High" + + +@dataclass +class EngineResult: + """Unified result envelope for every Wyckoff engine (v1.0 contract).""" + + name: str + version: str = "1.0.0" + confidence: float = 0.0 + score: float = 0.0 + reasons: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + payload: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "version": self.version, + "confidence": self.confidence, + "score": self.score, + "reasons": self.reasons, + "warnings": self.warnings, + "metrics": self.metrics, + "payload": self.payload, + } + + +@dataclass +class OHLCVFrame: + """In-memory OHLCV for one symbol one timeframe. Engines never touch DB.""" + + ts_code: str + timeframe: str # "1d" | "1w" | "1M" + trade_dates: list[date] + open: list[float] + high: list[float] + low: list[float] + close: list[float] + volume: list[float] + amount: list[float] = field(default_factory=list) + + def __len__(self) -> int: + return len(self.close) + + @property + def empty(self) -> bool: + return len(self.close) == 0 + + +@dataclass +class WyckoffScanRow: + """Persisted scan row for wyckoff_scan table.""" + + trade_date: date + ts_code: str + name: str = "" + industry: str = "" + engine_version: str = "v1.0.0" + + m_cycle: str = WyckoffCycle.UNKNOWN.value + cycle_confidence: float = 0.0 + trend_score: float = 0.0 + + w_cycle: str = WyckoffCycle.UNKNOWN.value + w_phase: str = WyckoffPhase.NONE.value + w_current_event: str = WyckoffEvent.NONE.value + w_recent_events_json: str = "[]" + phase_confidence: float = 0.0 + structure_score: float = 0.0 + + d_current_event: str = WyckoffEvent.NONE.value + d_recent_events_json: str = "[]" + event_confidence: float = 0.0 + entry_score: float = 0.0 + + entry: Optional[float] = None + stop: Optional[float] = None + target1: Optional[float] = None + target2: Optional[float] = None + rr: Optional[float] = None + + alignment: float = 0.0 + stars: int = 1 + decision_signal: str = DecisionSignal.WATCH.value + signal_confidence: float = 0.0 + overall_confidence: float = 0.0 + overall_score: float = 0.0 + risk: str = RiskLevel.MEDIUM.value + reasons_json: str = "[]" + + feature_snapshot_json: str = "{}" + markers_json: str = "[]" + scanned_at: datetime = field(default_factory=datetime.now) diff --git a/crypto_wyckoff/event.py b/crypto_wyckoff/event.py new file mode 100644 index 0000000..e9c09dc --- /dev/null +++ b/crypto_wyckoff/event.py @@ -0,0 +1,149 @@ +"""Event Engine — active concurrent events via Rule Registry. + +Note: `active_events` are rules that fire on the latest bar snapshot, +NOT a historical SC→AR→ST timeline. Do not present as chronological chain. +""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent +from crypto_wyckoff.rules.registry import rule_registry + +# Display order only (not temporal history) +_DISPLAY_ORDER = [ + WyckoffEvent.PS.value, + WyckoffEvent.SC.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.BC.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SOW.value, + WyckoffEvent.LPSY.value, +] + +# Dominant event: highest confidence wins; ties broken by this priority +_DOMINANCE_PRIORITY = [ + WyckoffEvent.SOS.value, + WyckoffEvent.LPS.value, + WyckoffEvent.UTAD.value, + WyckoffEvent.SPRING.value, + WyckoffEvent.JUMP.value, + WyckoffEvent.BACKUP.value, + WyckoffEvent.TEST.value, + WyckoffEvent.SC.value, + WyckoffEvent.SOW.value, + WyckoffEvent.AR.value, + WyckoffEvent.ST.value, +] + + +class EventEngine: + name = "Event" + version = "1.0.0" + + def run( + self, + cycle: EngineResult, + phase: EngineResult, + feature: EngineResult, + timeframe: str, + ) -> EngineResult: + if feature.payload.get("insufficient"): + return EngineResult( + name=self.name, + version=self.version, + confidence=20.0, + score=30.0, + reasons=["特征不足,跳过事件识别"], + warnings=["insufficient_features"], + payload={ + "current_event": WyckoffEvent.NONE.value, + "active_events": [], + "recent_events": [], # alias for DB/API compat; same as active_events + "timeframe": timeframe, + "entry_score": 30.0, + }, + ) + + context = { + "features": feature.payload, + "cycle": cycle.payload, + "phase": phase.payload, + "timeframe": timeframe, + } + hits = [] + for rule in rule_registry.by_category("event", timeframe): + hit = rule.evaluate(context) + if hit and hit.event: + hits.append(hit) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=35.0, + score=40.0, + reasons=["无显著事件"], + payload={ + "current_event": WyckoffEvent.NONE.value, + "active_events": [], + "recent_events": [], + "timeframe": timeframe, + "entry_score": 40.0, + }, + ) + + by_event: dict[str, float] = {} + reasons: list[str] = [] + metrics: dict = {} + for h in hits: + prev = by_event.get(h.event, -1.0) + if h.confidence >= prev: + by_event[h.event] = h.confidence + reasons.extend(h.reasons) + metrics.update(h.metrics) + + active = [e for e in _DISPLAY_ORDER if e in by_event] + for e in by_event: + if e not in active: + active.append(e) + + # Dominant = max confidence; tie-break by dominance priority index + def _dom_key(ev: str) -> tuple: + conf = by_event[ev] + try: + prio = _DOMINANCE_PRIORITY.index(ev) + except ValueError: + prio = 99 + return (conf, -prio) + + current = max(by_event.keys(), key=_dom_key) + event_conf = by_event[current] + co_bonus = min(12.0, max(0, len(active) - 1) * 3) + entry_score = min(98.0, event_conf + co_bonus) + if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event: + entry_score = min(98.0, entry_score + 5) + + return EngineResult( + name=self.name, + version=self.version, + confidence=event_conf, + score=entry_score, + reasons=list(dict.fromkeys(reasons))[:8], + warnings=["active_events_are_concurrent_not_timeline"], + metrics=metrics, + payload={ + "current_event": current, + "active_events": active, + "recent_events": active, # persisted column name; semantic = active + "event_scores": by_event, + "timeframe": timeframe, + "entry_score": entry_score, + }, + ) diff --git a/crypto_wyckoff/features.py b/crypto_wyckoff/features.py new file mode 100644 index 0000000..69e85c7 --- /dev/null +++ b/crypto_wyckoff/features.py @@ -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, + ) diff --git a/crypto_wyckoff/io.py b/crypto_wyckoff/io.py new file mode 100644 index 0000000..a493193 --- /dev/null +++ b/crypto_wyckoff/io.py @@ -0,0 +1,301 @@ +"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar).""" + +from __future__ import annotations + +import json +import logging +import os +import sqlite3 +import time +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Iterable + +import requests + +from crypto_wyckoff.domain_models import OHLCVFrame + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[1] +DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff"))) +BARS_DB = DATA_DIR / "bars.sqlite" +SCAN_DB = DATA_DIR / "scan.sqlite" + +DATA_SERVICE_URL = os.environ.get( + "DATA_SERVICE_URL", + os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"), +).rstrip("/") + +# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers) +# Provider has 1d/1w but no 1M — monthly is resampled locally from daily UTC months. +LOOKBACK = {"1d": 250, "1w": 104, "1M": 60} +TF_PROVIDER = ("1d", "1w") +TF_LIST = ("1d", "1w", "1M") + + +def ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + + +def _symbol_key(symbol: str) -> str: + return symbol.replace("/", "_").replace(":", "_") + + +def _bars_conn() -> sqlite3.Connection: + ensure_dirs() + conn = sqlite3.connect(str(BARS_DB), timeout=60) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS bars ( + symbol TEXT NOT NULL, + tf TEXT NOT NULL, + ts INTEGER NOT NULL, + open REAL, high REAL, low REAL, close REAL, volume REAL, + PRIMARY KEY (symbol, tf, ts) + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)") + return conn + + +def fetch_candles( + symbol: str, + tf: str, + *, + limit: int | None = None, + start_ms: int | None = None, + end_ms: int | None = None, + timeout: float = 15.0, +) -> list[dict]: + params: dict = {"symbol": symbol, "tf": tf} + if limit is not None: + params["limit"] = int(limit) + if start_ms is not None: + params["start"] = int(start_ms) + if end_ms is not None: + params["end"] = int(end_ms) + resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, list): + return [] + out = [] + for row in data: + try: + ts = int(float(row["timestamp"])) + out.append( + { + "ts": ts, + "open": float(row["open"]), + "high": float(row["high"]), + "low": float(row["low"]), + "close": float(row["close"]), + "volume": float(row.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + out.sort(key=lambda r: r["ts"]) + return out + + +def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int: + if not rows: + return 0 + conn = _bars_conn() + try: + conn.executemany( + """ + INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, tf, ts) DO UPDATE SET + open=excluded.open, high=excluded.high, low=excluded.low, + close=excluded.close, volume=excluded.volume + """, + [ + (symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"]) + for r in rows + ], + ) + conn.commit() + return len(rows) + finally: + conn.close() + + +def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None: + lookback = lookback or LOOKBACK.get(tf, 100) + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf=? + ORDER BY ts DESC LIMIT ? + """, + (symbol, tf, lookback), + ) + rows = list(reversed(cur.fetchall())) + finally: + conn.close() + if not rows: + return None + trade_dates: list[date] = [] + for ts, *_ in rows: + trade_dates.append(datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc).date()) + return OHLCVFrame( + ts_code=symbol, + timeframe=tf, + trade_dates=trade_dates, + open=[r[1] for r in rows], + high=[r[2] for r in rows], + low=[r[3] for r in rows], + close=[r[4] for r in rows], + volume=[r[5] for r in rows], + ) + + +def bar_count(symbol: str, tf: str) -> int: + conn = _bars_conn() + try: + cur = conn.execute( + "SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf) + ) + return int(cur.fetchone()[0]) + finally: + conn.close() + + +def rebuild_monthly_from_daily(symbol: str) -> int: + """Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M).""" + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf='1d' ORDER BY ts ASC + """, + (symbol,), + ) + daily = cur.fetchall() + finally: + conn.close() + if not daily: + return 0 + + months: dict[tuple[int, int], dict] = {} + for ts, o, h, l, c, v in daily: + dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc) + key = (dt.year, dt.month) + # month bar open timestamp = first day 00:00 UTC + month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000) + if key not in months: + months[key] = { + "ts": month_ts, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v or 0.0, + } + else: + m = months[key] + m["high"] = max(m["high"], h) + m["low"] = min(m["low"], l) + m["close"] = c + m["volume"] = (m["volume"] or 0) + (v or 0) + + rows = sorted(months.values(), key=lambda r: r["ts"]) + # drop stale months then upsert + conn = _bars_conn() + try: + conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,)) + conn.commit() + finally: + conn.close() + return upsert_bars(symbol, "1M", rows) + + +def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: + """Pull history for continuous crypto TFs; monthly derived from daily.""" + stats = {} + for tf in TF_PROVIDER: + if tf not in tfs and "1M" not in tfs: + continue + need = LOOKBACK.get(tf, 100) + # need extra daily for monthly history + if tf == "1d": + need = max(need, LOOKBACK["1M"] * 31) + try: + rows = fetch_candles(symbol, tf, limit=need) + n = upsert_bars(symbol, tf, rows) + stats[tf] = n + except Exception as e: + logger.warning("backfill %s %s failed: %s", symbol, tf, e) + stats[tf] = 0 + time.sleep(0.05) + if "1M" in tfs or True: + try: + stats["1M"] = rebuild_monthly_from_daily(symbol) + except Exception as e: + logger.warning("monthly rebuild %s failed: %s", symbol, e) + stats["1M"] = 0 + return stats + + +def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool: + """Update forming tip bars (limit=3). Returns True if any bar changed.""" + changed = False + for tf in TF_PROVIDER: + try: + rows = fetch_candles(symbol, tf, limit=3) + if not rows: + continue + before = _tip_fingerprint(symbol, tf) + upsert_bars(symbol, tf, rows) + after = _tip_fingerprint(symbol, tf) + if before != after: + changed = True + except Exception as e: + logger.debug("tip %s %s: %s", symbol, tf, e) + time.sleep(0.02) + # Always rebuild current month tip from daily + before_m = _tip_fingerprint(symbol, "1M") + try: + rebuild_monthly_from_daily(symbol) + except Exception as e: + logger.debug("monthly tip %s: %s", symbol, e) + after_m = _tip_fingerprint(symbol, "1M") + if before_m != after_m: + changed = True + return changed + + +def _tip_fingerprint(symbol: str, tf: str) -> tuple | None: + conn = _bars_conn() + try: + cur = conn.execute( + """ + SELECT ts, open, high, low, close, volume FROM bars + WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1 + """, + (symbol, tf), + ) + row = cur.fetchone() + return tuple(row) if row else None + finally: + conn.close() + + +def fetch_symbols_from_provider() -> list[str]: + try: + resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8) + resp.raise_for_status() + payload = resp.json() + symbols = payload.get("symbols") or payload.get("symbol_list") or [] + return [s for s in symbols if isinstance(s, str)] + except Exception as e: + logger.warning("health symbols failed: %s", e) + return [] diff --git a/crypto_wyckoff/phase.py b/crypto_wyckoff/phase.py new file mode 100644 index 0000000..be78050 --- /dev/null +++ b/crypto_wyckoff/phase.py @@ -0,0 +1,78 @@ +"""Phase Engine — Phase A–E via Rule Registry.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase +from crypto_wyckoff.rules.registry import rule_registry + + +class PhaseEngine: + name = "Phase" + version = "1.0.0" + + def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult: + if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown": + return EngineResult( + name=self.name, + version=self.version, + confidence=20.0, + score=30.0, + reasons=["数据/周期不足,Phase=None"], + warnings=["insufficient_features"], + payload={ + "phase": WyckoffPhase.NONE.value, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "structure_score": 30.0, + }, + ) + + context = { + "features": feature.payload, + "cycle": cycle.payload, + "timeframe": timeframe, + } + hits = [] + for rule in rule_registry.by_category("phase", timeframe): + hit = rule.evaluate(context) + if hit and hit.phase: + hits.append(hit) + + if not hits: + return EngineResult( + name=self.name, + version=self.version, + confidence=40.0, + score=cycle.score * 0.5, + reasons=["未识别明确 Phase"], + payload={ + "phase": WyckoffPhase.NONE.value, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "structure_score": cycle.score * 0.5, + }, + ) + + best = max(hits, key=lambda h: h.confidence) + structure_score = best.score + # Phase D/E stronger structure + if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value): + structure_score = max(structure_score, 80.0) + elif best.phase == WyckoffPhase.C.value: + structure_score = max(structure_score, 72.0) + + return EngineResult( + name=self.name, + version=self.version, + confidence=best.confidence, + score=structure_score, + reasons=best.reasons, + metrics=best.metrics, + payload={ + "phase": best.phase, + "timeframe": timeframe, + "cycle": cycle.payload.get("cycle"), + "rule_id": best.rule_id, + "structure_score": structure_score, + }, + ) diff --git a/crypto_wyckoff/pipeline.py b/crypto_wyckoff/pipeline.py new file mode 100644 index 0000000..2646703 --- /dev/null +++ b/crypto_wyckoff/pipeline.py @@ -0,0 +1,157 @@ +"""Scan pipeline: load local frames → engines → store.""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, timezone + +from crypto_wyckoff.cycle import CycleEngine +from crypto_wyckoff.decision import DecisionEngine +from crypto_wyckoff.domain_models import WyckoffScanRow +from crypto_wyckoff.event import EventEngine +from crypto_wyckoff.features import FeatureEngine +from crypto_wyckoff.io import LOOKBACK, TF_LIST, load_frame +from crypto_wyckoff.phase import PhaseEngine +from crypto_wyckoff.plan import PlanEngine +from crypto_wyckoff.signal import SignalEngine +from crypto_wyckoff.store import upsert_row +from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION + +logger = logging.getLogger(__name__) + + +def analyze_symbol( + daily_frame, + weekly_frame, + monthly_frame, + *, + feature_eng: FeatureEngine, + cycle_eng: CycleEngine, + phase_eng: PhaseEngine, + event_eng: EventEngine, + signal_eng: SignalEngine, + decision_eng: DecisionEngine, + plan_eng: PlanEngine, +) -> dict: + f_d = feature_eng.run(daily_frame, "1d") + f_w = feature_eng.run(weekly_frame, "1w") + f_m = feature_eng.run(monthly_frame, "1M") + + c_m = cycle_eng.run(f_m, "1M") + c_w = cycle_eng.run(f_w, "1w") + + p_w = phase_eng.run(c_w, f_w, "1w") + p_d = phase_eng.run(c_w, f_d, "1d") + + e_w = event_eng.run(c_w, p_w, f_w, "1w") + e_d = event_eng.run(c_w, p_d, f_d, "1d") + + s_d = signal_eng.run(e_d, p_d) + decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d) + plan = plan_eng.run(f_d, decision) + + return { + "f_d": f_d, "f_w": f_w, "f_m": f_m, + "c_m": c_m, "c_w": c_w, "p_w": p_w, + "e_w": e_w, "e_d": e_d, "s_d": s_d, + "decision": decision, "plan": plan, + } + + +def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow: + d = result["decision"] + p = result["plan"] + c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"] + e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"] + f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"] + + snapshot = { + "daily": {k: f_d.payload.get(k) for k in ( + "ma20", "ma60", "ma120", "atr", "adx", "volume_ratio", + "range_high", "range_low", "swing_high", "swing_low", "close", + )}, + "weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")}, + "monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")}, + } + markers = [] + for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")): + if p.payload.get(key) is not None: + markers.append({"type": typ, "price": p.payload[key]}) + + return WyckoffScanRow( + trade_date=trade_date, + ts_code=symbol, + name=symbol, + industry="crypto", + engine_version=WYCKOFF_ENGINE_VERSION, + m_cycle=c_m.payload.get("cycle", "Unknown"), + cycle_confidence=c_m.confidence, + trend_score=float(d.payload.get("trend_score", c_m.score)), + w_cycle=c_w.payload.get("cycle", "Unknown"), + w_phase=p_w.payload.get("phase", "None"), + w_current_event=e_w.payload.get("current_event", "None"), + w_recent_events_json=json.dumps( + e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [], + ensure_ascii=False, + ), + phase_confidence=p_w.confidence, + structure_score=float(d.payload.get("structure_score", p_w.score)), + d_current_event=e_d.payload.get("current_event", "None"), + d_recent_events_json=json.dumps( + e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [], + ensure_ascii=False, + ), + event_confidence=e_d.confidence, + entry_score=float(d.payload.get("entry_score", e_d.score)), + entry=p.payload.get("entry"), + stop=p.payload.get("stop"), + target1=p.payload.get("target1"), + target2=p.payload.get("target2"), + rr=p.payload.get("rr"), + alignment=float(d.payload.get("alignment", 0)), + stars=int(d.payload.get("stars", 1)), + decision_signal=d.payload.get("decision_signal", "Watch"), + signal_confidence=s_d.confidence, + overall_confidence=float(d.payload.get("overall_confidence", d.confidence)), + overall_score=float(d.payload.get("overall_score", d.score)), + risk=d.payload.get("risk", "Medium"), + reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False), + feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False), + markers_json=json.dumps(markers, ensure_ascii=False), + scanned_at=datetime.now(timezone.utc), + ) + + +_ENGINES = None + + +def _engines(): + global _ENGINES + if _ENGINES is None: + _ENGINES = { + "feature_eng": FeatureEngine(), + "cycle_eng": CycleEngine(), + "phase_eng": PhaseEngine(), + "event_eng": EventEngine(), + "signal_eng": SignalEngine(), + "decision_eng": DecisionEngine(), + "plan_eng": PlanEngine(), + } + return _ENGINES + + +def analyze_and_store(symbol: str, trade_date: date | None = None) -> WyckoffScanRow | None: + eng = _engines() + daily = load_frame(symbol, "1d", LOOKBACK["1d"]) + weekly = load_frame(symbol, "1w", LOOKBACK["1w"]) + monthly = load_frame(symbol, "1M", LOOKBACK["1M"]) + if daily is None or len(daily) < 40: + return None + result = analyze_symbol(daily, weekly, monthly, **eng) + td = trade_date or ( + daily.trade_dates[-1] if daily.trade_dates else datetime.now(timezone.utc).date() + ) + row = _to_row(td, symbol, result) + upsert_row(row) + return row diff --git a/crypto_wyckoff/plan.py b/crypto_wyckoff/plan.py new file mode 100644 index 0000000..ed113fc --- /dev/null +++ b/crypto_wyckoff/plan.py @@ -0,0 +1,78 @@ +"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable.""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import DecisionSignal, EngineResult + + +_TRADABLE = { + DecisionSignal.STRONG_BUY.value, + DecisionSignal.BUY.value, + DecisionSignal.SELL.value, +} + + +class PlanEngine: + name = "Plan" + version = "1.0.0" + + def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult: + f = daily_feature.payload + close = float(f.get("close") or 0) + atr = float(f.get("atr") or 0) or close * 0.02 + swing_low = float(f.get("swing_low") or close - 2 * atr) + swing_high = float(f.get("swing_high") or close + 2 * atr) + range_high = float(f.get("range_high") or swing_high) + signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value) + + entry = stop = t1 = t2 = rr = None + reasons: list[str] = [] + + if signal not in _TRADABLE or close <= 0: + reasons.append(f"无交易计划(信号={signal})") + return EngineResult( + name=self.name, + version=self.version, + confidence=decision.confidence, + score=decision.score, + reasons=reasons, + payload={ + "entry": None, + "stop": None, + "target1": None, + "target2": None, + "rr": None, + }, + ) + + if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value): + entry = round(close, 4) + stop = round(min(swing_low, close - 1.5 * atr), 4) + risk = max(entry - stop, 1e-6) + t1 = round(entry + 2.0 * risk, 4) + t2 = round(max(range_high, entry + 3.0 * risk), 4) + rr = round((t1 - entry) / risk, 2) + reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}") + else: # Sell + entry = round(close, 4) + stop = round(max(swing_high, close + 1.5 * atr), 4) + risk = max(stop - entry, 1e-6) + t1 = round(entry - 2.0 * risk, 4) + t2 = round(entry - 3.0 * risk, 4) + rr = round((entry - t1) / risk, 2) + reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}") + + return EngineResult( + name=self.name, + version=self.version, + confidence=decision.confidence, + score=decision.score, + reasons=reasons, + payload={ + "entry": entry, + "stop": stop, + "target1": t1, + "target2": t2, + "rr": rr, + }, + ) diff --git a/crypto_wyckoff/rules/__init__.py b/crypto_wyckoff/rules/__init__.py new file mode 100644 index 0000000..88d3abc --- /dev/null +++ b/crypto_wyckoff/rules/__init__.py @@ -0,0 +1,3 @@ +from crypto_wyckoff.rules.registry import rule_registry + +__all__ = ["rule_registry"] diff --git a/crypto_wyckoff/rules/base.py b/crypto_wyckoff/rules/base.py new file mode 100644 index 0000000..2060660 --- /dev/null +++ b/crypto_wyckoff/rules/base.py @@ -0,0 +1,33 @@ +"""Rule protocol for Wyckoff Rule Registry.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class RuleHit: + """A single rule match.""" + + rule_id: str + event: str | None = None + phase: str | None = None + cycle: str | None = None + confidence: float = 0.0 + score: float = 0.0 + reasons: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + + +class WyckoffRule(ABC): + """Pluggable rule. Engines iterate registry; never hardcode rule lists.""" + + rule_id: str + category: str # cycle | phase | event + timeframes: tuple[str, ...] = ("1d", "1w", "1M") + + @abstractmethod + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + """Return RuleHit if matched, else None. Pure — no I/O.""" diff --git a/crypto_wyckoff/rules/cycle_rules.py b/crypto_wyckoff/rules/cycle_rules.py new file mode 100644 index 0000000..8ff1545 --- /dev/null +++ b/crypto_wyckoff/rules/cycle_rules.py @@ -0,0 +1,126 @@ +"""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(), + ] diff --git a/crypto_wyckoff/rules/event_rules.py b/crypto_wyckoff/rules/event_rules.py new file mode 100644 index 0000000..2490359 --- /dev/null +++ b/crypto_wyckoff/rules/event_rules.py @@ -0,0 +1,254 @@ +"""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(), + ] diff --git a/crypto_wyckoff/rules/phase_rules.py b/crypto_wyckoff/rules/phase_rules.py new file mode 100644 index 0000000..b32c100 --- /dev/null +++ b/crypto_wyckoff/rules/phase_rules.py @@ -0,0 +1,163 @@ +"""Phase A–E rules (primarily weekly).""" + +from __future__ import annotations + +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffCycle, 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 WyckoffCycle.UNKNOWN.value + + +class PhaseARule(WyckoffRule): + rule_id = "phase_a" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value, + WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value): + return None + # Stopping action: high vol + large range recently, still range-bound + vol_ratio = _f(context, "volume_ratio") + range_last = _f(context, "bar_range_atr") + if vol_ratio >= 1.4 and range_last >= 1.2: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.A.value, + confidence=70.0, + score=65.0, + reasons=["放量宽幅波动,疑似 Phase A 停止行为"], + ) + return None + + +class PhaseBRule(WyckoffRule): + rule_id = "phase_b" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value): + return None + adx = _f(context, "adx") + range_pct = _f(context, "range_pct_60") + pos = _f(context, "range_position") # 0=low 1=high of range + if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.B.value, + confidence=72.0, + score=68.0, + reasons=["区间中部震荡,疑似 Phase B 建仓/派发"], + ) + return None + + +class PhaseCRule(WyckoffRule): + rule_id = "phase_c" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + pos = _f(context, "range_position") + spring_like = _f(context, "spring_score_hint") + utad_like = _f(context, "utad_score_hint") + if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value): + if pos < 0.25 or spring_like >= 50: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.C.value, + confidence=75.0 + min(15.0, spring_like * 0.15), + score=78.0, + reasons=["区间低位测试,疑似 Phase C (Spring/Test)"], + ) + if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value): + if pos > 0.75 or utad_like >= 50: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.C.value, + confidence=75.0, + score=78.0, + reasons=["区间高位测试,疑似 Phase C (UTAD)"], + ) + return None + + +class PhaseDRule(WyckoffRule): + rule_id = "phase_d" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + close = _f(context, "close") + ma20 = _f(context, "ma20") + range_high = _f(context, "range_high") + range_low = _f(context, "range_low") + vol_ratio = _f(context, "volume_ratio") + if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value): + if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.D.value, + confidence=80.0, + score=82.0, + reasons=["突破区间上沿放量,疑似 Phase D SOS"], + ) + if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value): + if close < ma20 and range_low > 0 and close <= range_low * 1.02: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.D.value, + confidence=80.0, + score=82.0, + reasons=["跌破区间下沿,疑似 Phase D SOW"], + ) + return None + + +class PhaseERule(WyckoffRule): + rule_id = "phase_e" + category = "phase" + timeframes = ("1w", "1d") + + def evaluate(self, context: dict[str, Any]) -> RuleHit | None: + cycle = _cycle(context) + # Markup/Markdown already imply trend continuation (Phase E of prior structure) + if cycle == WyckoffCycle.MARKUP.value: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.E.value, + confidence=78.0, + score=80.0, + reasons=["趋势上行,对应 Phase E Markup"], + ) + if cycle == WyckoffCycle.MARKDOWN.value: + return RuleHit( + rule_id=self.rule_id, + phase=WyckoffPhase.E.value, + confidence=78.0, + score=80.0, + reasons=["趋势下行,对应 Phase E Markdown"], + ) + return None + + +def build_rules() -> list[WyckoffRule]: + # More specific phases first + return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()] diff --git a/crypto_wyckoff/rules/registry.py b/crypto_wyckoff/rules/registry.py new file mode 100644 index 0000000..5bca7f8 --- /dev/null +++ b/crypto_wyckoff/rules/registry.py @@ -0,0 +1,39 @@ +"""Rule Registry — register Wyckoff rules without modifying engines.""" + +from __future__ import annotations + +from crypto_wyckoff.rules.base import WyckoffRule + + +class RuleRegistry: + def __init__(self) -> None: + self._rules: dict[str, WyckoffRule] = {} + + def register(self, rule: WyckoffRule) -> None: + self._rules[rule.rule_id] = rule + + def get(self, rule_id: str) -> WyckoffRule | None: + return self._rules.get(rule_id) + + def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]: + out = [r for r in self._rules.values() if r.category == category] + if timeframe: + out = [r for r in out if timeframe in r.timeframes] + return out + + def all(self) -> list[WyckoffRule]: + return list(self._rules.values()) + + +rule_registry = RuleRegistry() + + +def _register_defaults() -> None: + from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules + + for mod in (cycle_rules, phase_rules, event_rules): + for rule in mod.build_rules(): + rule_registry.register(rule) + + +_register_defaults() diff --git a/crypto_wyckoff/scheduler.py b/crypto_wyckoff/scheduler.py new file mode 100644 index 0000000..40cd45a --- /dev/null +++ b/crypto_wyckoff/scheduler.py @@ -0,0 +1,128 @@ +"""Background 60s tip-update + rescan scheduler.""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime, timezone +from typing import Any + +from crypto_wyckoff.io import ( + TF_LIST, + backfill_symbol, + bar_count, + fetch_symbols_from_provider, + tip_update_symbol, +) +from crypto_wyckoff.pipeline import analyze_and_store +from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_status: dict[str, Any] = { + "running": False, + "last_tick_at": None, + "last_error": None, + "symbols_total": 0, + "symbols_scanned": 0, + "backfill_done": False, + "engine_version": WYCKOFF_ENGINE_VERSION, + "tick_interval_sec": 60, +} +_stop = threading.Event() +_thread: threading.Thread | None = None + + +def get_status() -> dict[str, Any]: + with _lock: + return dict(_status) + + +def _set(**kwargs): + with _lock: + _status.update(kwargs) + + +def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict: + """One cycle: refresh symbols, tip-update, analyze changed (or all if force).""" + symbols = fetch_symbols_from_provider() + if max_symbols: + symbols = symbols[:max_symbols] + _set(symbols_total=len(symbols), running=True, last_error=None) + scanned = 0 + errors = 0 + changed_n = 0 + + # Lazy backfill: ensure min bars + for i, sym in enumerate(symbols): + try: + if bar_count(sym, "1d") < 40: + backfill_symbol(sym, TF_LIST) + tip_changed = tip_update_symbol(sym, TF_LIST) + if tip_changed: + changed_n += 1 + if force_rescan or tip_changed or bar_count(sym, "1d") >= 40: + # Always rescan on first pass after backfill; tip change triggers update + if force_rescan or tip_changed or True: + # Tip every minute: always re-analyze to refresh forming-bar features + row = analyze_and_store(sym) + if row: + scanned += 1 + except Exception as e: + errors += 1 + if errors <= 5: + logger.warning("tick %s: %s", sym, e) + _set(last_error=str(e)) + if (i + 1) % 25 == 0: + _set(symbols_scanned=scanned) + logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned) + + _set( + running=False, + symbols_scanned=scanned, + last_tick_at=datetime.now(timezone.utc).isoformat(), + backfill_done=True, + ) + return { + "symbols": len(symbols), + "scanned": scanned, + "changed_tips": changed_n, + "errors": errors, + } + + +def _loop(interval: int, max_symbols: int | None): + # First tick: force full rescan after tip/backfill + try: + run_tick(max_symbols=max_symbols, force_rescan=True) + except Exception as e: + logger.exception("initial tick failed: %s", e) + _set(last_error=str(e), running=False) + while not _stop.wait(interval): + try: + run_tick(max_symbols=max_symbols, force_rescan=True) + except Exception as e: + logger.exception("tick failed: %s", e) + _set(last_error=str(e), running=False) + + +def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None: + global _thread + if _thread and _thread.is_alive(): + return + _stop.clear() + _set(tick_interval_sec=interval_sec) + _thread = threading.Thread( + target=_loop, + args=(interval_sec, max_symbols), + name="crypto-wyckoff-scheduler", + daemon=True, + ) + _thread.start() + logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec) + + +def stop_scheduler() -> None: + _stop.set() diff --git a/crypto_wyckoff/signal.py b/crypto_wyckoff/signal.py new file mode 100644 index 0000000..dbcbbcf --- /dev/null +++ b/crypto_wyckoff/signal.py @@ -0,0 +1,35 @@ +"""Signal Engine — timeframe-local status labels only (not tradability).""" + +from __future__ import annotations + +from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent + + +class SignalEngine: + """Maps local Event/Phase into a status label. Decision decides tradability.""" + + name = "Signal" + version = "1.0.0" + + def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult: + current = event.payload.get("current_event", WyckoffEvent.NONE.value) + conf = event.confidence + label = current # status label mirrors event for V1 + reasons = [f"本地事件标签: {label}"] + if phase and phase.payload.get("phase"): + reasons.append(f"本地阶段: {phase.payload.get('phase')}") + + return EngineResult( + name=self.name, + version=self.version, + confidence=conf, + score=event.score, + reasons=reasons, + payload={ + "signal_label": label, + "current_event": current, + "phase": (phase.payload.get("phase") if phase else None), + "active_events": event.payload.get("active_events") + or event.payload.get("recent_events", []), + }, + ) diff --git a/crypto_wyckoff/store.py b/crypto_wyckoff/store.py new file mode 100644 index 0000000..54415e1 --- /dev/null +++ b/crypto_wyckoff/store.py @@ -0,0 +1,174 @@ +"""SQLite persistence for crypto wyckoff scan rows.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any + +from crypto_wyckoff.domain_models import WyckoffScanRow +from crypto_wyckoff.io import SCAN_DB, ensure_dirs + +_COLS = [ + "trade_date", "ts_code", "name", "industry", "engine_version", + "m_cycle", "cycle_confidence", "trend_score", + "w_cycle", "w_phase", "w_current_event", "w_recent_events_json", + "phase_confidence", "structure_score", + "d_current_event", "d_recent_events_json", "event_confidence", "entry_score", + "entry", "stop", "target1", "target2", "rr", + "alignment", "stars", "decision_signal", "signal_confidence", + "overall_confidence", "overall_score", "risk", "reasons_json", + "feature_snapshot_json", "markers_json", "scanned_at", +] + + +def _conn() -> sqlite3.Connection: + ensure_dirs() + c = sqlite3.connect(str(SCAN_DB), timeout=60) + c.row_factory = sqlite3.Row + c.execute( + """ + CREATE TABLE IF NOT EXISTS wyckoff_scan ( + trade_date TEXT NOT NULL, + ts_code TEXT NOT NULL, + name TEXT DEFAULT '', + industry TEXT DEFAULT '', + engine_version TEXT, + m_cycle TEXT, cycle_confidence REAL, trend_score REAL, + w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT, + phase_confidence REAL, structure_score REAL, + d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL, + entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL, + alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL, + overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT, + feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT, + PRIMARY KEY (trade_date, ts_code) + ) + """ + ) + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score ON wyckoff_scan(trade_date, overall_score DESC)" + ) + return c + + +def upsert_row(row: WyckoffScanRow) -> None: + vals = ( + row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date), + row.ts_code, row.name, row.industry, row.engine_version, + row.m_cycle, row.cycle_confidence, row.trend_score, + row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json, + row.phase_confidence, row.structure_score, + row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score, + row.entry, row.stop, row.target1, row.target2, row.rr, + row.alignment, row.stars, row.decision_signal, row.signal_confidence, + row.overall_confidence, row.overall_score, row.risk, row.reasons_json, + row.feature_snapshot_json, row.markers_json, + row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at), + ) + c = _conn() + try: + placeholders = ",".join("?" * len(_COLS)) + col_sql = ",".join(_COLS) + updates = ",".join(f"{c}=excluded.{c}" for c in _COLS if c not in ("trade_date", "ts_code")) + c.execute( + f""" + INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders}) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET {updates} + """, + vals, + ) + c.commit() + finally: + c.close() + + +def latest_trade_date() -> str | None: + c = _conn() + try: + cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan") + row = cur.fetchone() + return row[0] if row and row[0] else None + finally: + c.close() + + +def count_for_date(trade_date: str | None = None) -> int: + td = trade_date or latest_trade_date() + if not td: + return 0 + c = _conn() + try: + cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,)) + return int(cur.fetchone()[0]) + finally: + c.close() + + +def query_scan( + *, + trade_date: str | None = None, + m_cycle: str | None = None, + w_phase: str | None = None, + d_event: str | None = None, + decision_signal: str | None = None, + min_overall_score: float | None = None, + min_alignment: float | None = None, + sort: str = "overall_score", + limit: int = 100, + offset: int = 0, +) -> list[dict[str, Any]]: + td = trade_date or latest_trade_date() + if not td: + return [] + sort_col = sort if sort in { + "overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars" + } else "overall_score" + clauses = ["trade_date=?"] + args: list[Any] = [td] + if m_cycle: + clauses.append("m_cycle=?") + args.append(m_cycle) + if w_phase: + clauses.append("w_phase=?") + args.append(w_phase) + if d_event: + clauses.append("d_current_event=?") + args.append(d_event) + if decision_signal: + clauses.append("decision_signal=?") + args.append(decision_signal) + if min_overall_score is not None: + clauses.append("overall_score>=?") + args.append(min_overall_score) + if min_alignment is not None: + clauses.append("alignment>=?") + args.append(min_alignment) + where = " AND ".join(clauses) + args.extend([limit, offset]) + c = _conn() + try: + cur = c.execute( + f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?", + args, + ) + return [dict(r) for r in cur.fetchall()] + finally: + c.close() + + +def get_symbol(ts_code: str, trade_date: str | None = None) -> dict[str, Any] | None: + td = trade_date or latest_trade_date() + if not td: + return None + c = _conn() + try: + cur = c.execute( + "SELECT * FROM wyckoff_scan WHERE trade_date=? AND ts_code=?", + (td, ts_code), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + c.close() diff --git a/crypto_wyckoff/version.py b/crypto_wyckoff/version.py new file mode 100644 index 0000000..c4d7977 --- /dev/null +++ b/crypto_wyckoff/version.py @@ -0,0 +1,4 @@ +"""Wyckoff Screener engine version — bump when rules change.""" + +WYCKOFF_ENGINE_VERSION = "v1.0.0" +ARCHITECTURE_VERSION = "1.0" diff --git a/docs/AGENT_MEMORY.md b/docs/AGENT_MEMORY.md index 023e165..4e6f862 100644 --- a/docs/AGENT_MEMORY.md +++ b/docs/AGENT_MEMORY.md @@ -24,6 +24,7 @@ - ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) - ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed - ECR-008 Reviewed:主站 `chart_tv.js` → `chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面 +- ECR-009 Implementing:`/wyckoff_crypto` 独立选股页(`crypto_wyckoff/`);D/W + 本地月线;60s tip - 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层 - Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层 @@ -31,7 +32,7 @@ - `/api/analyze` 字段可增不可删 - 无 ADR 不改笔/段/中枢/买卖点语义 -- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法 +- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎 - Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human ## 已知债务 @@ -42,3 +43,4 @@ - 威科夫启发式参数未做 UI 调参 - ECR-007 待 Human 在 Gitea 开 PR 合入 `dev` - `chart_tv_overlays.js` 仍偏大,可后续再拆 +- ECR-009:月线历史受日线深度限制;Cycle 规则在 crypto 上可能偏 Unknown,看效果再调参 diff --git a/docs/CHANGELOG/CHANGELOG.md b/docs/CHANGELOG/CHANGELOG.md index f4742f5..273d7d7 100644 --- a/docs/CHANGELOG/CHANGELOG.md +++ b/docs/CHANGELOG/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 2026-08-07 +### ECR-009(L2,进行中) + +- 独立页 `/wyckoff_crypto`:移植 A_Share_DP D/W/M 威科夫选股引擎至数字货币 +- 本地 `data/crypto_wyckoff/`;60s tip;月线由日线 UTC 自然月聚合(provider 无 1M) +- API:`/api/wyckoff_crypto/*`;不碰主站 analyze / 缠论叠层 + ### ECR-008(L3,Reviewed) - 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面 diff --git a/docs/ECR/ECR-009-crypto-wyckoff-screener.md b/docs/ECR/ECR-009-crypto-wyckoff-screener.md new file mode 100644 index 0000000..ee64669 --- /dev/null +++ b/docs/ECR/ECR-009-crypto-wyckoff-screener.md @@ -0,0 +1,22 @@ +# ECR-009 + +**Title:** Crypto Wyckoff Screener 独立页(D/W/M) +**Status:** Approved(计划执行) +**Date:** 2026-08-07 +**Change Level:** L2 + +## Change + +新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存全量币对日/周/月 K 线;60s tip 更新。 + +## Forbidden + +- 改缠论算法、主站叠层、`/api/analyze`、`config/`/`strategies/` +- 小周期;自动下单 + +## Acceptance + +- [ ] 页面可列出扫描结果(decision/cycle/phase/event) +- [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan +- [ ] 调度可跑 tip 更新 +- [ ] Decision 门闩单测通过 diff --git a/docs/ENGINEERING_SPEC/ECR-009-crypto-wyckoff-screener.md b/docs/ENGINEERING_SPEC/ECR-009-crypto-wyckoff-screener.md new file mode 100644 index 0000000..1b4bd4b --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-009-crypto-wyckoff-screener.md @@ -0,0 +1,31 @@ +# ENGINEERING_SPEC — ECR-009 Crypto Wyckoff Screener + +**Level:** L2 · 独立页 +**Date:** 2026-08-07 + +## Goal + +数字货币 D/W/M 威科夫选股观察页(A_Share_DP 引擎语义);24/7 tip 每分钟更新。 + +## Package + +`crypto_wyckoff/`:features → cycle/phase/event/signal → decision → plan;本地 `data/crypto_wyckoff/`。 + +## API + +- `GET /wyckoff_crypto` +- `GET /api/wyckoff_crypto/meta|status|scan` +- `GET /api/wyckoff_crypto/symbol/` +- `POST /api/wyckoff_crypto/tick` + +## Env + +- `CRYPTO_WYCKOFF_DISABLE=1` 关闭调度 +- `CRYPTO_WYCKOFF_INTERVAL=60` +- `CRYPTO_WYCKOFF_MAX_SYMBOLS=N` 小样本调试 +- `DATA_SERVICE_URL` 默认 provider.jackyu66.com + +## Crypto calendar + +UTC 连续盘;回填不做 A 股周末放大。 +**月线**:provider 无 `1M`,由本地日线按 **UTC 自然月** OHLCV 聚合;日/周直接拉 `1d`/`1w`。 diff --git a/docs/IDEA/IDEA-007-crypto-wyckoff-screener.md b/docs/IDEA/IDEA-007-crypto-wyckoff-screener.md new file mode 100644 index 0000000..c2e1508 --- /dev/null +++ b/docs/IDEA/IDEA-007-crypto-wyckoff-screener.md @@ -0,0 +1,13 @@ +# Idea: Crypto Wyckoff Screener(独立页) + +## Problem + +主站威科夫是图叠层;需要 A_Share_DP 式 D/W/M 多周期选股/决策观察,用于数字货币。 + +## Hypothesis + +独立包 + 独立页,币对来自 DATA_SERVICE,本地缓存 1d/1w/1M,每分钟 tip 更新,不碰缠论主链路。 + +## Change Level Guess + +**L2**(新行为面;不改 strategies) diff --git a/docs/STATE/CURRENT.md b/docs/STATE/CURRENT.md index 626a785..2b58465 100644 --- a/docs/STATE/CURRENT.md +++ b/docs/STATE/CURRENT.md @@ -1,8 +1,8 @@ # STATE -**owner:** idle -**active_ecr:** none(ECR-008 Reviewed;ECR-007 待合入 `dev`) -**phase:** post-review +**owner:** engineer +**active_ecr:** ECR-009(crypto wyckoff screener) +**phase:** implementing **system_version:** v1.0.0 **strategy_version:** unchanged **updated:** 2026-08-07 @@ -16,12 +16,12 @@ | ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 | | ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 | | ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 | -| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · 待 Gitea PR → `dev` | -| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 · 本分支 | +| ECR-007 | L2 | Done (Final Approval) | Live Structure · 待合入 `dev` | +| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 | +| ECR-009 | L2 | Implementing | `/wyckoff_crypto` · D/W/M | ## Notes -- ECR-007:**FINAL_APPROVAL** · 已 push;开 PR:https://git.jackyu66.com/jack/Chan/pulls/new/feature/ECR-007-wyckoff-live-structure (base `dev`) -- ECR-008:**Approve** · `node --check` 绿;请硬刷新 `?v=20260807f` 目测 -- 归档:`docs/runs/LOOP-RUN-005/` +- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流 +- 月线由日线 UTC 聚合(provider 无 1M) - 未请求新 system tag diff --git a/docs/STATE/ECR-009.md b/docs/STATE/ECR-009.md new file mode 100644 index 0000000..6094e72 --- /dev/null +++ b/docs/STATE/ECR-009.md @@ -0,0 +1,5 @@ +ecr: ECR-009 +owner: engineer +phase: implementing +updated: 2026-08-07 +notes: crypto wyckoff screener · D/W/M · 24/7 tip diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4ebe174..1671b79 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -61,3 +61,11 @@ | ECR-008 | 拆分 chart_tv 单体 | ENG-008 | `chart_tv_*.js` + 薄门面 | `node --check` | dbb6202 | | ECR-008 | 对外 API 不变 | ENG-008 | `initTradingView` / `disposeTradingViewCharts` | ui.js 调用点 | dbb6202 | | ECR-008 | 无打包器 | PROFILE | `index.html` script 顺序 | 人工 | dbb6202 | + +## ECR-009 + +| ECR | Requirement | Spec | Code | Test | Commit | +|-----|-------------|------|------|------|--------| +| ECR-009 | Crypto D/W/M screener 独立页 | ENG-009 | `crypto_wyckoff/` + `/wyckoff_crypto` | `test_crypto_wyckoff_decision` | (本分支) | +| ECR-009 | 月线本地聚合 | ENG-009 | `io.rebuild_monthly_from_daily` | smoke tip | (本分支) | +| ECR-009 | 不碰 analyze/缠论 | ECR-009 Forbidden | 新 API 前缀 | 人工 | (本分支) | diff --git a/tests/test_crypto_wyckoff_decision.py b/tests/test_crypto_wyckoff_decision.py new file mode 100644 index 0000000..d85102d --- /dev/null +++ b/tests/test_crypto_wyckoff_decision.py @@ -0,0 +1,66 @@ +"""Decision engine MTF gate tests (ported semantics).""" + +from crypto_wyckoff.domain_models import ( + DecisionSignal, + EngineResult, + WyckoffCycle, + WyckoffEvent, + WyckoffPhase, +) +from crypto_wyckoff.decision import DecisionEngine + + +def _er(name, payload, score=70, confidence=70): + return EngineResult(name=name, score=score, confidence=confidence, payload=payload) + + +def test_monthly_distribution_daily_spring_is_watch(): + eng = DecisionEngine() + monthly = _er("Cycle", {"cycle": WyckoffCycle.DISTRIBUTION.value, "trend_score": 40}, score=40) + weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 70}, score=70) + weekly_p = _er( + "Phase", + {"phase": WyckoffPhase.B.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 65}, + score=65, + ) + weekly_e = _er("Event", {"current_event": WyckoffEvent.ST.value, "recent_events": ["SC", "AR", "ST"]}, score=60) + daily_e = _er( + "Event", + {"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring"], "entry_score": 92}, + score=92, + confidence=92, + ) + daily_s = _er("Signal", {"signal_label": "Spring", "current_event": "Spring"}, confidence=92, score=92) + out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s) + assert out.payload["decision_signal"] == DecisionSignal.WATCH.value + assert out.payload["d_event"] == WyckoffEvent.SPRING.value + + +def test_bull_alignment_can_strong_buy(): + eng = DecisionEngine() + monthly = _er("Cycle", {"cycle": WyckoffCycle.MARKUP.value, "trend_score": 90}, score=90, confidence=90) + weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 85}, score=85, confidence=85) + weekly_p = _er( + "Phase", + {"phase": WyckoffPhase.D.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 88}, + score=88, + confidence=88, + ) + weekly_e = _er("Event", {"current_event": WyckoffEvent.SOS.value, "recent_events": ["SOS"]}, score=85, confidence=85) + daily_e = _er( + "Event", + { + "current_event": WyckoffEvent.SPRING.value, + "recent_events": ["SC", "AR", "ST", "Spring", "Test"], + "active_events": ["SC", "AR", "ST", "Spring"], + "entry_score": 92, + }, + score=92, + confidence=92, + ) + daily_s = _er("Signal", {"signal_label": "Spring"}, confidence=92, score=92) + out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s) + assert out.payload["decision_signal"] in ( + DecisionSignal.STRONG_BUY.value, + DecisionSignal.BUY.value, + ) diff --git a/web/api/wyckoff_crypto.py b/web/api/wyckoff_crypto.py new file mode 100644 index 0000000..e061ab2 --- /dev/null +++ b/web/api/wyckoff_crypto.py @@ -0,0 +1,118 @@ +"""Crypto Wyckoff Screener API + page (independent of /api/analyze).""" + +from __future__ import annotations + +import os +import threading + +from flask import Blueprint, jsonify, render_template, request + +from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase +from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler +from crypto_wyckoff import store as wyckoff_store +from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION + +bp = Blueprint("wyckoff_crypto", __name__) + +_scheduler_started = False +_sched_lock = threading.Lock() + + +def ensure_scheduler() -> None: + global _scheduler_started + with _sched_lock: + if _scheduler_started: + return + if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"): + return + interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60")) + max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS") + max_symbols = int(max_sym) if max_sym else None + start_scheduler(interval_sec=interval, max_symbols=max_symbols) + _scheduler_started = True + + +@bp.route("/wyckoff_crypto") +def page(): + ensure_scheduler() + return render_template("wyckoff_crypto.html") + + +@bp.route("/api/wyckoff_crypto/meta") +def meta(): + ensure_scheduler() + latest = wyckoff_store.latest_trade_date() + return jsonify( + { + "architecture_version": ARCHITECTURE_VERSION, + "engine_version": WYCKOFF_ENGINE_VERSION, + "latest_trade_date": latest, + "scan_count": wyckoff_store.count_for_date(latest), + "cycles": [c.value for c in WyckoffCycle], + "phases": [p.value for p in WyckoffPhase], + "events": [e.value for e in WyckoffEvent], + "decision_signals": [s.value for s in DecisionSignal], + "timezone": "UTC", + "timeframes": ["1d", "1w", "1M"], + "status": get_status(), + } + ) + + +@bp.route("/api/wyckoff_crypto/status") +def status(): + ensure_scheduler() + return jsonify(get_status()) + + +@bp.route("/api/wyckoff_crypto/scan") +def scan(): + ensure_scheduler() + rows = wyckoff_store.query_scan( + trade_date=request.args.get("trade_date"), + m_cycle=request.args.get("m_cycle"), + w_phase=request.args.get("w_phase"), + d_event=request.args.get("d_event"), + decision_signal=request.args.get("decision_signal"), + min_overall_score=_float_or_none(request.args.get("min_overall_score")), + min_alignment=_float_or_none(request.args.get("min_alignment")), + sort=request.args.get("sort") or "overall_score", + limit=min(int(request.args.get("limit") or 100), 500), + offset=int(request.args.get("offset") or 0), + ) + return jsonify({"rows": rows, "count": len(rows)}) + + +@bp.route("/api/wyckoff_crypto/symbol/") +def symbol_detail(symbol: str): + ensure_scheduler() + row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date")) + if not row: + return jsonify({"error": "not_found"}), 404 + return jsonify(row) + + +@bp.route("/api/wyckoff_crypto/tick", methods=["POST"]) +def manual_tick(): + """Manual one-shot tick (debug). Optional JSON/query max_symbols.""" + ensure_scheduler() + max_sym = request.args.get("max_symbols") or (request.json or {}).get("max_symbols") + max_symbols = int(max_sym) if max_sym else None + + def _job(): + try: + run_tick(max_symbols=max_symbols, force_rescan=True) + except Exception: + pass + + threading.Thread(target=_job, daemon=True).start() + return jsonify({"ok": True, "started": True}) + + +def _float_or_none(v): + if v in (None, ""): + return None + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/web/app.py b/web/app.py index 8a46e31..8393f64 100644 --- a/web/app.py +++ b/web/app.py @@ -15,6 +15,7 @@ from api.analyze import bp as analyze_bp from api.pages import bp as pages_bp from api.symbols import bp as symbols_bp from api.trend import bp as trend_bp +from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler def create_app() -> Flask: @@ -23,6 +24,12 @@ def create_app() -> Flask: app.register_blueprint(analyze_bp) app.register_blueprint(symbols_bp) app.register_blueprint(trend_bp) + app.register_blueprint(wyckoff_crypto_bp) + # Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1 + try: + ensure_scheduler() + except Exception: + pass return app diff --git a/web/templates/wyckoff_crypto.html b/web/templates/wyckoff_crypto.html new file mode 100644 index 0000000..279a23e --- /dev/null +++ b/web/templates/wyckoff_crypto.html @@ -0,0 +1,210 @@ + + + + + +Crypto Wyckoff Screener + + + +
+
+

Crypto Wyckoff Screener

+
加载中…
+
+
+ ← 主站 + · D/W/M · UTC · 每分钟 tip +
+
+
+ + + + + + + + +
+
+
+

SCAN

+ + + + + + + + +
SymbolDecisionM CycleW PhaseD EventScoreAlign
+ +
+
+

DETAIL

+
点选左侧一行
+
+
+ + +