feat(ECR-009): Crypto Wyckoff Screener 独立页(D/W/M)
移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""Crypto Wyckoff Screener API + page (independent of /api/analyze)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler
|
||||
from crypto_wyckoff import store as wyckoff_store
|
||||
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||
|
||||
bp = Blueprint("wyckoff_crypto", __name__)
|
||||
|
||||
_scheduler_started = False
|
||||
_sched_lock = threading.Lock()
|
||||
|
||||
|
||||
def ensure_scheduler() -> None:
|
||||
global _scheduler_started
|
||||
with _sched_lock:
|
||||
if _scheduler_started:
|
||||
return
|
||||
if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"):
|
||||
return
|
||||
interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60"))
|
||||
max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS")
|
||||
max_symbols = int(max_sym) if max_sym else None
|
||||
start_scheduler(interval_sec=interval, max_symbols=max_symbols)
|
||||
_scheduler_started = True
|
||||
|
||||
|
||||
@bp.route("/wyckoff_crypto")
|
||||
def page():
|
||||
ensure_scheduler()
|
||||
return render_template("wyckoff_crypto.html")
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/meta")
|
||||
def meta():
|
||||
ensure_scheduler()
|
||||
latest = wyckoff_store.latest_trade_date()
|
||||
return jsonify(
|
||||
{
|
||||
"architecture_version": ARCHITECTURE_VERSION,
|
||||
"engine_version": WYCKOFF_ENGINE_VERSION,
|
||||
"latest_trade_date": latest,
|
||||
"scan_count": wyckoff_store.count_for_date(latest),
|
||||
"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],
|
||||
"timezone": "UTC",
|
||||
"timeframes": ["1d", "1w", "1M"],
|
||||
"status": get_status(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/status")
|
||||
def status():
|
||||
ensure_scheduler()
|
||||
return jsonify(get_status())
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/scan")
|
||||
def scan():
|
||||
ensure_scheduler()
|
||||
rows = wyckoff_store.query_scan(
|
||||
trade_date=request.args.get("trade_date"),
|
||||
m_cycle=request.args.get("m_cycle"),
|
||||
w_phase=request.args.get("w_phase"),
|
||||
d_event=request.args.get("d_event"),
|
||||
decision_signal=request.args.get("decision_signal"),
|
||||
min_overall_score=_float_or_none(request.args.get("min_overall_score")),
|
||||
min_alignment=_float_or_none(request.args.get("min_alignment")),
|
||||
sort=request.args.get("sort") or "overall_score",
|
||||
limit=min(int(request.args.get("limit") or 100), 500),
|
||||
offset=int(request.args.get("offset") or 0),
|
||||
)
|
||||
return jsonify({"rows": rows, "count": len(rows)})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/symbol/<path:symbol>")
|
||||
def symbol_detail(symbol: str):
|
||||
ensure_scheduler()
|
||||
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"))
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(row)
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/tick", methods=["POST"])
|
||||
def manual_tick():
|
||||
"""Manual one-shot tick (debug). Optional JSON/query max_symbols."""
|
||||
ensure_scheduler()
|
||||
max_sym = request.args.get("max_symbols") or (request.json or {}).get("max_symbols")
|
||||
max_symbols = int(max_sym) if max_sym else None
|
||||
|
||||
def _job():
|
||||
try:
|
||||
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_job, daemon=True).start()
|
||||
return jsonify({"ok": True, "started": True})
|
||||
|
||||
|
||||
def _float_or_none(v):
|
||||
if v in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -15,6 +15,7 @@ from api.analyze import bp as analyze_bp
|
||||
from api.pages import bp as pages_bp
|
||||
from api.symbols import bp as symbols_bp
|
||||
from api.trend import bp as trend_bp
|
||||
from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -23,6 +24,12 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(analyze_bp)
|
||||
app.register_blueprint(symbols_bp)
|
||||
app.register_blueprint(trend_bp)
|
||||
app.register_blueprint(wyckoff_crypto_bp)
|
||||
# Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1
|
||||
try:
|
||||
ensure_scheduler()
|
||||
except Exception:
|
||||
pass
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Crypto Wyckoff Screener</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',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;gap:12px;flex-wrap:wrap}
|
||||
header h1{font-size:15px;font-weight:700}
|
||||
header .meta{font-size:11px;color:var(--muted)}
|
||||
header a{color:var(--accent);font-size:12px;text-decoration:none}
|
||||
.filters{padding:8px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;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:100px}
|
||||
.filters button{background:var(--accent);border:0;color:#fff;border-radius:4px;padding:6px 12px;font-size:11px;cursor:pointer}
|
||||
.filters button.secondary{background:transparent;border:1px solid var(--border);color:var(--muted)}
|
||||
.main{flex:1;display:grid;grid-template-columns:1fr 340px;min-height:0}
|
||||
@media(max-width:900px){.main{grid-template-columns:1fr}}
|
||||
.pane{min-height:0;overflow:auto;border-right:1px solid var(--border)}
|
||||
.pane:last-child{border-right:0;background:var(--panel)}
|
||||
.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)}
|
||||
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.StrongBuy,.tag.Buy{background:rgba(63,185,80,.15);color:var(--green)}
|
||||
.tag.Sell,.tag.Avoid{background:rgba(248,81,73,.15);color:var(--red)}
|
||||
.tag.Watch{background:rgba(210,153,29,.15);color:var(--orange)}
|
||||
#detail{padding:12px;font-size:12px;line-height:1.55;color:var(--muted)}
|
||||
#detail h3{color:var(--text);font-size:13px;margin-bottom:8px}
|
||||
#detail .kv{margin:4px 0}
|
||||
#detail .kv b{color:var(--text);display:inline-block;min-width:88px}
|
||||
#detail ul{margin:8px 0 0 16px}
|
||||
.empty{padding:24px;color:var(--muted);font-size:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Crypto Wyckoff Screener</h1>
|
||||
<div class="meta" id="metaLine">加载中…</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="/">← 主站</a>
|
||||
· D/W/M · UTC · 每分钟 tip
|
||||
</div>
|
||||
</header>
|
||||
<div class="filters">
|
||||
<label>决策
|
||||
<select id="fDecision"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label>月 Cycle
|
||||
<select id="fCycle"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label>周 Phase
|
||||
<select id="fPhase"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label>日 Event
|
||||
<select id="fEvent"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label>最低分
|
||||
<input id="fMinScore" type="number" placeholder="0" step="1">
|
||||
</label>
|
||||
<label>排序
|
||||
<select id="fSort">
|
||||
<option value="overall_score">overall_score</option>
|
||||
<option value="alignment">alignment</option>
|
||||
<option value="entry_score">entry_score</option>
|
||||
<option value="stars">stars</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="btnRefresh" type="button">刷新</button>
|
||||
<button id="btnTick" class="secondary" type="button">手动 Tick</button>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="pane">
|
||||
<h2>SCAN <span id="rowCount"></span></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th><th>Decision</th><th>M Cycle</th><th>W Phase</th>
|
||||
<th>D Event</th><th>Score</th><th>Align</th><th>★</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="emptyHint" style="display:none">暂无数据。调度器正在回填/扫描,稍后点刷新;或点「手动 Tick」(可先设 CRYPTO_WYCKOFF_MAX_SYMBOLS 做小样本)。</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<h2>DETAIL</h2>
|
||||
<div id="detail">点选左侧一行</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var meta = null;
|
||||
var rows = [];
|
||||
var active = null;
|
||||
|
||||
function fillSelect(id, values){
|
||||
var el = document.getElementById(id);
|
||||
values.forEach(function(v){
|
||||
var o = document.createElement('option');
|
||||
o.value = v; o.textContent = v; el.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function loadMeta(){
|
||||
return fetch('/api/wyckoff_crypto/meta').then(function(r){return r.json()}).then(function(m){
|
||||
meta = m;
|
||||
if (!document.getElementById('fDecision').options.length || document.getElementById('fDecision').options.length===1){
|
||||
fillSelect('fDecision', m.decision_signals||[]);
|
||||
fillSelect('fCycle', m.cycles||[]);
|
||||
fillSelect('fPhase', m.phases||[]);
|
||||
fillSelect('fEvent', m.events||[]);
|
||||
}
|
||||
var st = m.status||{};
|
||||
document.getElementById('metaLine').textContent =
|
||||
'engine '+m.engine_version+' · date '+(m.latest_trade_date||'-')+
|
||||
' · rows '+m.scan_count+
|
||||
' · tick '+(st.last_tick_at||'pending')+
|
||||
(st.running?' · RUNNING':'')+
|
||||
(st.last_error?' · err '+st.last_error:'');
|
||||
});
|
||||
}
|
||||
|
||||
function qs(){
|
||||
var p = new URLSearchParams();
|
||||
var d=document.getElementById('fDecision').value; if(d) p.set('decision_signal',d);
|
||||
var c=document.getElementById('fCycle').value; if(c) p.set('m_cycle',c);
|
||||
var ph=document.getElementById('fPhase').value; if(ph) p.set('w_phase',ph);
|
||||
var e=document.getElementById('fEvent').value; if(e) p.set('d_event',e);
|
||||
var ms=document.getElementById('fMinScore').value; if(ms) p.set('min_overall_score',ms);
|
||||
p.set('sort', document.getElementById('fSort').value);
|
||||
p.set('limit','200');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function loadScan(){
|
||||
return fetch('/api/wyckoff_crypto/scan?'+qs()).then(function(r){return r.json()}).then(function(data){
|
||||
rows = data.rows||[];
|
||||
document.getElementById('rowCount').textContent = '('+rows.length+')';
|
||||
document.getElementById('emptyHint').style.display = rows.length? 'none':'block';
|
||||
var tb = document.getElementById('tbody');
|
||||
tb.innerHTML = '';
|
||||
rows.forEach(function(row, i){
|
||||
var tr = document.createElement('tr');
|
||||
if (active && active.ts_code===row.ts_code) tr.className='active';
|
||||
tr.innerHTML =
|
||||
'<td>'+row.ts_code+'</td>'+
|
||||
'<td><span class="tag '+row.decision_signal+'">'+row.decision_signal+'</span></td>'+
|
||||
'<td>'+row.m_cycle+'</td>'+
|
||||
'<td>'+row.w_phase+'</td>'+
|
||||
'<td>'+row.d_current_event+'</td>'+
|
||||
'<td>'+(row.overall_score!=null?Number(row.overall_score).toFixed(1):'-')+'</td>'+
|
||||
'<td>'+(row.alignment!=null?Number(row.alignment).toFixed(0):'-')+'</td>'+
|
||||
'<td>'+(row.stars||'')+'</td>';
|
||||
tr.onclick = function(){ showDetail(row); Array.prototype.forEach.call(tb.querySelectorAll('tr'),function(x){x.className='';}); tr.className='active'; };
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showDetail(row){
|
||||
active = row;
|
||||
var reasons=[];
|
||||
try{ reasons = JSON.parse(row.reasons_json||'[]'); }catch(e){}
|
||||
var plan = [];
|
||||
if(row.entry!=null) plan.push('entry '+row.entry);
|
||||
if(row.stop!=null) plan.push('stop '+row.stop);
|
||||
if(row.target1!=null) plan.push('t1 '+row.target1);
|
||||
if(row.target2!=null) plan.push('t2 '+row.target2);
|
||||
if(row.rr!=null) plan.push('rr '+row.rr);
|
||||
document.getElementById('detail').innerHTML =
|
||||
'<h3>'+row.ts_code+'</h3>'+
|
||||
'<div class="kv"><b>Decision</b> '+row.decision_signal+' · risk '+row.risk+'</div>'+
|
||||
'<div class="kv"><b>Month</b> '+row.m_cycle+'</div>'+
|
||||
'<div class="kv"><b>Week</b> '+row.w_cycle+' / '+row.w_phase+' / '+row.w_current_event+'</div>'+
|
||||
'<div class="kv"><b>Day</b> '+row.d_current_event+'</div>'+
|
||||
'<div class="kv"><b>Scores</b> overall '+fmt(row.overall_score)+' align '+fmt(row.alignment)+' entry '+fmt(row.entry_score)+'</div>'+
|
||||
'<div class="kv"><b>Plan</b> '+(plan.join(' · ')||'无(非 tradable)')+'</div>'+
|
||||
'<div class="kv"><b>Updated</b> '+(row.scanned_at||'')+'</div>'+
|
||||
'<ul>'+reasons.map(function(x){return '<li>'+x+'</li>';}).join('')+'</ul>';
|
||||
}
|
||||
function fmt(v){ return v==null?'-':Number(v).toFixed(1); }
|
||||
|
||||
function refresh(){ return loadMeta().then(loadScan); }
|
||||
|
||||
document.getElementById('btnRefresh').onclick = function(){ refresh(); };
|
||||
document.getElementById('btnTick').onclick = function(){
|
||||
fetch('/api/wyckoff_crypto/tick', {method:'POST'}).then(function(){
|
||||
document.getElementById('metaLine').textContent = '手动 tick 已启动…';
|
||||
setTimeout(refresh, 3000);
|
||||
});
|
||||
};
|
||||
['fDecision','fCycle','fPhase','fEvent','fMinScore','fSort'].forEach(function(id){
|
||||
document.getElementById(id).addEventListener('change', loadScan);
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user