"""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, *, role: str | None = None, ) -> dict: """Pure annotation: phase bands + event markers + latest levels. ``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe. ``step`` defaults by role to keep interactive charts snappy. """ tf = role or 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, *, combo_id: str | None = None, ) -> dict: """IO + annotate for one symbol (used by API). For the combo *low* chart, phase bands come from **mid** structure, while event markers / levels come from the low TF. """ from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo from crypto_wyckoff.io import load_frame combo = get_combo(combo_id) allowed = {combo["low"], combo["mid"], combo["high"]} if freq not in allowed: raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})") empty = { "ts_code": ts_code, "freq": freq, "phases": [], "events": [], "levels": {}, "zones": [], "bars": 0, "phase_source": freq, "cycles": [], "combo_id": combo["id"], } _ = end_date if freq == combo["low"]: low = load_frame(ts_code, combo["low"], lookback) mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3)) if low is None: return empty d_ann = annotate_frame(low, role=ROLE_LOW) w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []} cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else [] levels = d_ann.get("levels") or {} if cycles: levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")} 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": low.trade_dates[-1].isoformat() if low.trade_dates else None, "phases": w_ann.get("phases") or [], "events": d_ann.get("events") or [], "levels": d_ann.get("levels") or {}, "zones": _build_range_zones(low, cycles, levels), "bars": d_ann.get("bars", 0), "phase_source": combo["mid"], "cycles": cycles, "combo_id": combo["id"], } role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH frame = load_frame(ts_code, freq, lookback) if frame is None: return empty out = annotate_frame(frame, role=role) out["ts_code"] = ts_code out["freq"] = freq out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None out["phase_source"] = freq out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID) out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {}) out["combo_id"] = combo["id"] if role == ROLE_HIGH: 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, *, role: str | None = None, ) -> list[dict]: """Walk-forward cycle labels compressed to segments.""" tf = role or 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]