feat: 威科夫多周期选股引擎与中文图表界面
新增规则驱动的月/周/日结构识别、决策融合与交易计划,提供扫描 API、本地 K 线(成交量/MACD/吸筹区间标注)及回填调度;K 线无起始日时默认取最近 N 根。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,10 @@ from fastapi.responses import HTMLResponse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ashare_dp.apps.api.routers import stocks, kline, realtime, calendar
|
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.router import router as dashboard_router
|
||||||
from ashare_dp.apps.api.dashboard.html import DASHBOARD_HTML
|
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.core.models import Freq
|
||||||
from ashare_dp.data.store.repository import KLineRepository
|
from ashare_dp.data.store.repository import KLineRepository
|
||||||
from ashare_dp.apps.api.websocket.handlers import router as ws_router
|
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."""
|
"""Trading OS Dashboard — professional trader decision support."""
|
||||||
return DASHBOARD_HTML
|
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")
|
@app.get("/api/v1/screening/ema52")
|
||||||
async def api_ema52_screening(
|
async def api_ema52_screening(
|
||||||
freq: str = Query("1d", description="Frequency: 1d or 1w"),
|
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(realtime.router, prefix="/api/v1")
|
||||||
app.include_router(calendar.router, prefix="/api/v1")
|
app.include_router(calendar.router, prefix="/api/v1")
|
||||||
app.include_router(dashboard_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)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Wyckoff Screener presentation package."""
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
"""威科夫选股界面 — 三栏布局 + 本地 K 线。"""
|
||||||
|
|
||||||
|
WYCKOFF_HTML = r"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>威科夫选股</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--orange:#d2991d}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column}
|
||||||
|
header{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||||
|
header h1{font-size:14px;font-weight:700}
|
||||||
|
header .meta{font-size:11px;color:var(--muted)}
|
||||||
|
.filters{padding:8px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:var(--panel)}
|
||||||
|
.filters label{font-size:10px;color:var(--muted);display:flex;flex-direction:column;gap:2px}
|
||||||
|
.filters select,.filters input{background:#0d1117;border:1px solid var(--border);color:var(--text);border-radius:4px;padding:4px 6px;font-size:11px;min-width:90px}
|
||||||
|
.filters button{background:var(--accent);border:0;color:#fff;border-radius:4px;padding:6px 12px;font-size:11px;cursor:pointer;margin-top:12px}
|
||||||
|
.filters button.secondary{background:transparent;border:1px solid var(--border);color:var(--muted)}
|
||||||
|
.main{flex:1;display:grid;grid-template-columns:340px 1fr 320px;min-height:0}
|
||||||
|
@media(max-width:1100px){.main{grid-template-columns:1fr;overflow:auto}}
|
||||||
|
.pane{border-right:1px solid var(--border);min-height:0;overflow:auto}
|
||||||
|
.pane:last-child{border-right:0}
|
||||||
|
.pane.chart-pane{display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.pane h2{font-size:10px;color:var(--muted);letter-spacing:1px;padding:8px 10px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--panel);display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
.chart-tools{display:flex;gap:4px}
|
||||||
|
.chart-tools button{background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;padding:2px 8px;font-size:10px;cursor:pointer}
|
||||||
|
.chart-tools button.on{border-color:var(--accent);color:var(--accent)}
|
||||||
|
#chartWrap{flex:1;min-height:480px;position:relative;background:#0d1117}
|
||||||
|
#chartCanvas{width:100%;height:100%;display:block}
|
||||||
|
#chartHint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;pointer-events:none}
|
||||||
|
#chartTip{position:absolute;left:10px;top:8px;font-size:11px;color:var(--muted);background:rgba(13,17,23,.85);padding:4px 8px;border-radius:4px;pointer-events:none;max-width:75%}
|
||||||
|
#chartLegend{position:absolute;right:10px;top:8px;font-size:10px;color:var(--muted);background:rgba(13,17,23,.85);padding:4px 8px;border-radius:4px;pointer-events:none;line-height:1.55}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:11px}
|
||||||
|
th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:600;position:sticky;top:28px;background:var(--panel)}
|
||||||
|
td{padding:6px 8px;border-top:1px solid rgba(255,255,255,.04);cursor:pointer}
|
||||||
|
tr:hover td{background:rgba(88,166,255,.08)}
|
||||||
|
tr.active td{background:rgba(88,166,255,.16)}
|
||||||
|
.tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px}
|
||||||
|
.tag.buy,.tag.strongbuy{background:rgba(63,185,80,.15);color:var(--green)}
|
||||||
|
.tag.watch{background:rgba(210,153,29,.15);color:var(--orange)}
|
||||||
|
.tag.avoid,.tag.sell{background:rgba(248,81,73,.15);color:var(--red)}
|
||||||
|
.tag.strongbuy{background:rgba(63,185,80,.25);font-weight:700}
|
||||||
|
.card{margin:8px 10px;padding:10px;background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:6px}
|
||||||
|
.card h3{font-size:11px;color:var(--muted);margin-bottom:6px}
|
||||||
|
.card .row{display:flex;justify-content:space-between;font-size:12px;margin:3px 0}
|
||||||
|
.card .val{font-weight:600}
|
||||||
|
.stars{color:var(--orange);letter-spacing:1px}
|
||||||
|
.chain{font-size:11px;color:var(--muted);line-height:1.6}
|
||||||
|
.reasons{font-size:11px;line-height:1.55;color:var(--text)}
|
||||||
|
.muted{color:var(--muted)}.good{color:var(--green)}.bad{color:var(--red)}
|
||||||
|
.loading{padding:40px;text-align:center;color:var(--muted)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>威科夫选股 <span class="meta">架构 1.0</span></h1>
|
||||||
|
<div class="meta" id="hdrMeta">加载中…</div>
|
||||||
|
</header>
|
||||||
|
<div class="filters">
|
||||||
|
<label>市场<select disabled><option>沪深京</option></select></label>
|
||||||
|
<label>月线周期<select id="fCycle"><option value="">全部</option></select></label>
|
||||||
|
<label>周线阶段<select id="fPhase"><option value="">全部</option></select></label>
|
||||||
|
<label>日线事件<select id="fEvent"><option value="">全部</option></select></label>
|
||||||
|
<label>决策信号<select id="fSignal"><option value="">全部</option></select></label>
|
||||||
|
<label>最低得分<input id="fScore" type="number" min="0" max="100" placeholder="0"></label>
|
||||||
|
<label>最低一致性<input id="fAlign" type="number" min="0" max="100" placeholder="0"></label>
|
||||||
|
<button onclick="loadList()">筛选</button>
|
||||||
|
<button class="secondary" onclick="resetFilters()">重置</button>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="pane">
|
||||||
|
<h2>股票列表</h2>
|
||||||
|
<div id="list"><div class="loading">加载中…</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="pane chart-pane">
|
||||||
|
<h2>
|
||||||
|
<span>K 线</span>
|
||||||
|
<span class="chart-tools">
|
||||||
|
<button type="button" class="on" data-freq="1d" onclick="setFreq('1d')">日</button>
|
||||||
|
<button type="button" data-freq="1w" onclick="setFreq('1w')">周</button>
|
||||||
|
<button type="button" data-freq="1M" onclick="setFreq('1M')">月</button>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div id="chartWrap">
|
||||||
|
<canvas id="chartCanvas"></canvas>
|
||||||
|
<div id="chartTip"></div>
|
||||||
|
<div id="chartLegend"></div>
|
||||||
|
<div id="chartHint">选择左侧股票查看 K 线</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pane">
|
||||||
|
<h2>分析详情</h2>
|
||||||
|
<div id="detail"><div class="loading">请选择左侧股票</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let meta=null, selected=null, chartFreq='1d', chartBars=[], chartHover=-1;
|
||||||
|
let overlay={phases:[], events:[], levels:{}}, planLevels=[];
|
||||||
|
|
||||||
|
const PHASE_COLOR = {
|
||||||
|
A:'rgba(88,166,255,.14)', B:'rgba(139,148,158,.12)', C:'rgba(210,153,29,.18)',
|
||||||
|
D:'rgba(63,185,80,.16)', E:'rgba(63,185,80,.28)', None:'rgba(0,0,0,0)',
|
||||||
|
Accumulation:'rgba(88,166,255,.14)', ReAccumulation:'rgba(88,166,255,.10)',
|
||||||
|
Markup:'rgba(63,185,80,.18)', Distribution:'rgba(248,81,73,.14)',
|
||||||
|
ReDistribution:'rgba(248,81,73,.10)', Markdown:'rgba(248,81,73,.20)', Unknown:'rgba(0,0,0,0)',
|
||||||
|
Range:'rgba(210,153,29,.12)',
|
||||||
|
};
|
||||||
|
const ZONE_FILL = {
|
||||||
|
Accumulation:'rgba(88,166,255,.22)', ReAccumulation:'rgba(88,166,255,.16)',
|
||||||
|
Distribution:'rgba(248,81,73,.18)', ReDistribution:'rgba(248,81,73,.14)',
|
||||||
|
Range:'rgba(210,153,29,.14)',
|
||||||
|
};
|
||||||
|
const ZONE_EDGE = {
|
||||||
|
Accumulation:'#58a6ff', ReAccumulation:'#79b8ff',
|
||||||
|
Distribution:'#f85149', ReDistribution:'#ff7b72', Range:'#d2991d',
|
||||||
|
};
|
||||||
|
const ZONE_LABEL = {
|
||||||
|
Accumulation:'吸筹区间', ReAccumulation:'再吸筹区间',
|
||||||
|
Distribution:'派发区间', ReDistribution:'再派发区间', Range:'震荡区间',
|
||||||
|
};
|
||||||
|
const PHASE_EDGE = {
|
||||||
|
A:'#58a6ff', B:'#8b949e', C:'#d2991d', D:'#3fb950', E:'#2ea043', None:'transparent',
|
||||||
|
Accumulation:'#58a6ff', ReAccumulation:'#79b8ff', Markup:'#3fb950',
|
||||||
|
Distribution:'#f85149', ReDistribution:'#ff7b72', Markdown:'#da3633', Unknown:'transparent',
|
||||||
|
};
|
||||||
|
const EVENT_COLOR = {
|
||||||
|
Spring:'#d2991d', Test:'#d2991d', LPS:'#3fb950', SOS:'#3fb950', Jump:'#3fb950',
|
||||||
|
Backup:'#58a6ff', SC:'#f85149', SOW:'#f85149', UTAD:'#f85149', BC:'#f85149',
|
||||||
|
AR:'#58a6ff', ST:'#8b949e', PS:'#f85149', LPSY:'#f85149',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZH = {
|
||||||
|
Accumulation:'吸筹', ReAccumulation:'再吸筹', Markup:'拉升',
|
||||||
|
Distribution:'派发', ReDistribution:'再派发', Markdown:'下跌', Unknown:'未知', Range:'震荡',
|
||||||
|
A:'阶段甲', B:'阶段乙', C:'阶段丙', D:'阶段丁', E:'阶段戊', None:'无',
|
||||||
|
PS:'初步供应', SC:'卖出高潮', AR:'自动反弹', ST:'二次测试',
|
||||||
|
Spring:'弹簧', Test:'回测', SOS:'强势信号', LPS:'最后支撑',
|
||||||
|
Jump:'跳跃', Backup:'回踩', BC:'买入高潮', UTAD:'上冲失败',
|
||||||
|
SOW:'弱势信号', LPSY:'最后供应',
|
||||||
|
StrongBuy:'强烈买入', Buy:'买入', Watch:'观察', Avoid:'回避', Sell:'卖出',
|
||||||
|
Low:'低', Medium:'中', High:'高',
|
||||||
|
};
|
||||||
|
function zh(v){
|
||||||
|
if(v==null||v===''||v==='-') return '-';
|
||||||
|
return ZH[v]||v;
|
||||||
|
}
|
||||||
|
function zhList(arr){
|
||||||
|
return (arr||[]).map(zh).filter(Boolean);
|
||||||
|
}
|
||||||
|
function zhText(s){
|
||||||
|
if(!s) return '';
|
||||||
|
let t=String(s);
|
||||||
|
const keys=Object.keys(ZH).sort((a,b)=>b.length-a.length);
|
||||||
|
for(const k of keys){ t=t.split(k).join(ZH[k]); }
|
||||||
|
return t.replace(/Decision:\s*/g,'决策:')
|
||||||
|
.replace(/Phase\s*/g,'阶段')
|
||||||
|
.replace(/Entry=/g,'入场=')
|
||||||
|
.replace(/Stop=/g,'止损=')
|
||||||
|
.replace(/T1=/g,'目标一=')
|
||||||
|
.replace(/T2=/g,'目标二=')
|
||||||
|
.replace(/RR=/g,'盈亏比=')
|
||||||
|
.replace(/Short plan\s*/g,'做空计划 ');
|
||||||
|
}
|
||||||
|
function stars(n){return '★'.repeat(n||0)+'☆'.repeat(Math.max(0,5-(n||0)));}
|
||||||
|
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
function sigClass(s){
|
||||||
|
if(!s) return 'watch';
|
||||||
|
return s.toLowerCase().replace(' ','');
|
||||||
|
}
|
||||||
|
function fillSelect(id, facetMap, fallback){
|
||||||
|
const el=document.getElementById(id);
|
||||||
|
while(el.options.length>1) el.remove(1);
|
||||||
|
const entries = facetMap && Object.keys(facetMap).length
|
||||||
|
? Object.entries(facetMap).sort((a,b)=>b[1]-a[1])
|
||||||
|
: (fallback||[]).map(v=>[v,null]);
|
||||||
|
entries.forEach(([v,n])=>{
|
||||||
|
const o=document.createElement('option'); o.value=v;
|
||||||
|
const label=zh(v);
|
||||||
|
o.textContent = n==null ? label : `${label}(${n})`;
|
||||||
|
el.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function hasActiveFilters(){
|
||||||
|
return !!(document.getElementById('fCycle').value
|
||||||
|
|| document.getElementById('fPhase').value
|
||||||
|
|| document.getElementById('fEvent').value
|
||||||
|
|| document.getElementById('fSignal').value
|
||||||
|
|| document.getElementById('fScore').value
|
||||||
|
|| document.getElementById('fAlign').value);
|
||||||
|
}
|
||||||
|
function resetFilters(){
|
||||||
|
['fCycle','fPhase','fEvent','fSignal'].forEach(id=>document.getElementById(id).value='');
|
||||||
|
document.getElementById('fScore').value='';
|
||||||
|
document.getElementById('fAlign').value='';
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
function setFreq(freq){
|
||||||
|
chartFreq=freq;
|
||||||
|
document.querySelectorAll('.chart-tools button').forEach(b=>{
|
||||||
|
b.classList.toggle('on', b.dataset.freq===freq);
|
||||||
|
});
|
||||||
|
if(selected) loadChart(selected);
|
||||||
|
}
|
||||||
|
function barDate(b){
|
||||||
|
const raw=b.trade_date||b.trade_time||'';
|
||||||
|
return String(raw).slice(0,10);
|
||||||
|
}
|
||||||
|
function setHint(msg){
|
||||||
|
const el=document.getElementById('chartHint');
|
||||||
|
el.textContent=msg||'';
|
||||||
|
el.style.display=msg?'flex':'none';
|
||||||
|
}
|
||||||
|
function dateIndex(dateStr){
|
||||||
|
const d=String(dateStr).slice(0,10);
|
||||||
|
let best=-1;
|
||||||
|
for(let i=0;i<chartBars.length;i++){
|
||||||
|
if(barDate(chartBars[i])<=d) best=i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function emaSeries(values, period){
|
||||||
|
const out=new Array(values.length).fill(null);
|
||||||
|
if(!values.length) return out;
|
||||||
|
const k=2/(period+1);
|
||||||
|
let prev=null, sum=0, n=0;
|
||||||
|
for(let i=0;i<values.length;i++){
|
||||||
|
const v=values[i];
|
||||||
|
if(v==null||!isFinite(v)){ out[i]=prev; continue; }
|
||||||
|
if(prev==null){
|
||||||
|
sum+=v; n++;
|
||||||
|
if(n===period){ prev=sum/period; out[i]=prev; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prev=v*k + prev*(1-k);
|
||||||
|
out[i]=prev;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function calcMacd(closes, fast=12, slow=26, signal=9){
|
||||||
|
const emaFast=emaSeries(closes, fast);
|
||||||
|
const emaSlow=emaSeries(closes, slow);
|
||||||
|
const dif=closes.map((_,i)=>{
|
||||||
|
if(emaFast[i]==null||emaSlow[i]==null) return null;
|
||||||
|
return emaFast[i]-emaSlow[i];
|
||||||
|
});
|
||||||
|
// signal EMA over dif (skip nulls by carrying)
|
||||||
|
const difFilled=dif.map((v,i)=>{
|
||||||
|
if(v!=null) return v;
|
||||||
|
for(let j=i-1;j>=0;j--) if(dif[j]!=null) return dif[j];
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
const firstValid=dif.findIndex(v=>v!=null);
|
||||||
|
const dea=new Array(closes.length).fill(null);
|
||||||
|
if(firstValid>=0){
|
||||||
|
const slice=difFilled.slice(firstValid);
|
||||||
|
const deaSlice=emaSeries(slice, signal);
|
||||||
|
for(let i=0;i<deaSlice.length;i++) dea[firstValid+i]=deaSlice[i];
|
||||||
|
}
|
||||||
|
const hist=closes.map((_,i)=>{
|
||||||
|
if(dif[i]==null||dea[i]==null) return null;
|
||||||
|
return dif[i]-dea[i];
|
||||||
|
});
|
||||||
|
return {dif, dea, hist};
|
||||||
|
}
|
||||||
|
function fmtVol(v){
|
||||||
|
const n=Number(v)||0;
|
||||||
|
if(n>=1e8) return (n/1e8).toFixed(2)+'亿';
|
||||||
|
if(n>=1e4) return (n/1e4).toFixed(1)+'万';
|
||||||
|
return String(Math.round(n));
|
||||||
|
}
|
||||||
|
function drawLevel(ctx, yOf, price, color, label, padL, W, padR, dash){
|
||||||
|
if(price==null||!isFinite(price)) return;
|
||||||
|
const y=yOf(price);
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle=color;
|
||||||
|
ctx.lineWidth=1;
|
||||||
|
if(dash) ctx.setLineDash(dash);
|
||||||
|
ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle=color;
|
||||||
|
ctx.font='10px sans-serif';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
ctx.fillText(`${label} ${Number(price).toFixed(2)}`, padL+4, y-3);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function drawChart(){
|
||||||
|
const wrap=document.getElementById('chartWrap');
|
||||||
|
const canvas=document.getElementById('chartCanvas');
|
||||||
|
const tip=document.getElementById('chartTip');
|
||||||
|
const legend=document.getElementById('chartLegend');
|
||||||
|
const dpr=window.devicePixelRatio||1;
|
||||||
|
const W=wrap.clientWidth, H=wrap.clientHeight;
|
||||||
|
if(W<=0||H<=0) return;
|
||||||
|
canvas.width=Math.floor(W*dpr);
|
||||||
|
canvas.height=Math.floor(H*dpr);
|
||||||
|
canvas.style.width=W+'px';
|
||||||
|
canvas.style.height=H+'px';
|
||||||
|
const ctx=canvas.getContext('2d');
|
||||||
|
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||||
|
ctx.clearRect(0,0,W,H);
|
||||||
|
ctx.fillStyle='#0d1117';
|
||||||
|
ctx.fillRect(0,0,W,H);
|
||||||
|
const bars=chartBars;
|
||||||
|
if(!bars.length){
|
||||||
|
tip.textContent='';
|
||||||
|
legend.textContent='';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padL=52, padR=12, padTop=28, padBot=18, gap=6;
|
||||||
|
const volH=Math.max(48, Math.floor(H*0.14));
|
||||||
|
const macdH=Math.max(56, Math.floor(H*0.16));
|
||||||
|
const priceH=Math.max(80, H - padTop - padBot - volH - macdH - gap*2);
|
||||||
|
const priceTop=padTop;
|
||||||
|
const volTop=priceTop + priceH + gap;
|
||||||
|
const macdTop=volTop + volH + gap;
|
||||||
|
const plotW=Math.max(10,W-padL-padR);
|
||||||
|
|
||||||
|
let lo=Infinity, hi=-Infinity;
|
||||||
|
bars.forEach(b=>{
|
||||||
|
lo=Math.min(lo, Number(b.low));
|
||||||
|
hi=Math.max(hi, Number(b.high));
|
||||||
|
});
|
||||||
|
const lv=overlay.levels||{};
|
||||||
|
[lv.range_high, lv.range_low, lv.ma20].forEach(p=>{
|
||||||
|
if(p!=null){ lo=Math.min(lo,Number(p)); hi=Math.max(hi,Number(p)); }
|
||||||
|
});
|
||||||
|
(overlay.zones||[]).forEach(z=>{
|
||||||
|
if(z.high!=null) hi=Math.max(hi, Number(z.high));
|
||||||
|
if(z.low!=null) lo=Math.min(lo, Number(z.low));
|
||||||
|
});
|
||||||
|
planLevels.forEach(m=>{
|
||||||
|
if(m.price!=null){ lo=Math.min(lo,Number(m.price)); hi=Math.max(hi,Number(m.price)); }
|
||||||
|
});
|
||||||
|
if(!isFinite(lo)||!isFinite(hi)||hi<=lo){ hi=lo+1; }
|
||||||
|
const span=hi-lo;
|
||||||
|
lo-=span*0.06; hi+=span*0.08;
|
||||||
|
const yPrice=p=>priceTop + (hi-p)/(hi-lo)*priceH;
|
||||||
|
const slot=plotW/bars.length;
|
||||||
|
const bodyW=Math.max(1, Math.min(10, slot*0.62));
|
||||||
|
const xOf=i=>padL + slot*(i+0.5);
|
||||||
|
|
||||||
|
const vols=bars.map(b=>Number(b.volume)||0);
|
||||||
|
const maxVol=Math.max(...vols, 1);
|
||||||
|
const yVol=v=>volTop + volH - (v/maxVol)* (volH-14);
|
||||||
|
|
||||||
|
const closes=bars.map(b=>Number(b.close));
|
||||||
|
const macd=calcMacd(closes);
|
||||||
|
let mLo=0, mHi=0;
|
||||||
|
macd.dif.forEach((v,i)=>{
|
||||||
|
[v, macd.dea[i], macd.hist[i]].forEach(x=>{
|
||||||
|
if(x==null||!isFinite(x)) return;
|
||||||
|
mLo=Math.min(mLo,x); mHi=Math.max(mHi,x);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(mHi<=mLo){ mHi=0.01; mLo=-0.01; }
|
||||||
|
const mPad=(mHi-mLo)*0.12;
|
||||||
|
mLo-=mPad; mHi+=mPad;
|
||||||
|
const yMacd=v=>macdTop + (mHi-v)/(mHi-mLo)*macdH;
|
||||||
|
|
||||||
|
// —— 价格区:阶段色带(全高淡色)——
|
||||||
|
const usedPhases=new Set();
|
||||||
|
(overlay.phases||[]).forEach(seg=>{
|
||||||
|
const i0=dateIndex(seg.start);
|
||||||
|
const i1=dateIndex(seg.end);
|
||||||
|
if(i0<0||i1<0) return;
|
||||||
|
const phase=seg.phase||'None';
|
||||||
|
if(phase==='None'||phase==='Unknown') return;
|
||||||
|
usedPhases.add(phase);
|
||||||
|
const x0=padL + slot*i0;
|
||||||
|
const x1=padL + slot*(i1+1);
|
||||||
|
ctx.fillStyle=PHASE_COLOR[phase]||'rgba(88,166,255,.08)';
|
||||||
|
ctx.fillRect(x0, priceTop, Math.max(1,x1-x0), priceH);
|
||||||
|
ctx.fillStyle=PHASE_EDGE[phase]||'#8b949e';
|
||||||
|
ctx.font='bold 10px sans-serif';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
const label=zh(phase);
|
||||||
|
if(x1-x0 > label.length*8) ctx.fillText(label, x0+4, priceTop+12);
|
||||||
|
});
|
||||||
|
|
||||||
|
// —— 吸筹/派发价格区间(矩形框)——
|
||||||
|
const usedZones=new Set();
|
||||||
|
(overlay.zones||[]).forEach(z=>{
|
||||||
|
const kind=z.kind||'Range';
|
||||||
|
const i0=dateIndex(z.start);
|
||||||
|
const i1=dateIndex(z.end);
|
||||||
|
if(i0<0||i1<0) return;
|
||||||
|
const zHi=Number(z.high), zLo=Number(z.low);
|
||||||
|
if(!isFinite(zHi)||!isFinite(zLo)||zHi<=zLo) return;
|
||||||
|
usedZones.add(kind);
|
||||||
|
const x0=padL + slot*i0;
|
||||||
|
const x1=padL + slot*(i1+1);
|
||||||
|
const y0=yPrice(zHi), y1=yPrice(zLo);
|
||||||
|
const isCurrent=!!z.current;
|
||||||
|
ctx.fillStyle=ZONE_FILL[kind]||ZONE_FILL.Range;
|
||||||
|
ctx.fillRect(x0, y0, Math.max(1,x1-x0), Math.max(1,y1-y0));
|
||||||
|
ctx.strokeStyle=ZONE_EDGE[kind]||ZONE_EDGE.Range;
|
||||||
|
ctx.lineWidth=isCurrent?1.6:1;
|
||||||
|
ctx.setLineDash(isCurrent?[]:[4,3]);
|
||||||
|
ctx.strokeRect(x0+0.5, y0+0.5, Math.max(1,x1-x0-1), Math.max(1,y1-y0-1));
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
const zLabel=ZONE_LABEL[kind]||'区间';
|
||||||
|
if(isCurrent || (x1-x0)>50){
|
||||||
|
ctx.fillStyle=ZONE_EDGE[kind]||ZONE_EDGE.Range;
|
||||||
|
ctx.font='bold 11px sans-serif';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
ctx.fillText(zLabel, x0+6, Math.max(priceTop+14, y0+14));
|
||||||
|
ctx.font='9px sans-serif';
|
||||||
|
ctx.fillText(`${zHi.toFixed(2)} ~ ${zLo.toFixed(2)}`, x0+6, Math.max(priceTop+26, y0+26));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedCycles=new Set();
|
||||||
|
(overlay.cycles||[]).forEach(seg=>{
|
||||||
|
const i0=dateIndex(seg.start);
|
||||||
|
const i1=dateIndex(seg.end);
|
||||||
|
if(i0<0||i1<0) return;
|
||||||
|
const cy=seg.cycle||'Unknown';
|
||||||
|
if(cy==='Unknown') return;
|
||||||
|
usedCycles.add(cy);
|
||||||
|
const x0=padL + slot*i0;
|
||||||
|
const x1=padL + slot*(i1+1);
|
||||||
|
ctx.fillStyle=PHASE_COLOR[cy]||'rgba(139,148,158,.2)';
|
||||||
|
ctx.fillRect(x0, priceTop+priceH-10, Math.max(1,x1-x0), 10);
|
||||||
|
if(x1-x0 > 36){
|
||||||
|
ctx.fillStyle=PHASE_EDGE[cy]||'#8b949e';
|
||||||
|
ctx.font='9px sans-serif';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
ctx.fillText(zh(cy), x0+3, priceTop+priceH-2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// price grid
|
||||||
|
ctx.strokeStyle='rgba(48,54,61,.7)';
|
||||||
|
ctx.lineWidth=1;
|
||||||
|
ctx.fillStyle='#8b949e';
|
||||||
|
ctx.font='10px sans-serif';
|
||||||
|
ctx.textAlign='right';
|
||||||
|
for(let i=0;i<=4;i++){
|
||||||
|
const p=lo+(hi-lo)*i/4;
|
||||||
|
const y=yPrice(p);
|
||||||
|
ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke();
|
||||||
|
ctx.fillText(p.toFixed(2), padL-6, y+3);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLevel(ctx,yPrice,lv.range_high,'rgba(210,153,29,.85)','区间上沿',padL,W,padR,[4,3]);
|
||||||
|
drawLevel(ctx,yPrice,lv.range_low,'rgba(210,153,29,.85)','区间下沿',padL,W,padR,[4,3]);
|
||||||
|
drawLevel(ctx,yPrice,lv.ma20,'rgba(88,166,255,.55)','均线20',padL,W,padR,[2,2]);
|
||||||
|
planLevels.forEach(m=>{
|
||||||
|
const map={entry:['入场','#58a6ff'], stop:['止损','#f85149'], target1:['目标一','#3fb950'], target2:['目标二','#3fb950']};
|
||||||
|
const conf=map[m.type]; if(!conf) return;
|
||||||
|
drawLevel(ctx,yPrice,m.price,conf[1],conf[0],padL,W,padR,[6,3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// candles
|
||||||
|
bars.forEach((b,i)=>{
|
||||||
|
const o=Number(b.open), h=Number(b.high), l=Number(b.low), c=Number(b.close);
|
||||||
|
const x=xOf(i);
|
||||||
|
const up=c>=o;
|
||||||
|
const color=up?'#3fb950':'#f85149';
|
||||||
|
ctx.strokeStyle=color;
|
||||||
|
ctx.fillStyle=color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, yPrice(h));
|
||||||
|
ctx.lineTo(x, yPrice(l));
|
||||||
|
ctx.stroke();
|
||||||
|
const y1=yPrice(Math.max(o,c)), y2=yPrice(Math.min(o,c));
|
||||||
|
ctx.fillRect(x-bodyW/2, y1, bodyW, Math.max(1, y2-y1));
|
||||||
|
});
|
||||||
|
|
||||||
|
(overlay.events||[]).forEach(ev=>{
|
||||||
|
const i=dateIndex(ev.date);
|
||||||
|
if(i<0) return;
|
||||||
|
const x=xOf(i);
|
||||||
|
const y=yPrice(Number(ev.high||ev.price))-14;
|
||||||
|
const color=EVENT_COLOR[ev.event]||'#58a6ff';
|
||||||
|
ctx.fillStyle=color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y+10); ctx.lineTo(x-5, y); ctx.lineTo(x+5, y); ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.font='bold 10px sans-serif';
|
||||||
|
ctx.textAlign='center';
|
||||||
|
ctx.fillText(zh(ev.event), x, Math.max(priceTop+10, y-2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// —— 成交量 ——
|
||||||
|
ctx.fillStyle='#8b949e';
|
||||||
|
ctx.font='10px sans-serif';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
ctx.fillText('成交量', padL+2, volTop+10);
|
||||||
|
ctx.strokeStyle='rgba(48,54,61,.8)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(padL, volTop); ctx.lineTo(W-padR, volTop); ctx.stroke();
|
||||||
|
bars.forEach((b,i)=>{
|
||||||
|
const c=Number(b.close), o=Number(b.open);
|
||||||
|
const up=c>=o;
|
||||||
|
ctx.fillStyle=up?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)';
|
||||||
|
const v=vols[i];
|
||||||
|
const y=yVol(v);
|
||||||
|
ctx.fillRect(xOf(i)-bodyW/2, y, bodyW, Math.max(1, volTop+volH-y));
|
||||||
|
});
|
||||||
|
ctx.fillStyle='#8b949e';
|
||||||
|
ctx.textAlign='right';
|
||||||
|
ctx.fillText(fmtVol(maxVol), padL-6, volTop+12);
|
||||||
|
|
||||||
|
// —— MACD ——
|
||||||
|
ctx.fillStyle='#8b949e';
|
||||||
|
ctx.textAlign='left';
|
||||||
|
ctx.fillText('异同平均线', padL+2, macdTop+10);
|
||||||
|
ctx.strokeStyle='rgba(48,54,61,.8)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(padL, macdTop); ctx.lineTo(W-padR, macdTop); ctx.stroke();
|
||||||
|
// zero line
|
||||||
|
ctx.strokeStyle='rgba(139,148,158,.35)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(padL, yMacd(0)); ctx.lineTo(W-padR, yMacd(0)); ctx.stroke();
|
||||||
|
// hist
|
||||||
|
bars.forEach((_,i)=>{
|
||||||
|
const h=macd.hist[i];
|
||||||
|
if(h==null) return;
|
||||||
|
ctx.fillStyle=h>=0?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)';
|
||||||
|
const y0=yMacd(0), y1=yMacd(h);
|
||||||
|
const top=Math.min(y0,y1), bot=Math.max(y0,y1);
|
||||||
|
ctx.fillRect(xOf(i)-bodyW/2, top, bodyW, Math.max(1, bot-top));
|
||||||
|
});
|
||||||
|
// DIF / DEA lines
|
||||||
|
function strokeLine(series, color){
|
||||||
|
ctx.strokeStyle=color;
|
||||||
|
ctx.lineWidth=1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
let started=false;
|
||||||
|
series.forEach((v,i)=>{
|
||||||
|
if(v==null||!isFinite(v)){ started=false; return; }
|
||||||
|
const x=xOf(i), y=yMacd(v);
|
||||||
|
if(!started){ ctx.moveTo(x,y); started=true; }
|
||||||
|
else ctx.lineTo(x,y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
strokeLine(macd.dif, '#e3b341');
|
||||||
|
strokeLine(macd.dea, '#58a6ff');
|
||||||
|
ctx.fillStyle='#e3b341'; ctx.font='9px sans-serif'; ctx.textAlign='left';
|
||||||
|
ctx.fillText('快线', padL+70, macdTop+10);
|
||||||
|
ctx.fillStyle='#58a6ff';
|
||||||
|
ctx.fillText('慢线', padL+100, macdTop+10);
|
||||||
|
|
||||||
|
// x labels under MACD
|
||||||
|
ctx.fillStyle='#8b949e';
|
||||||
|
ctx.textAlign='center';
|
||||||
|
const step=Math.max(1, Math.floor(bars.length/5));
|
||||||
|
for(let i=0;i<bars.length;i+=step){
|
||||||
|
ctx.fillText(barDate(bars[i]).slice(5), xOf(i), H-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiIdx = chartHover>=0 && chartHover<bars.length ? chartHover : bars.length-1;
|
||||||
|
const hb=bars[hiIdx];
|
||||||
|
let phaseAt='';
|
||||||
|
const dHover=barDate(hb);
|
||||||
|
for(const seg of (overlay.phases||[])){
|
||||||
|
if(dHover>=seg.start && dHover<=seg.end){ phaseAt=zh(seg.phase); break; }
|
||||||
|
}
|
||||||
|
const difV=macd.dif[hiIdx], deaV=macd.dea[hiIdx], histV=macd.hist[hiIdx];
|
||||||
|
tip.textContent = `${selected||''} · ${barDate(hb)} 开${Number(hb.open).toFixed(2)} 高${Number(hb.high).toFixed(2)} 低${Number(hb.low).toFixed(2)} 收${Number(hb.close).toFixed(2)} 量${fmtVol(hb.volume)}${phaseAt?' · '+phaseAt:''}`
|
||||||
|
+ (difV!=null?` 快${difV.toFixed(3)} 慢${(deaV||0).toFixed(3)} 柱${(histV||0).toFixed(3)}`:'');
|
||||||
|
|
||||||
|
const legendBits=[...usedPhases].sort().map(p=>`${zh(p)}`);
|
||||||
|
const cycleBits=[...usedCycles].sort().map(c=>zh(c));
|
||||||
|
const zoneBits=[...usedZones].map(k=>ZONE_LABEL[k]||zh(k));
|
||||||
|
const src = overlay.phase_source==='1w' ? '周线阶段' : (chartFreq==='1M' ? '月线周期' : '阶段');
|
||||||
|
const parts=[];
|
||||||
|
if(zoneBits.length) parts.push(zoneBits.join('/'));
|
||||||
|
if(legendBits.length) parts.push(`${src}:`+legendBits.join('/'));
|
||||||
|
if(cycleBits.length) parts.push('周期:'+cycleBits.join('/'));
|
||||||
|
legend.textContent = parts.join(' · ');
|
||||||
|
|
||||||
|
if(chartHover>=0 && chartHover<bars.length){
|
||||||
|
const x=xOf(chartHover);
|
||||||
|
ctx.strokeStyle='rgba(88,166,255,.45)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(x,priceTop); ctx.lineTo(x, macdTop+macdH); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadChart(ts){
|
||||||
|
setHint('加载 K 线中…');
|
||||||
|
chartBars=[];
|
||||||
|
overlay={phases:[], events:[], levels:{}};
|
||||||
|
chartHover=-1;
|
||||||
|
drawChart();
|
||||||
|
try{
|
||||||
|
const q=new URLSearchParams({ts_code:ts, limit:'180'});
|
||||||
|
const [klineRes, ovRes] = await Promise.all([
|
||||||
|
fetch(`/api/v1/klines/${encodeURIComponent(chartFreq)}?`+q),
|
||||||
|
fetch(`/api/v1/wyckoff/scan/${encodeURIComponent(ts)}/overlay?freq=${encodeURIComponent(chartFreq)}&bars=180`),
|
||||||
|
]);
|
||||||
|
const data=await klineRes.json();
|
||||||
|
const items=Array.isArray(data.items)?data.items:[];
|
||||||
|
items.sort((a,b)=>String(barDate(a)).localeCompare(String(barDate(b))));
|
||||||
|
chartBars=items.slice(-160);
|
||||||
|
if(ovRes.ok){
|
||||||
|
overlay=await ovRes.json();
|
||||||
|
}
|
||||||
|
if(!chartBars.length){
|
||||||
|
setHint('暂无 K 线数据');
|
||||||
|
document.getElementById('chartTip').textContent='';
|
||||||
|
document.getElementById('chartLegend').textContent='';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHint('');
|
||||||
|
drawChart();
|
||||||
|
}catch(err){
|
||||||
|
setHint('K 线加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bindChartEvents(){
|
||||||
|
const canvas=document.getElementById('chartCanvas');
|
||||||
|
canvas.addEventListener('mousemove', ev=>{
|
||||||
|
if(!chartBars.length) return;
|
||||||
|
const rect=canvas.getBoundingClientRect();
|
||||||
|
const x=ev.clientX-rect.left;
|
||||||
|
const padL=52, padR=12;
|
||||||
|
const plotW=Math.max(10, rect.width-padL-padR);
|
||||||
|
const slot=plotW/chartBars.length;
|
||||||
|
const idx=Math.floor((x-padL)/slot);
|
||||||
|
chartHover = (idx>=0 && idx<chartBars.length) ? idx : -1;
|
||||||
|
drawChart();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mouseleave', ()=>{ chartHover=-1; drawChart(); });
|
||||||
|
window.addEventListener('resize', ()=>drawChart());
|
||||||
|
}
|
||||||
|
async function init(){
|
||||||
|
meta=await (await fetch('/api/v1/wyckoff/meta')).json();
|
||||||
|
const date=meta.latest_trade_date||'尚无扫描';
|
||||||
|
document.getElementById('hdrMeta').textContent =
|
||||||
|
`引擎 ${meta.engine_version||'-'} · ${date} · ${meta.scan_count||0} 条`;
|
||||||
|
const f=meta.facets||{};
|
||||||
|
fillSelect('fCycle', f.m_cycle, meta.cycles);
|
||||||
|
fillSelect('fPhase', f.w_phase, meta.phases);
|
||||||
|
fillSelect('fEvent', f.d_event, meta.events);
|
||||||
|
fillSelect('fSignal', f.decision_signal, meta.decision_signals);
|
||||||
|
bindChartEvents();
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
async function loadList(){
|
||||||
|
const q=new URLSearchParams();
|
||||||
|
const c=document.getElementById('fCycle').value; if(c) q.set('m_cycle',c);
|
||||||
|
const p=document.getElementById('fPhase').value; if(p) q.set('w_phase',p);
|
||||||
|
const e=document.getElementById('fEvent').value; if(e) q.set('d_event',e);
|
||||||
|
const s=document.getElementById('fSignal').value; if(s) q.set('decision_signal',s);
|
||||||
|
const sc=document.getElementById('fScore').value; if(sc) q.set('min_overall_score',sc);
|
||||||
|
const al=document.getElementById('fAlign').value; if(al) q.set('min_alignment',al);
|
||||||
|
q.set('limit','120');
|
||||||
|
const data=await (await fetch('/api/v1/wyckoff/scan?'+q)).json();
|
||||||
|
if(!data.items||!data.items.length){
|
||||||
|
const msg = (meta && meta.scan_count>0) || hasActiveFilters()
|
||||||
|
? '当前筛选无匹配结果 — 点击「重置」查看全部(库内已有扫描数据)'
|
||||||
|
: '无扫描结果 — 请在终端执行威科夫回填命令';
|
||||||
|
document.getElementById('list').innerHTML='<div class="loading">'+msg+'</div>';
|
||||||
|
document.getElementById('detail').innerHTML='<div class="loading">未选中股票</div>';
|
||||||
|
setHint('暂无股票');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html='<table><thead><tr><th>代码</th><th>月</th><th>周</th><th>日</th><th>一致</th><th>信号</th><th>分</th></tr></thead><tbody>';
|
||||||
|
data.items.forEach((r,i)=>{
|
||||||
|
html+=`<tr data-code="${r.ts_code}" class="${i===0?'active':''}">
|
||||||
|
<td><b>${r.ts_code}</b><div class="muted">${esc(r.name||'')}</div></td>
|
||||||
|
<td>${zh(r.m_cycle)}</td>
|
||||||
|
<td>${zh(r.w_phase)}<div class="muted">${zh(r.w_current_event)}</div></td>
|
||||||
|
<td>${zh(r.d_current_event)}</td>
|
||||||
|
<td class="stars">${stars(r.stars)}</td>
|
||||||
|
<td><span class="tag ${sigClass(r.decision_signal)}">${zh(r.decision_signal)}</span></td>
|
||||||
|
<td>${(r.overall_score||0).toFixed(0)}</td></tr>`;
|
||||||
|
});
|
||||||
|
html+='</tbody></table>';
|
||||||
|
document.getElementById('list').innerHTML=html;
|
||||||
|
document.querySelectorAll('#list tr[data-code]').forEach(tr=>{
|
||||||
|
tr.onclick=()=>select(tr.dataset.code);
|
||||||
|
});
|
||||||
|
select(data.items[0].ts_code);
|
||||||
|
}
|
||||||
|
async function select(ts){
|
||||||
|
selected=ts;
|
||||||
|
document.querySelectorAll('#list tr').forEach(tr=>tr.classList.toggle('active', tr.dataset.code===ts));
|
||||||
|
const d=await (await fetch('/api/v1/wyckoff/scan/'+encodeURIComponent(ts))).json();
|
||||||
|
const dec=d.decision||{};
|
||||||
|
const plan=d.plan||{};
|
||||||
|
planLevels=[];
|
||||||
|
if(plan.entry!=null) planLevels.push({type:'entry', price:plan.entry});
|
||||||
|
if(plan.stop!=null) planLevels.push({type:'stop', price:plan.stop});
|
||||||
|
if(plan.target1!=null) planLevels.push({type:'target1', price:plan.target1});
|
||||||
|
if(plan.target2!=null) planLevels.push({type:'target2', price:plan.target2});
|
||||||
|
loadChart(ts);
|
||||||
|
const wEvents=(d.weekly&&(d.weekly.active_events||d.weekly.recent_events))||[];
|
||||||
|
const dEvents=(d.daily&&(d.daily.active_events||d.daily.recent_events))||[];
|
||||||
|
const reasons=Array.isArray(dec.reasons)?dec.reasons:[];
|
||||||
|
document.getElementById('detail').innerHTML=`
|
||||||
|
<div class="card"><h3>综合研判</h3>
|
||||||
|
<div class="row"><span>信号</span><span class="tag ${sigClass(dec.signal)}">${esc(zh(dec.signal))}</span></div>
|
||||||
|
<div class="row"><span>星级</span><span class="stars">${stars(dec.stars)}</span></div>
|
||||||
|
<div class="row"><span>得分</span><span class="val">${(dec.overall_score||0).toFixed(1)}</span></div>
|
||||||
|
<div class="row"><span>一致性</span><span class="val">${(dec.alignment||0).toFixed(0)}%</span></div>
|
||||||
|
<div class="row"><span>趋势/结构/入场</span><span class="muted">${(dec.scores?.trend||0).toFixed(0)} / ${(dec.scores?.structure||0).toFixed(0)} / ${(dec.scores?.entry||0).toFixed(0)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>月线</h3>
|
||||||
|
<div class="row"><span>周期</span><span class="val">${esc(zh(d.monthly?.cycle))}</span></div>
|
||||||
|
<div class="row"><span>置信度</span><span>${(d.monthly?.confidence||0).toFixed(0)}%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>周线</h3>
|
||||||
|
<div class="row"><span>周期</span><span class="val">${esc(zh(d.weekly?.cycle))}</span></div>
|
||||||
|
<div class="row"><span>阶段</span><span class="val">${esc(zh(d.weekly?.phase))}</span></div>
|
||||||
|
<div class="row"><span>事件</span><span>${esc(zh(d.weekly?.current_event))}</span></div>
|
||||||
|
<div class="chain">活跃事件:${esc(zhList(Array.isArray(wEvents)?wEvents:[]).join(' · ')||'-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>日线</h3>
|
||||||
|
<div class="row"><span>事件</span><span class="val">${esc(zh(d.daily?.current_event))}</span></div>
|
||||||
|
<div class="row"><span>强度</span><span>${(d.daily?.entry_score||0).toFixed(0)}</span></div>
|
||||||
|
<div class="chain">活跃事件:${esc(zhList(Array.isArray(dEvents)?dEvents:[]).join(' · ')||'-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>交易计划</h3>
|
||||||
|
<div class="row"><span>入场</span><span>${plan.entry??'-'}</span></div>
|
||||||
|
<div class="row"><span>止损</span><span class="bad">${plan.stop??'-'}</span></div>
|
||||||
|
<div class="row"><span>目标一</span><span class="good">${plan.target1??'-'}</span></div>
|
||||||
|
<div class="row"><span>目标二</span><span class="good">${plan.target2??'-'}</span></div>
|
||||||
|
<div class="row"><span>盈亏比</span><span>${plan.rr??'-'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>理由</h3>
|
||||||
|
<div class="reasons">${reasons.map(x=>`<div>${esc(zhText(x))}</div>`).join('')||'-'}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
init().catch(e=>{document.getElementById('list').innerHTML='<div class="loading bad">加载失败:'+esc(e)+'</div>';});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
@@ -120,6 +120,31 @@ def backfill_industry():
|
|||||||
typer.echo(f"Industry data stored: {count} stocks classified")
|
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")
|
@backfill_app.command("signals")
|
||||||
def backfill_signals(
|
def backfill_signals(
|
||||||
days: int = typer.Option(500, help="Calendar days to scan for historical signals"),
|
days: int = typer.Option(500, help="Calendar days to scan for historical signals"),
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ async def ema52_screening_job():
|
|||||||
"""EOD EMA52 screening — delegates to shared implementation."""
|
"""EOD EMA52 screening — delegates to shared implementation."""
|
||||||
from ashare_dp.signals.detectors import run_ema52_screening
|
from ashare_dp.signals.detectors import run_ema52_screening
|
||||||
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}")
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ class Scheduler:
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the scheduler and register jobs."""
|
"""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
|
# EOD job: 15:05 Beijing time, Mon-Fri
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
@@ -62,8 +67,25 @@ class Scheduler:
|
|||||||
replace_existing=True,
|
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()
|
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):
|
def shutdown(self):
|
||||||
"""Shut down the scheduler."""
|
"""Shut down the scheduler."""
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ class KLineRepository:
|
|||||||
if conditions:
|
if conditions:
|
||||||
where_clause = "WHERE " + " AND ".join(conditions)
|
where_clause = "WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
# 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"""
|
sql = f"""
|
||||||
SELECT * FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true)
|
SELECT * FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true)
|
||||||
{where_clause}
|
{where_clause}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ DDL_STATEMENTS = [
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
# ── Signal Intelligence ──
|
# ── 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 (
|
CREATE TABLE IF NOT EXISTS signal_instance (
|
||||||
id BIGINT PRIMARY KEY DEFAULT nextval('seq_signal_id'),
|
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_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)
|
CREATE INDEX IF NOT EXISTS idx_signal_ts_code ON signal_instance(ts_code, trade_date)
|
||||||
""",
|
""",
|
||||||
# ── Trading Memory ──
|
# ── Trading Memory ──
|
||||||
|
"""CREATE SEQUENCE IF NOT EXISTS seq_trade_id""",
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS trade_log (
|
CREATE TABLE IF NOT EXISTS trade_log (
|
||||||
id BIGINT PRIMARY KEY DEFAULT nextval('seq_trade_id'),
|
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
|
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_date ON trade_log(trade_date)""",
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_trade_signal ON trade_log(signal_type)""",
|
"""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)""",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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]
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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]
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from ashare_dp.wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
__all__ = ["rule_registry"]
|
||||||
@@ -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."""
|
||||||
@@ -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(),
|
||||||
|
]
|
||||||
@@ -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(),
|
||||||
|
]
|
||||||
@@ -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()]
|
||||||
@@ -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()
|
||||||
@@ -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", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Wyckoff Screener engine version — bump when rules change."""
|
||||||
|
|
||||||
|
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
||||||
|
ARCHITECTURE_VERSION = "1.0"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user