diff --git a/src/ashare_dp/apps/api/app.py b/src/ashare_dp/apps/api/app.py index 7fb491e..eecba2c 100644 --- a/src/ashare_dp/apps/api/app.py +++ b/src/ashare_dp/apps/api/app.py @@ -11,8 +11,10 @@ from fastapi.responses import HTMLResponse from loguru import logger from ashare_dp.apps.api.routers import stocks, kline, realtime, calendar +from ashare_dp.apps.api.routers.wyckoff import router as wyckoff_router from ashare_dp.apps.api.dashboard.router import router as dashboard_router from ashare_dp.apps.api.dashboard.html import DASHBOARD_HTML +from ashare_dp.apps.api.wyckoff.html import WYCKOFF_HTML from ashare_dp.core.models import Freq from ashare_dp.data.store.repository import KLineRepository from ashare_dp.apps.api.websocket.handlers import router as ws_router @@ -572,6 +574,11 @@ def create_app() -> FastAPI: """Trading OS Dashboard — professional trader decision support.""" return DASHBOARD_HTML + @app.get("/wyckoff", response_class=HTMLResponse) + async def wyckoff_page(): + """Wyckoff Screener — multi-timeframe stock discovery.""" + return WYCKOFF_HTML + @app.get("/api/v1/screening/ema52") async def api_ema52_screening( freq: str = Query("1d", description="Frequency: 1d or 1w"), @@ -617,6 +624,7 @@ def create_app() -> FastAPI: app.include_router(realtime.router, prefix="/api/v1") app.include_router(calendar.router, prefix="/api/v1") app.include_router(dashboard_router, prefix="/api/v1") + app.include_router(wyckoff_router, prefix="/api/v1") app.include_router(ws_router) return app diff --git a/src/ashare_dp/apps/api/routers/wyckoff.py b/src/ashare_dp/apps/api/routers/wyckoff.py new file mode 100644 index 0000000..5e57c3f --- /dev/null +++ b/src/ashare_dp/apps/api/routers/wyckoff.py @@ -0,0 +1,170 @@ +"""Wyckoff Screener REST API.""" + +from __future__ import annotations + +from datetime import date + +from fastapi import APIRouter, HTTPException, Query + +from ashare_dp.domain.wyckoff import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase +from ashare_dp.wyckoff import store as wyckoff_store +from ashare_dp.wyckoff.annotate import annotate_symbol +from ashare_dp.wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION + +router = APIRouter(prefix="/wyckoff", tags=["wyckoff"]) + + +@router.get("/meta") +async def wyckoff_meta(): + latest = wyckoff_store.latest_trade_date() + count = wyckoff_store.count_for_date(latest) if latest else 0 + dist = wyckoff_store.facet_counts(latest) if latest else {} + return { + "architecture_version": ARCHITECTURE_VERSION, + "engine_version": WYCKOFF_ENGINE_VERSION, + "latest_trade_date": latest.isoformat() if latest else None, + "scan_count": count, + "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], + "facets": dist, + "sort_fields": [ + "overall_score", "alignment", "entry_score", "trend_score", "structure_score", + ], + } + + +@router.get("/scan") +async def wyckoff_scan( + trade_date: str | None = Query(None), + m_cycle: str | None = Query(None), + w_phase: str | None = Query(None), + d_event: str | None = Query(None), + decision_signal: str | None = Query(None), + industry: str | None = Query(None), + min_overall_score: float | None = Query(None), + min_alignment: float | None = Query(None), + engine_version: str | None = Query(None), + sort: str = Query("overall_score"), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + td = date.fromisoformat(trade_date) if trade_date else None + rows = wyckoff_store.query_scan( + trade_date=td, + m_cycle=m_cycle, + w_phase=w_phase, + d_event=d_event, + decision_signal=decision_signal, + industry=industry, + min_overall_score=min_overall_score, + min_alignment=min_alignment, + engine_version=engine_version, + sort=sort, + limit=limit, + offset=offset, + ) + # list projection + items = [] + for r in rows: + items.append({ + "ts_code": r["ts_code"], + "name": r.get("name"), + "industry": r.get("industry"), + "m_cycle": r.get("m_cycle"), + "w_cycle": r.get("w_cycle"), + "w_phase": r.get("w_phase"), + "w_current_event": r.get("w_current_event"), + "d_current_event": r.get("d_current_event"), + "alignment": r.get("alignment"), + "stars": r.get("stars"), + "decision_signal": r.get("decision_signal"), + "overall_score": r.get("overall_score"), + "trend_score": r.get("trend_score"), + "structure_score": r.get("structure_score"), + "entry_score": r.get("entry_score"), + "risk": r.get("risk"), + "engine_version": r.get("engine_version"), + "trade_date": str(r.get("trade_date"))[:10], + }) + return { + "trade_date": str(rows[0]["trade_date"])[:10] if rows else (trade_date or None), + "count": len(items), + "items": items, + } + + +@router.get("/scan/{ts_code}/overlay") +async def wyckoff_overlay( + ts_code: str, + freq: str = Query("1d", description="1d | 1w | 1M"), + bars: int = Query(180, ge=60, le=400), +): + """Walk-forward phase bands + event markers for chart overlay.""" + if freq not in ("1d", "1w", "1M"): + raise HTTPException(status_code=400, detail="freq 仅支持 1d / 1w / 1M") + try: + return annotate_symbol(ts_code, freq=freq, lookback=bars) + except Exception as e: + raise HTTPException(status_code=500, detail=f"标注失败: {e}") from e + + +@router.get("/scan/{ts_code}") +async def wyckoff_detail(ts_code: str, trade_date: str | None = Query(None)): + td = date.fromisoformat(trade_date) if trade_date else None + row = wyckoff_store.get_detail(ts_code, td) + if not row: + raise HTTPException(status_code=404, detail="Wyckoff scan not found") + return { + "ts_code": row["ts_code"], + "name": row.get("name"), + "industry": row.get("industry"), + "trade_date": str(row.get("trade_date"))[:10], + "engine_version": row.get("engine_version"), + "monthly": { + "cycle": row.get("m_cycle"), + "confidence": row.get("cycle_confidence"), + "trend_score": row.get("trend_score"), + }, + "weekly": { + "cycle": row.get("w_cycle"), + "phase": row.get("w_phase"), + "current_event": row.get("w_current_event"), + "active_events": row.get("w_active_events") or row.get("w_recent_events"), + "recent_events": row.get("w_active_events") or row.get("w_recent_events"), + "confidence": row.get("phase_confidence"), + "structure_score": row.get("structure_score"), + }, + "daily": { + "current_event": row.get("d_current_event"), + "active_events": row.get("d_active_events") or row.get("d_recent_events"), + "recent_events": row.get("d_active_events") or row.get("d_recent_events"), + "confidence": row.get("event_confidence"), + "entry_score": row.get("entry_score"), + }, + "decision": { + "signal": row.get("decision_signal"), + "alignment": row.get("alignment"), + "stars": row.get("stars"), + "overall_score": row.get("overall_score"), + "overall_confidence": row.get("overall_confidence"), + "signal_confidence": row.get("signal_confidence"), + "risk": row.get("risk"), + "scores": { + "trend": row.get("trend_score"), + "structure": row.get("structure_score"), + "entry": row.get("entry_score"), + }, + "reasons": row.get("reasons") or row.get("reasons_json"), + }, + "plan": { + "entry": row.get("entry"), + "stop": row.get("stop"), + "target1": row.get("target1"), + "target2": row.get("target2"), + "rr": row.get("rr"), + }, + "feature_snapshot": row.get("feature_snapshot") or row.get("feature_snapshot_json"), + "markers": row.get("markers") or row.get("markers_json"), + } diff --git a/src/ashare_dp/apps/api/wyckoff/__init__.py b/src/ashare_dp/apps/api/wyckoff/__init__.py new file mode 100644 index 0000000..52a28f9 --- /dev/null +++ b/src/ashare_dp/apps/api/wyckoff/__init__.py @@ -0,0 +1 @@ +"""Wyckoff Screener presentation package.""" diff --git a/src/ashare_dp/apps/api/wyckoff/html.py b/src/ashare_dp/apps/api/wyckoff/html.py new file mode 100644 index 0000000..68e507d --- /dev/null +++ b/src/ashare_dp/apps/api/wyckoff/html.py @@ -0,0 +1,731 @@ +"""威科夫选股界面 — 三栏布局 + 本地 K 线。""" + +WYCKOFF_HTML = r""" + + + +威科夫选股 + + + +
+

威科夫选股 架构 1.0

+
加载中…
+
+
+ + + + + + + + + +
+
+
+

股票列表

+
加载中…
+
+
+

+ K 线 + + + + + +

+
+ +
+
+
选择左侧股票查看 K 线
+
+
+
+

分析详情

+
请选择左侧股票
+
+
+ + + +""" diff --git a/src/ashare_dp/apps/cli/backfill_cmd.py b/src/ashare_dp/apps/cli/backfill_cmd.py index 1dbab5a..d86afb2 100644 --- a/src/ashare_dp/apps/cli/backfill_cmd.py +++ b/src/ashare_dp/apps/cli/backfill_cmd.py @@ -120,6 +120,31 @@ def backfill_industry(): typer.echo(f"Industry data stored: {count} stocks classified") +@backfill_app.command("wyckoff") +def backfill_wyckoff( + date_str: str = typer.Option(None, "--date", help="Trade date YYYY-MM-DD (default: latest)"), + batch_size: int = typer.Option(400, help="Symbols per IO batch"), + max_symbols: int = typer.Option(None, help="Limit symbols (debug)"), +): + """Run multi-timeframe Wyckoff Screener scan (Architecture v1.0).""" + from datetime import date as date_cls + + from ashare_dp.wyckoff.pipeline import run_daily_scan + from ashare_dp.wyckoff.version import WYCKOFF_ENGINE_VERSION + + trade_date = date_cls.fromisoformat(date_str) if date_str else None + typer.echo(f"Wyckoff Screener scan engine={WYCKOFF_ENGINE_VERSION}") + result = run_daily_scan( + trade_date=trade_date, + batch_size=batch_size, + max_symbols=max_symbols, + ) + typer.echo( + f"Done: date={result['trade_date']} stored={result['stored']} " + f"symbols={result['symbols']} errors={result['errors']}" + ) + + @backfill_app.command("signals") def backfill_signals( days: int = typer.Option(500, help="Calendar days to scan for historical signals"), diff --git a/src/ashare_dp/apps/scheduler/jobs.py b/src/ashare_dp/apps/scheduler/jobs.py index c606852..5895273 100644 --- a/src/ashare_dp/apps/scheduler/jobs.py +++ b/src/ashare_dp/apps/scheduler/jobs.py @@ -66,3 +66,17 @@ async def ema52_screening_job(): """EOD EMA52 screening — delegates to shared implementation.""" from ashare_dp.signals.detectors import run_ema52_screening run_ema52_screening() + + +async def wyckoff_scan_job(): + """EOD Wyckoff multi-timeframe screener scan (after daily/weekly/monthly ready).""" + from ashare_dp.wyckoff.pipeline import run_daily_scan + + try: + result = run_daily_scan() + logger.info( + f"Wyckoff scan job done: date={result['trade_date']} " + f"stored={result['stored']} errors={result['errors']}" + ) + except Exception as e: + logger.error(f"Wyckoff scan job failed: {e}") diff --git a/src/ashare_dp/apps/scheduler/scheduler.py b/src/ashare_dp/apps/scheduler/scheduler.py index 7e2bc0d..928d5db 100644 --- a/src/ashare_dp/apps/scheduler/scheduler.py +++ b/src/ashare_dp/apps/scheduler/scheduler.py @@ -19,7 +19,12 @@ class Scheduler: def start(self): """Start the scheduler and register jobs.""" - from ashare_dp.apps.scheduler.jobs import eod_pull_job, ema52_screening_job, health_check_job + from ashare_dp.apps.scheduler.jobs import ( + eod_pull_job, + ema52_screening_job, + health_check_job, + wyckoff_scan_job, + ) # EOD job: 15:05 Beijing time, Mon-Fri self._scheduler.add_job( @@ -62,8 +67,25 @@ class Scheduler: replace_existing=True, ) + # Wyckoff MTF scan: 15:15 Beijing time, Mon-Fri (after EOD + weekly/monthly derive) + self._scheduler.add_job( + wyckoff_scan_job, + trigger=CronTrigger( + day_of_week="mon-fri", + hour=15, + minute=15, + timezone=BEIJING_TZ, + ), + id="wyckoff_scan", + name="Wyckoff Screener scan", + replace_existing=True, + ) + self._scheduler.start() - logger.info("Scheduler started with EOD (15:05) + EMA52 (15:10) + health check (08:00)") + logger.info( + "Scheduler started with EOD (15:05) + EMA52 (15:10) + " + "Wyckoff (15:15) + health check (08:00)" + ) def shutdown(self): """Shut down the scheduler.""" diff --git a/src/ashare_dp/data/store/repository.py b/src/ashare_dp/data/store/repository.py index 3381dec..6963f89 100644 --- a/src/ashare_dp/data/store/repository.py +++ b/src/ashare_dp/data/store/repository.py @@ -146,12 +146,25 @@ class KLineRepository: if conditions: where_clause = "WHERE " + " AND ".join(conditions) - sql = f""" - SELECT * FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true) - {where_clause} - ORDER BY trade_time - LIMIT {limit} OFFSET {offset} - """ + # Without start_date, LIMIT should mean "latest N bars" (chart/query UX). + # With start_date, keep chronological window from the start. + if start_date is None: + sql = f""" + SELECT * FROM ( + SELECT * FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true) + {where_clause} + ORDER BY trade_time DESC + LIMIT {limit} OFFSET {offset} + ) AS recent + ORDER BY trade_time ASC + """ + else: + sql = f""" + SELECT * FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true) + {where_clause} + ORDER BY trade_time + LIMIT {limit} OFFSET {offset} + """ try: return self._query_parquet(sql, tuple(params) if params else None) except Exception as e: diff --git a/src/ashare_dp/data/store/schema.py b/src/ashare_dp/data/store/schema.py index 0ce2678..72d872f 100644 --- a/src/ashare_dp/data/store/schema.py +++ b/src/ashare_dp/data/store/schema.py @@ -81,6 +81,10 @@ DDL_STATEMENTS = [ ) """, # ── Signal Intelligence ── + # Sequences must exist before tables that reference them via nextval() + """ + CREATE SEQUENCE IF NOT EXISTS seq_signal_id + """, """ CREATE TABLE IF NOT EXISTS signal_instance ( id BIGINT PRIMARY KEY DEFAULT nextval('seq_signal_id'), @@ -109,15 +113,13 @@ DDL_STATEMENTS = [ ) """, """ - CREATE SEQUENCE IF NOT EXISTS seq_signal_id - """, - """ CREATE INDEX IF NOT EXISTS idx_signal_type_date ON signal_instance(signal_type, trade_date) """, """ CREATE INDEX IF NOT EXISTS idx_signal_ts_code ON signal_instance(ts_code, trade_date) """, # ── Trading Memory ── + """CREATE SEQUENCE IF NOT EXISTS seq_trade_id""", """ CREATE TABLE IF NOT EXISTS trade_log ( id BIGINT PRIMARY KEY DEFAULT nextval('seq_trade_id'), @@ -132,7 +134,50 @@ DDL_STATEMENTS = [ closed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, - """CREATE SEQUENCE IF NOT EXISTS seq_trade_id""", """CREATE INDEX IF NOT EXISTS idx_trade_date ON trade_log(trade_date)""", """CREATE INDEX IF NOT EXISTS idx_trade_signal ON trade_log(signal_type)""", + # ── Wyckoff Screener (Architecture v1.0) ── + """ + CREATE TABLE IF NOT EXISTS wyckoff_scan ( + trade_date DATE NOT NULL, + ts_code VARCHAR(15) NOT NULL, + name VARCHAR(40), + industry VARCHAR(40), + engine_version VARCHAR(20) NOT NULL, + m_cycle VARCHAR(30), + cycle_confidence DOUBLE, + trend_score DOUBLE, + w_cycle VARCHAR(30), + w_phase VARCHAR(10), + w_current_event VARCHAR(30), + w_recent_events_json VARCHAR, + phase_confidence DOUBLE, + structure_score DOUBLE, + d_current_event VARCHAR(30), + d_recent_events_json VARCHAR, + event_confidence DOUBLE, + entry_score DOUBLE, + entry DOUBLE, + stop DOUBLE, + target1 DOUBLE, + target2 DOUBLE, + rr DOUBLE, + alignment DOUBLE, + stars INTEGER, + decision_signal VARCHAR(20), + signal_confidence DOUBLE, + overall_confidence DOUBLE, + overall_score DOUBLE, + risk VARCHAR(10), + reasons_json VARCHAR, + feature_snapshot_json VARCHAR, + markers_json VARCHAR, + scanned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (trade_date, ts_code) + ) + """, + """CREATE INDEX IF NOT EXISTS idx_wyckoff_signal ON wyckoff_scan(trade_date, decision_signal)""", + """CREATE INDEX IF NOT EXISTS idx_wyckoff_score ON wyckoff_scan(trade_date, overall_score DESC)""", + """CREATE INDEX IF NOT EXISTS idx_wyckoff_align ON wyckoff_scan(trade_date, alignment DESC)""", + """CREATE INDEX IF NOT EXISTS idx_wyckoff_version ON wyckoff_scan(trade_date, engine_version)""", ] diff --git a/src/ashare_dp/domain/wyckoff.py b/src/ashare_dp/domain/wyckoff.py new file mode 100644 index 0000000..a3e1956 --- /dev/null +++ b/src/ashare_dp/domain/wyckoff.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/src/ashare_dp/wyckoff/__init__.py b/src/ashare_dp/wyckoff/__init__.py new file mode 100644 index 0000000..0c96927 --- /dev/null +++ b/src/ashare_dp/wyckoff/__init__.py @@ -0,0 +1,5 @@ +"""Wyckoff Screener — multi-timeframe rule-driven analysis (Architecture v1.0).""" + +from ashare_dp.wyckoff.version import WYCKOFF_ENGINE_VERSION + +__all__ = ["WYCKOFF_ENGINE_VERSION"] diff --git a/src/ashare_dp/wyckoff/annotate.py b/src/ashare_dp/wyckoff/annotate.py new file mode 100644 index 0000000..a45311a --- /dev/null +++ b/src/ashare_dp/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 ashare_dp.domain.wyckoff import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase +from ashare_dp.wyckoff.cycle import CycleEngine +from ashare_dp.wyckoff.event import EventEngine +from ashare_dp.wyckoff.features import FeatureEngine +from ashare_dp.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 ashare_dp.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/src/ashare_dp/wyckoff/cycle.py b/src/ashare_dp/wyckoff/cycle.py new file mode 100644 index 0000000..bc1c7dd --- /dev/null +++ b/src/ashare_dp/wyckoff/cycle.py @@ -0,0 +1,102 @@ +"""Cycle Engine — monthly/weekly macro cycle via Rule Registry.""" + +from __future__ import annotations + +from ashare_dp.domain.wyckoff import EngineResult, WyckoffCycle +from ashare_dp.wyckoff.rules.base import RuleHit +from ashare_dp.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/src/ashare_dp/wyckoff/decision.py b/src/ashare_dp/wyckoff/decision.py new file mode 100644 index 0000000..753b328 --- /dev/null +++ b/src/ashare_dp/wyckoff/decision.py @@ -0,0 +1,195 @@ +"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0).""" + +from __future__ import annotations + +from ashare_dp.domain.wyckoff 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/src/ashare_dp/wyckoff/event.py b/src/ashare_dp/wyckoff/event.py new file mode 100644 index 0000000..1b7435b --- /dev/null +++ b/src/ashare_dp/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 ashare_dp.domain.wyckoff import EngineResult, WyckoffEvent +from ashare_dp.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/src/ashare_dp/wyckoff/features.py b/src/ashare_dp/wyckoff/features.py new file mode 100644 index 0000000..b81f841 --- /dev/null +++ b/src/ashare_dp/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 ashare_dp.domain.wyckoff 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/src/ashare_dp/wyckoff/io.py b/src/ashare_dp/wyckoff/io.py new file mode 100644 index 0000000..9b33e75 --- /dev/null +++ b/src/ashare_dp/wyckoff/io.py @@ -0,0 +1,133 @@ +"""IO layer — only place that loads OHLCV from Parquet/DuckDB for Wyckoff.""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Iterator + +import pandas as pd +from loguru import logger + +from ashare_dp.core.models import Freq +from ashare_dp.data.store.database import analytics_conn, get_db +from ashare_dp.data.store.partitioning import partition_glob +from ashare_dp.domain.wyckoff import OHLCVFrame + + +def latest_daily_trade_date() -> date | None: + glob = partition_glob(Freq.d1) + conn = analytics_conn() + try: + row = conn.execute( + f"SELECT MAX(trade_date) FROM read_parquet('{glob}', " + f"hive_partitioning=true, union_by_name=true)" + ).fetchone() + if row and row[0]: + return date.fromisoformat(str(row[0])[:10]) + return None + finally: + conn.close() + + +def load_stock_meta() -> dict[str, dict[str, str]]: + """ts_code → {name, industry}.""" + meta: dict[str, dict[str, str]] = {} + with get_db(read_only=True) as db: + try: + rows = db.query( + """ + SELECT s.ts_code, s.name, COALESCE(i.industry_name, '') AS industry + FROM stock_info s + LEFT JOIN stock_industry i ON s.ts_code = i.ts_code + """ + ) + for ts_code, name, industry in rows: + meta[ts_code] = {"name": name or "", "industry": industry or ""} + except Exception as e: + logger.warning(f"load_stock_meta failed: {e}") + return meta + + +def _df_to_frames(df: pd.DataFrame, timeframe: str) -> dict[str, OHLCVFrame]: + frames: dict[str, OHLCVFrame] = {} + if df.empty: + return frames + df = df.sort_values(["ts_code", "trade_date"]) + for ts_code, g in df.groupby("ts_code", sort=False): + dates = [date.fromisoformat(str(d)[:10]) for d in g["trade_date"].tolist()] + frames[str(ts_code)] = OHLCVFrame( + ts_code=str(ts_code), + timeframe=timeframe, + trade_dates=dates, + open=g["open"].astype(float).tolist(), + high=g["high"].astype(float).tolist(), + low=g["low"].astype(float).tolist(), + close=g["close"].astype(float).tolist(), + volume=g["volume"].astype(float).tolist(), + amount=g["amount"].astype(float).tolist() if "amount" in g.columns else [], + ) + return frames + + +def load_frames_batch( + timeframe: str, + end_date: date, + lookback_bars: int, + ts_codes: list[str] | None = None, +) -> dict[str, OHLCVFrame]: + """Load OHLCV frames for a timeframe. Pure IO for pipeline.""" + freq_map = {"1d": Freq.d1, "1w": Freq.w1, "1M": Freq.M1} + freq = freq_map[timeframe] + glob = partition_glob(freq) + + # calendar lookback with buffer + if timeframe == "1d": + start = end_date - timedelta(days=int(lookback_bars * 1.8)) + elif timeframe == "1w": + start = end_date - timedelta(days=int(lookback_bars * 10)) + else: + start = end_date - timedelta(days=int(lookback_bars * 40)) + + conn = analytics_conn() + try: + code_filter = "" + params: list = [start, end_date] + if ts_codes: + q = ", ".join(["?"] * len(ts_codes)) + code_filter = f"AND ts_code IN ({q})" + params.extend(ts_codes) + + sql = f""" + SELECT ts_code, trade_date, open, high, low, close, volume, amount + FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true) + WHERE trade_date >= ? AND trade_date <= ? {code_filter} + ORDER BY ts_code, trade_date + """ + df = conn.execute(sql, params).fetchdf() + except Exception as e: + logger.error(f"load_frames_batch {timeframe} failed: {e}") + return {} + finally: + conn.close() + + frames = _df_to_frames(df, timeframe) + # trim to last N bars + for code, fr in list(frames.items()): + if len(fr) > lookback_bars: + frames[code] = OHLCVFrame( + ts_code=fr.ts_code, + timeframe=fr.timeframe, + trade_dates=fr.trade_dates[-lookback_bars:], + open=fr.open[-lookback_bars:], + high=fr.high[-lookback_bars:], + low=fr.low[-lookback_bars:], + close=fr.close[-lookback_bars:], + volume=fr.volume[-lookback_bars:], + amount=fr.amount[-lookback_bars:] if fr.amount else [], + ) + return frames + + +def iter_code_batches(all_codes: list[str], batch_size: int = 500) -> Iterator[list[str]]: + for i in range(0, len(all_codes), batch_size): + yield all_codes[i : i + batch_size] diff --git a/src/ashare_dp/wyckoff/phase.py b/src/ashare_dp/wyckoff/phase.py new file mode 100644 index 0000000..2b9fab6 --- /dev/null +++ b/src/ashare_dp/wyckoff/phase.py @@ -0,0 +1,78 @@ +"""Phase Engine — Phase A–E via Rule Registry.""" + +from __future__ import annotations + +from ashare_dp.domain.wyckoff import EngineResult, WyckoffPhase +from ashare_dp.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/src/ashare_dp/wyckoff/pipeline.py b/src/ashare_dp/wyckoff/pipeline.py new file mode 100644 index 0000000..a54962a --- /dev/null +++ b/src/ashare_dp/wyckoff/pipeline.py @@ -0,0 +1,243 @@ +"""Wyckoff scan pipeline — IO + pure engines + bulk store.""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from loguru import logger + +from ashare_dp.domain.wyckoff import WyckoffScanRow +from ashare_dp.wyckoff.cycle import CycleEngine +from ashare_dp.wyckoff.decision import DecisionEngine +from ashare_dp.wyckoff.event import EventEngine +from ashare_dp.wyckoff.features import FeatureEngine +from ashare_dp.wyckoff.io import ( + iter_code_batches, + latest_daily_trade_date, + load_frames_batch, + load_stock_meta, +) +from ashare_dp.wyckoff.phase import PhaseEngine +from ashare_dp.wyckoff.plan import PlanEngine +from ashare_dp.wyckoff.signal import SignalEngine +from ashare_dp.wyckoff.store import bulk_upsert, ensure_schema +from ashare_dp.wyckoff.version import WYCKOFF_ENGINE_VERSION + + +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: + """Pure multi-TF analysis for one symbol. Engines never touch DB. + + Never borrows daily features for weekly/monthly — insufficient TF → Unknown. + """ + 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") + # Daily phase uses daily features + weekly cycle as structure context only + 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, + ts_code: str, + name: str, + industry: str, + result: dict, +) -> WyckoffScanRow: + d = result["decision"] + p = result["plan"] + c_m = result["c_m"] + c_w = result["c_w"] + p_w = result["p_w"] + e_w = result["e_w"] + e_d = result["e_d"] + s_d = result["s_d"] + f_d = result["f_d"] + f_w = result["f_w"] + f_m = 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 = [] + entry = p.payload.get("entry") + stop = p.payload.get("stop") + if entry is not None: + markers.append({"type": "entry", "price": entry}) + if stop is not None: + markers.append({"type": "stop", "price": stop}) + for tname, key in (("target1", "target1"), ("target2", "target2")): + if p.payload.get(key) is not None: + markers.append({"type": tname, "price": p.payload[key]}) + + return WyckoffScanRow( + trade_date=trade_date, + ts_code=ts_code, + name=name, + industry=industry, + 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(), + ) + + +def run_daily_scan( + trade_date: date | None = None, + batch_size: int = 400, + max_symbols: int | None = None, +) -> dict: + """Full-market MTF Wyckoff scan → wyckoff_scan table.""" + ensure_schema() + trade_date = trade_date or latest_daily_trade_date() + if trade_date is None: + raise RuntimeError("No daily K-line data available") + + meta = load_stock_meta() + codes = sorted(meta.keys()) + if not codes: + # fallback: discover from daily frames + sample = load_frames_batch("1d", trade_date, 5) + codes = sorted(sample.keys()) + if max_symbols: + codes = codes[:max_symbols] + + logger.info( + f"Wyckoff scan {trade_date} engine={WYCKOFF_ENGINE_VERSION} symbols={len(codes)}" + ) + + feature_eng = FeatureEngine() + cycle_eng = CycleEngine() + phase_eng = PhaseEngine() + event_eng = EventEngine() + signal_eng = SignalEngine() + decision_eng = DecisionEngine() + plan_eng = PlanEngine() + + all_rows: list[WyckoffScanRow] = [] + errors = 0 + stored_total = 0 + + for batch_i, batch in enumerate(iter_code_batches(codes, batch_size)): + daily = load_frames_batch("1d", trade_date, 250, batch) + weekly = load_frames_batch("1w", trade_date, 104, batch) + monthly = load_frames_batch("1M", trade_date, 60, batch) + + batch_rows = [] + for ts_code in batch: + dfr = daily.get(ts_code) + if not dfr or len(dfr) < 40: + continue + try: + result = analyze_symbol( + dfr, + weekly.get(ts_code), + monthly.get(ts_code), + feature_eng=feature_eng, + cycle_eng=cycle_eng, + phase_eng=phase_eng, + event_eng=event_eng, + signal_eng=signal_eng, + decision_eng=decision_eng, + plan_eng=plan_eng, + ) + info = meta.get(ts_code, {}) + batch_rows.append( + _to_row(trade_date, ts_code, info.get("name", ""), info.get("industry", ""), result) + ) + except Exception as e: + errors += 1 + if errors <= 5: + logger.warning(f"Wyckoff analyze failed {ts_code}: {e}") + + if batch_rows: + stored_total += bulk_upsert(batch_rows) + all_rows.extend(batch_rows) + + logger.info( + f" batch {batch_i + 1}: processed {len(batch)} → {len(batch_rows)} rows " + f"(stored {stored_total}, errors {errors})" + ) + + stored = stored_total + return { + "trade_date": trade_date.isoformat(), + "engine_version": WYCKOFF_ENGINE_VERSION, + "symbols": len(codes), + "stored": stored, + "errors": errors, + } diff --git a/src/ashare_dp/wyckoff/plan.py b/src/ashare_dp/wyckoff/plan.py new file mode 100644 index 0000000..ae3caf3 --- /dev/null +++ b/src/ashare_dp/wyckoff/plan.py @@ -0,0 +1,78 @@ +"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable.""" + +from __future__ import annotations + +from ashare_dp.domain.wyckoff 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/src/ashare_dp/wyckoff/ranking/__init__.py b/src/ashare_dp/wyckoff/ranking/__init__.py new file mode 100644 index 0000000..d1d1ad6 --- /dev/null +++ b/src/ashare_dp/wyckoff/ranking/__init__.py @@ -0,0 +1,4 @@ +from ashare_dp.wyckoff.ranking.ranker import Ranker +from ashare_dp.wyckoff.ranking.providers import ScoreProvider, WyckoffScoreProvider + +__all__ = ["Ranker", "ScoreProvider", "WyckoffScoreProvider"] diff --git a/src/ashare_dp/wyckoff/ranking/providers.py b/src/ashare_dp/wyckoff/ranking/providers.py new file mode 100644 index 0000000..48892fe --- /dev/null +++ b/src/ashare_dp/wyckoff/ranking/providers.py @@ -0,0 +1,23 @@ +"""ScoreProvider plugins for Ranker.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class ScoreProvider(ABC): + name: str + weight: float = 1.0 + + @abstractmethod + def score(self, row: dict[str, Any]) -> float: + """Return 0–100 component score.""" + + +class WyckoffScoreProvider(ScoreProvider): + name = "wyckoff" + weight = 1.0 + + def score(self, row: dict[str, Any]) -> float: + return float(row.get("overall_score") or 0.0) diff --git a/src/ashare_dp/wyckoff/ranking/ranker.py b/src/ashare_dp/wyckoff/ranking/ranker.py new file mode 100644 index 0000000..db9454b --- /dev/null +++ b/src/ashare_dp/wyckoff/ranking/ranker.py @@ -0,0 +1,21 @@ +"""Plugin Ranker — weighted ScoreProviders → final score.""" + +from __future__ import annotations + +from typing import Any + +from ashare_dp.wyckoff.ranking.providers import ScoreProvider, WyckoffScoreProvider + + +class Ranker: + def __init__(self, providers: list[ScoreProvider] | None = None) -> None: + self.providers = providers or [WyckoffScoreProvider()] + + def score(self, row: dict[str, Any]) -> float: + total_w = sum(p.weight for p in self.providers) or 1.0 + return sum(p.weight * p.score(row) for p in self.providers) / total_w + + def rank(self, rows: list[dict[str, Any]], key: str = "overall_score") -> list[dict[str, Any]]: + for r in rows: + r[key] = self.score(r) + return sorted(rows, key=lambda x: x.get(key, 0), reverse=True) diff --git a/src/ashare_dp/wyckoff/rules/__init__.py b/src/ashare_dp/wyckoff/rules/__init__.py new file mode 100644 index 0000000..59e2e0b --- /dev/null +++ b/src/ashare_dp/wyckoff/rules/__init__.py @@ -0,0 +1,3 @@ +from ashare_dp.wyckoff.rules.registry import rule_registry + +__all__ = ["rule_registry"] diff --git a/src/ashare_dp/wyckoff/rules/base.py b/src/ashare_dp/wyckoff/rules/base.py new file mode 100644 index 0000000..2060660 --- /dev/null +++ b/src/ashare_dp/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/src/ashare_dp/wyckoff/rules/cycle_rules.py b/src/ashare_dp/wyckoff/rules/cycle_rules.py new file mode 100644 index 0000000..76b123f --- /dev/null +++ b/src/ashare_dp/wyckoff/rules/cycle_rules.py @@ -0,0 +1,126 @@ +"""Cycle classification rules (monthly / weekly).""" + +from __future__ import annotations + +from typing import Any + +from ashare_dp.domain.wyckoff import WyckoffCycle +from ashare_dp.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/src/ashare_dp/wyckoff/rules/event_rules.py b/src/ashare_dp/wyckoff/rules/event_rules.py new file mode 100644 index 0000000..685072f --- /dev/null +++ b/src/ashare_dp/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 ashare_dp.domain.wyckoff import WyckoffCycle, WyckoffEvent, WyckoffPhase +from ashare_dp.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/src/ashare_dp/wyckoff/rules/phase_rules.py b/src/ashare_dp/wyckoff/rules/phase_rules.py new file mode 100644 index 0000000..9e2f7ea --- /dev/null +++ b/src/ashare_dp/wyckoff/rules/phase_rules.py @@ -0,0 +1,163 @@ +"""Phase A–E rules (primarily weekly).""" + +from __future__ import annotations + +from typing import Any + +from ashare_dp.domain.wyckoff import WyckoffCycle, WyckoffPhase +from ashare_dp.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/src/ashare_dp/wyckoff/rules/registry.py b/src/ashare_dp/wyckoff/rules/registry.py new file mode 100644 index 0000000..301d3d2 --- /dev/null +++ b/src/ashare_dp/wyckoff/rules/registry.py @@ -0,0 +1,39 @@ +"""Rule Registry — register Wyckoff rules without modifying engines.""" + +from __future__ import annotations + +from ashare_dp.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 ashare_dp.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/src/ashare_dp/wyckoff/signal.py b/src/ashare_dp/wyckoff/signal.py new file mode 100644 index 0000000..6553214 --- /dev/null +++ b/src/ashare_dp/wyckoff/signal.py @@ -0,0 +1,35 @@ +"""Signal Engine — timeframe-local status labels only (not tradability).""" + +from __future__ import annotations + +from ashare_dp.domain.wyckoff 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/src/ashare_dp/wyckoff/store.py b/src/ashare_dp/wyckoff/store.py new file mode 100644 index 0000000..a18baa8 --- /dev/null +++ b/src/ashare_dp/wyckoff/store.py @@ -0,0 +1,245 @@ +"""Bulk persistence for wyckoff_scan.""" + +from __future__ import annotations + +import json +from datetime import date +from typing import Any, Optional + +from loguru import logger + +from ashare_dp.data.store.database import get_db +from ashare_dp.domain.wyckoff import WyckoffScanRow + +_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", +] + +_schema_ready = False + + +def ensure_schema() -> None: + """Idempotent schema ensure — call from write/scan paths only, not every read.""" + global _schema_ready + if _schema_ready: + return + from ashare_dp.data.store.schema import DDL_STATEMENTS + + with get_db(read_only=False) as db: + for ddl in DDL_STATEMENTS: + try: + db.execute(ddl) + except Exception as e: + logger.debug(f"DDL skip/warn: {e}") + # Verify critical table exists + try: + db.execute("SELECT 1 FROM wyckoff_scan LIMIT 0") + _schema_ready = True + except Exception as e: + logger.error(f"wyckoff_scan schema missing: {e}") + raise + + +def bulk_upsert(rows: list[WyckoffScanRow]) -> int: + """Delete+insert by (trade_date, ts_code) in bulk for one batch.""" + if not rows: + return 0 + ensure_schema() + trade_date = rows[0].trade_date + placeholders = ", ".join(["?"] * len(_COLS)) + col_sql = ", ".join(_COLS) + values = [tuple(getattr(r, c) for c in _COLS) for r in rows] + codes = [r.ts_code for r in rows] + + with get_db(read_only=False) as db: + chunk = 500 + for i in range(0, len(codes), chunk): + part_codes = codes[i : i + chunk] + part_vals = values[i : i + chunk] + qmarks = ", ".join(["?"] * len(part_codes)) + db.execute( + f"DELETE FROM wyckoff_scan WHERE trade_date = ? AND ts_code IN ({qmarks})", + [trade_date, *part_codes], + ) + db.conn.executemany( + f"INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})", + part_vals, + ) + logger.debug(f"wyckoff_scan upserted {len(values)} rows for {trade_date}") + return len(values) + + +def _parse_json_fields(r: dict[str, Any]) -> dict[str, Any]: + mapping = { + "reasons_json": "reasons", + "w_recent_events_json": "w_recent_events", + "d_recent_events_json": "d_recent_events", + "feature_snapshot_json": "feature_snapshot", + "markers_json": "markers", + } + for src, dst in mapping.items(): + if isinstance(r.get(src), str): + try: + r[dst] = json.loads(r[src]) + except Exception: + r[dst] = r[src] + # Semantic alias: stored column is historical name, meaning = active concurrent events + if "w_recent_events" in r: + r["w_active_events"] = r["w_recent_events"] + if "d_recent_events" in r: + r["d_active_events"] = r["d_recent_events"] + return r + + +def query_scan( + trade_date: date | None = None, + m_cycle: str | None = None, + w_phase: str | None = None, + d_event: str | None = None, + decision_signal: str | None = None, + industry: str | None = None, + min_overall_score: float | None = None, + min_alignment: float | None = None, + engine_version: str | None = None, + sort: str = "overall_score", + limit: int = 100, + offset: int = 0, +) -> list[dict[str, Any]]: + clauses = ["1=1"] + params: list[Any] = [] + + with get_db(read_only=True) as db: + try: + if trade_date is None: + row = db.execute("SELECT MAX(trade_date) FROM wyckoff_scan").fetchone() + if not row or not row[0]: + return [] + trade_date = date.fromisoformat(str(row[0])[:10]) + except Exception: + return [] + + clauses.append("trade_date = ?") + params.append(trade_date) + + if m_cycle: + clauses.append("m_cycle = ?") + params.append(m_cycle) + if w_phase: + clauses.append("w_phase = ?") + params.append(w_phase) + if d_event: + clauses.append("d_current_event = ?") + params.append(d_event) + if decision_signal: + clauses.append("decision_signal = ?") + params.append(decision_signal) + if industry: + clauses.append("industry = ?") + params.append(industry) + if min_overall_score is not None: + clauses.append("overall_score >= ?") + params.append(min_overall_score) + if min_alignment is not None: + clauses.append("alignment >= ?") + params.append(min_alignment) + if engine_version: + clauses.append("engine_version = ?") + params.append(engine_version) + + allowed_sort = { + "overall_score": "overall_score DESC", + "alignment": "alignment DESC", + "entry_score": "entry_score DESC", + "trend_score": "trend_score DESC", + "structure_score": "structure_score DESC", + } + order = allowed_sort.get(sort, "overall_score DESC") + where = " AND ".join(clauses) + sql = f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?" + params.extend([limit, offset]) + cur = db.execute(sql, params) + cols = [d[0] for d in cur.description] + return [_parse_json_fields(dict(zip(cols, row))) for row in cur.fetchall()] + + +def get_detail(ts_code: str, trade_date: date | None = None) -> Optional[dict[str, Any]]: + with get_db(read_only=True) as db: + try: + if trade_date is None: + row = db.execute( + "SELECT MAX(trade_date) FROM wyckoff_scan WHERE ts_code = ?", + [ts_code], + ).fetchone() + if not row or not row[0]: + return None + trade_date = date.fromisoformat(str(row[0])[:10]) + cur = db.execute( + "SELECT * FROM wyckoff_scan WHERE ts_code = ? AND trade_date = ?", + [ts_code, trade_date], + ) + except Exception: + return None + cols = [d[0] for d in cur.description] + row = cur.fetchone() + if not row: + return None + return _parse_json_fields(dict(zip(cols, row))) + + +def latest_trade_date() -> date | None: + with get_db(read_only=True) as db: + try: + row = db.execute("SELECT MAX(trade_date) FROM wyckoff_scan").fetchone() + except Exception: + return None + if row and row[0]: + return date.fromisoformat(str(row[0])[:10]) + return None + + +def count_for_date(trade_date: date) -> int: + with get_db(read_only=True) as db: + try: + row = db.execute( + "SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date = ?", + [trade_date], + ).fetchone() + except Exception: + return 0 + return int(row[0]) if row else 0 + + +def facet_counts(trade_date: date | None = None) -> dict[str, dict[str, int]]: + """Value histograms for filter UI (only non-empty buckets).""" + with get_db(read_only=True) as db: + try: + if trade_date is None: + row = db.execute("SELECT MAX(trade_date) FROM wyckoff_scan").fetchone() + if not row or not row[0]: + return {} + trade_date = date.fromisoformat(str(row[0])[:10]) + out: dict[str, dict[str, int]] = {} + for col, key in ( + ("m_cycle", "m_cycle"), + ("w_phase", "w_phase"), + ("d_current_event", "d_event"), + ("decision_signal", "decision_signal"), + ): + rows = db.execute( + f"SELECT {col}, COUNT(*) FROM wyckoff_scan " + f"WHERE trade_date = ? AND {col} IS NOT NULL " + f"GROUP BY {col} ORDER BY COUNT(*) DESC", + [trade_date], + ).fetchall() + out[key] = {str(r[0]): int(r[1]) for r in rows if r[0] is not None} + return out + except Exception: + return {} diff --git a/src/ashare_dp/wyckoff/version.py b/src/ashare_dp/wyckoff/version.py new file mode 100644 index 0000000..c4d7977 --- /dev/null +++ b/src/ashare_dp/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/tests/test_wyckoff_decision.py b/tests/test_wyckoff_decision.py new file mode 100644 index 0000000..4dd85b3 --- /dev/null +++ b/tests/test_wyckoff_decision.py @@ -0,0 +1,63 @@ +"""Decision Engine contract tests — MTF facts must not be overwritten.""" + +from ashare_dp.domain.wyckoff import DecisionSignal, EngineResult, WyckoffCycle, WyckoffEvent, WyckoffPhase +from ashare_dp.wyckoff.decision import DecisionEngine + + +def _er(name, payload, confidence=80.0, score=80.0, reasons=None): + return EngineResult( + name=name, + confidence=confidence, + score=score, + reasons=reasons or [], + 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}, + confidence=92, + score=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) + + # Facts preserved + assert out.payload["facts"]["monthly"]["cycle"] == WyckoffCycle.DISTRIBUTION.value + assert out.payload["m_cycle"] == WyckoffCycle.DISTRIBUTION.value + assert out.payload["d_event"] == WyckoffEvent.SPRING.value + # Decision gated + assert out.payload["decision_signal"] == DecisionSignal.WATCH.value + assert out.payload["overall_score"] <= 55.0 + + +def test_bullish_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"], "entry_score": 92}, + confidence=92, + score=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"] == DecisionSignal.STRONG_BUY.value + assert out.payload["stars"] >= 4 diff --git a/tests/test_wyckoff_engines.py b/tests/test_wyckoff_engines.py new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/tests/test_wyckoff_engines.py @@ -0,0 +1,42 @@ +"""Feature / Cycle pure-engine smoke tests (no DB).""" + +from datetime import date, timedelta + +from ashare_dp.domain.wyckoff import OHLCVFrame +from ashare_dp.wyckoff.cycle import CycleEngine +from ashare_dp.wyckoff.features import FeatureEngine + + +def _synth_uptrend(n=120) -> OHLCVFrame: + base = date(2024, 1, 1) + closes = [100 + i * 0.5 for i in range(n)] + return OHLCVFrame( + ts_code="000001.SZ", + timeframe="1d", + trade_dates=[base + timedelta(days=i) for i in range(n)], + open=closes, + high=[c * 1.01 for c in closes], + low=[c * 0.99 for c in closes], + close=closes, + volume=[1_000_000 + i * 1000 for i in range(n)], + ) + + +def test_feature_engine_snapshot(): + fe = FeatureEngine() + out = fe.run(_synth_uptrend()) + assert out.name == "Feature" + assert "ma20" in out.payload + assert out.payload["bars"] == 120 + assert out.confidence > 50 + + +def test_cycle_engine_markup_on_uptrend(): + fe = FeatureEngine() + ce = CycleEngine() + feat = fe.run(_synth_uptrend(150)) + # Use monthly timeframe rules + feat.payload["timeframe"] = "1M" + cyc = ce.run(feat, "1M") + assert cyc.payload["cycle"] in ("Markup", "Accumulation", "Unknown", "Distribution") + assert "cycle" in cyc.payload diff --git a/tests/test_wyckoff_plan_and_fallback.py b/tests/test_wyckoff_plan_and_fallback.py new file mode 100644 index 0000000..ff410da --- /dev/null +++ b/tests/test_wyckoff_plan_and_fallback.py @@ -0,0 +1,94 @@ +"""Plan gate + insufficient TF fallback tests.""" + +from datetime import date, timedelta + +from ashare_dp.domain.wyckoff import DecisionSignal, EngineResult, OHLCVFrame, WyckoffCycle +from ashare_dp.wyckoff.cycle import CycleEngine +from ashare_dp.wyckoff.decision import DecisionEngine +from ashare_dp.wyckoff.features import FeatureEngine +from ashare_dp.wyckoff.plan import PlanEngine +from ashare_dp.wyckoff.pipeline import analyze_symbol +from ashare_dp.wyckoff.phase import PhaseEngine +from ashare_dp.wyckoff.event import EventEngine +from ashare_dp.wyckoff.signal import SignalEngine + + +def _er(name, payload, confidence=80.0, score=80.0): + return EngineResult(name=name, confidence=confidence, score=score, payload=payload) + + +def test_plan_no_entry_on_watch_even_if_spring_event(): + plan = PlanEngine() + feat = _er("Feature", {"close": 10.0, "atr": 0.3, "swing_low": 9.0, "swing_high": 11.0, "range_high": 11.0}) + decision = _er( + "Decision", + { + "decision_signal": DecisionSignal.WATCH.value, + "d_event": "Spring", + }, + confidence=90, + score=50, + ) + out = plan.run(feat, decision) + assert out.payload["entry"] is None + assert out.payload["stop"] is None + + +def test_plan_entry_on_buy(): + plan = PlanEngine() + feat = _er("Feature", {"close": 10.0, "atr": 0.3, "swing_low": 9.0, "swing_high": 11.0, "range_high": 11.0}) + decision = _er("Decision", {"decision_signal": DecisionSignal.BUY.value, "d_event": "Spring"}) + out = plan.run(feat, decision) + assert out.payload["entry"] == 10.0 + assert out.payload["stop"] is not None + + +def test_feature_insufficient_for_short_monthly(): + fe = FeatureEngine() + base = date(2024, 1, 1) + n = 10 + frame = OHLCVFrame( + ts_code="000001.SZ", + timeframe="1M", + trade_dates=[base + timedelta(days=30 * i) for i in range(n)], + open=[10.0] * n, + high=[11.0] * n, + low=[9.0] * n, + close=[10.0] * n, + volume=[1e6] * n, + ) + out = fe.run(frame, "1M") + assert out.payload["insufficient"] is True + cyc = CycleEngine().run(out, "1M") + assert cyc.payload["cycle"] == WyckoffCycle.UNKNOWN.value + + +def test_pipeline_does_not_borrow_daily_as_monthly(): + """Daily-only data → monthly cycle Unknown, not inferred from daily.""" + base = date(2024, 1, 1) + n = 120 + closes = [100 + i * 0.4 for i in range(n)] + daily = OHLCVFrame( + ts_code="000001.SZ", + timeframe="1d", + trade_dates=[base + timedelta(days=i) for i in range(n)], + open=closes, + high=[c * 1.01 for c in closes], + low=[c * 0.99 for c in closes], + close=closes, + volume=[1e6] * n, + ) + result = analyze_symbol( + daily, + None, + None, + feature_eng=FeatureEngine(), + cycle_eng=CycleEngine(), + phase_eng=PhaseEngine(), + event_eng=EventEngine(), + signal_eng=SignalEngine(), + decision_eng=DecisionEngine(), + plan_eng=PlanEngine(), + ) + assert result["f_m"].payload.get("insufficient") is True + assert result["c_m"].payload["cycle"] == WyckoffCycle.UNKNOWN.value