feat: 威科夫多周期选股引擎与中文图表界面

新增规则驱动的月/周/日结构识别、决策融合与交易计划,提供扫描 API、本地 K 线(成交量/MACD/吸筹区间标注)及回填调度;K 线无起始日时默认取最近 N 根。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 14:26:46 +08:00
co-authored by Cursor
parent cc95bbb638
commit 03851d2247
35 changed files with 3861 additions and 12 deletions
+8
View File
@@ -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
+170
View File
@@ -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."""
+731
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
"""
+25
View File
@@ -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"),
+14
View File
@@ -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}")
+24 -2
View File
@@ -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."""
+13
View File
@@ -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}
+49 -4
View File
@@ -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)""",
] ]
+153
View File
@@ -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)
+5
View File
@@ -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"]
+329
View File
@@ -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]
+102
View File
@@ -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,
},
)
+195
View File
@@ -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},
},
},
)
+149
View File
@@ -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,
},
)
+206
View File
@@ -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,
)
+133
View File
@@ -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]
+78
View File
@@ -0,0 +1,78 @@
"""Phase Engine — Phase AE 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,
},
)
+243
View File
@@ -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,
}
+78
View File
@@ -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 0100 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)
+21
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from ashare_dp.wyckoff.rules.registry import rule_registry
__all__ = ["rule_registry"]
+33
View File
@@ -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."""
+126
View File
@@ -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(),
]
+254
View File
@@ -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(),
]
+163
View File
@@ -0,0 +1,163 @@
"""Phase AE 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()]
+39
View File
@@ -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()
+35
View File
@@ -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", []),
},
)
+245
View File
@@ -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 {}
+4
View File
@@ -0,0 +1,4 @@
"""Wyckoff Screener engine version — bump when rules change."""
WYCKOFF_ENGINE_VERSION = "v1.0.0"
ARCHITECTURE_VERSION = "1.0"
+63
View File
@@ -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
+42
View File
@@ -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
+94
View File
@@ -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