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"""
+
+
+
+威科夫选股
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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