Files
Chan/web/templates/wyckoff_crypto.html
T
jackyu66gitandCursor 18a7f485e6 feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:45:40 +08:00

1046 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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)}
.combo-add{display:none;gap:8px;flex-wrap:wrap;align-items:flex-end;padding:8px 14px;border-bottom:1px solid var(--border);background:#0d1117}
.combo-add.open{display:flex}
.combo-add .err{color:var(--red);font-size:11px;align-self:center}
.main{flex:1;display:flex;min-height:0;overflow:hidden}
.pane{min-height:0;min-width:120px}
.pane#paneList{
flex:0 0 var(--pane-left, 360px);width:var(--pane-left, 360px);
background:var(--panel);display:flex;flex-direction:column;overflow:hidden
}
.pane.chart-pane{
flex:1 1 auto;min-width:200px;display:flex;flex-direction:column;
overflow:hidden;background:#0d1117
}
.pane#paneDetail{
flex:0 0 var(--pane-right, 300px);width:var(--pane-right, 300px);
background:var(--panel);display:flex;flex-direction:column;overflow:hidden
}
.splitter{flex:0 0 5px;cursor:col-resize;background:var(--border);position:relative;z-index:2}
.splitter:hover,.splitter.active{background:var(--accent)}
.splitter::after{content:'';position:absolute;inset:-2px -3px}
@media(max-width:900px){
.main{flex-direction:column;overflow:auto}
.splitter{display:none}
.pane#paneList,.pane#paneDetail,.pane.chart-pane{flex:none;width:100%!important;min-height:280px}
}
.pane h2{
font-size:10px;color:var(--muted);letter-spacing:1px;padding:8px 10px;
border-bottom:1px solid var(--border);background:var(--panel);
display:flex;align-items:center;justify-content:space-between;gap:8px;z-index:1;flex:0 0 auto
}
.chart-pane h2{background:#0d1117}
.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:200px;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:78%}
#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;text-align:right}
.table-wrap{overflow:auto;flex:1;min-height:0}
table{width:max-content;min-width:100%;border-collapse:collapse;font-size:11px}
th,td{white-space:nowrap}
th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:600;position:sticky;top:0;background:var(--panel);z-index:1}
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{flex:1;overflow:auto;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>
· 周期组合 · UTC+8 · tip
</div>
</header>
<div class="filters">
<label>周期组合
<select id="fCombo"></select>
</label>
<button id="btnAddCombo" class="secondary" type="button">添加组合</button>
<button id="btnDelCombo" class="secondary" type="button" title="删除自定义组合">删除</button>
<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="combo-add" id="comboAdd">
<label><select id="addHigh"></select></label>
<label><select id="addMid"></select></label>
<label><select id="addLow"></select></label>
<label>名称<input id="addLabel" placeholder="可选,如 4h/1h/15m" style="min-width:140px"></label>
<button id="btnSaveCombo" type="button">保存</button>
<button id="btnCancelCombo" class="secondary" type="button">取消</button>
<span class="err" id="comboErr"></span>
</div>
<div class="main" id="mainPanes">
<div class="pane" id="paneList">
<h2>扫描 <span id="rowCount"></span></h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>决策</th><th>高周期</th><th>中阶段</th>
<th>低事件</th><th>得分</th><th>一致</th><th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="empty" id="emptyHint" style="display:none">暂无数据。调度器正在回填/扫描,稍后点刷新;或点「手动 Tick」。</div>
</div>
<div class="splitter" id="splitLeft" title="拖动调整宽度"></div>
<div class="pane chart-pane" id="paneChart">
<h2>
<span>K 线</span>
<span class="chart-tools" id="chartTools"></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="splitter" id="splitRight" title="拖动调整宽度"></div>
<div class="pane" id="paneDetail">
<h2>详情</h2>
<div id="detail">点选左侧一行</div>
</div>
</div>
<script>
(function(){
var meta = null;
var rows = [];
var active = null;
var combo = {id:'h8_4_1', label:'8h / 4h / 1h', high:'8h', mid:'4h', low:'1h'};
var chartFreq = '1h';
var chartSymbol = '';
var chartBars = [];
var chartHover = -1;
var overlay = {phases:[], events:[], levels:{}, zones:[], cycles:[]};
var planLevels = [];
var allowedTfs = [];
var chartReqSeq = 0;
var 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)'
};
var 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)'
};
var ZONE_EDGE = {
Accumulation:'#58a6ff', ReAccumulation:'#79b8ff',
Distribution:'#f85149', ReDistribution:'#ff7b72', Range:'#d2991d'
};
var ZONE_LABEL = {
Accumulation:'吸筹区间', ReAccumulation:'再吸筹区间',
Distribution:'派发区间', ReDistribution:'再派发区间', Range:'震荡区间'
};
var 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'
};
var 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'
};
var 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){ return (v==null||v===''||v==='-') ? '-' : (ZH[v]||v); }
var symbolNames = {};
var DEFAULT_SYMBOL = 'BTC/USDT:USDT';
function shortSymbol(code){
var s = String(code||'');
var head = s.split(':')[0];
return head || s;
}
function nameOf(row){
return shortSymbol(row && row.ts_code);
}
function fillSelect(id, values, reset){
var el = document.getElementById(id);
if (reset){
var keepFirst = el.options.length && el.options[0].value==='';
el.innerHTML = keepFirst ? '<option value="">全部</option>' : '';
}
values.forEach(function(v){
var o = document.createElement('option');
o.value = v; o.textContent = zh(v); el.appendChild(o);
});
}
function esc(s){
return String(s==null?'':s).replace(/[&<>"']/g,function(c){
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]);
});
}
function comboId(){ return document.getElementById('fCombo').value || combo.id; }
function rebuildChartTools(){
var tools = document.getElementById('chartTools');
var tfs = [combo.low, combo.mid, combo.high];
if (tfs.indexOf(chartFreq)<0) chartFreq = combo.low;
tools.innerHTML = '';
tfs.forEach(function(tf){
var b = document.createElement('button');
b.type = 'button';
b.setAttribute('data-freq', tf);
b.textContent = tf;
if (tf===chartFreq) b.className = 'on';
b.onclick = function(){ setFreq(tf); };
tools.appendChild(b);
});
}
function fillComboSelect(combos){
var el = document.getElementById('fCombo');
var cur = el.value || localStorage.getItem('cw_combo') || '';
el.innerHTML = '';
(combos||[]).forEach(function(c){
var o = document.createElement('option');
o.value = c.id;
o.textContent = c.label + (c.builtin?'':' ✎');
el.appendChild(o);
});
if (cur && Array.prototype.some.call(el.options, function(o){return o.value===cur;})) el.value = cur;
else if (el.options.length) el.selectedIndex = 0;
}
function fillAddTfSelects(){
['addHigh','addMid','addLow'].forEach(function(id){
var el = document.getElementById(id);
el.innerHTML = '';
allowedTfs.forEach(function(tf){
var o = document.createElement('option');
o.value = tf; o.textContent = tf; el.appendChild(o);
});
});
document.getElementById('addHigh').value = '8h';
document.getElementById('addMid').value = '4h';
document.getElementById('addLow').value = '1h';
}
function isIntradayTf(tf){
var t = String(tf||'');
return /m$/.test(t) || /h$/.test(t);
}
function barTs(b){
if (b && b.ts != null) return Number(b.ts);
var raw = b && (b.datetime || b.date || b.trade_date || b.trade_time);
var n = Date.parse(raw);
return isFinite(n) ? n : 0;
}
function barDate(b){
// date key for overlay matching (YYYY-MM-DD)
if (b && b.date) return String(b.date).slice(0,10);
var raw = String((b && (b.datetime || b.trade_date || b.trade_time)) || '');
return raw.slice(0,10);
}
function barLabel(b){
var raw = String((b && (b.datetime || b.date || b.trade_date || b.trade_time)) || '');
if (isIntradayTf(chartFreq)){
// already Asia/Shanghai (+08:00) from API
var m = raw.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if (m) return m[2]+'-'+m[3]+' '+m[4]+':'+m[5];
if (b && b.ts){
var d = new Date(Number(b.ts) + 8*3600*1000); // fallback UTC+8
var pad = function(n){ return n<10?'0'+n:''+n; };
return pad(d.getUTCMonth()+1)+'-'+pad(d.getUTCDate())+' '+pad(d.getUTCHours())+':'+pad(d.getUTCMinutes());
}
}
return raw.slice(0,10);
}
function barAxisLabel(b){
var raw = String((b && (b.datetime || b.date || b.trade_date || b.trade_time)) || '');
if (isIntradayTf(chartFreq)){
var m = raw.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if (m) return m[3]+' '+m[4]+':'+m[5]; // DD HH:MM (UTC+8)
return barLabel(b);
}
return raw.slice(5,10); // MM-DD
}
function setHint(msg){
var el = document.getElementById('chartHint');
el.textContent = msg || '';
el.style.display = msg ? 'flex' : 'none';
}
function dateIndex(dateStr){
var d = String(dateStr).slice(0,10);
var best = -1;
for (var i=0;i<chartBars.length;i++){
if (barDate(chartBars[i]) <= d) best = i;
else break;
}
return best;
}
function fmtVol(v){
var n = Number(v)||0;
if (n>=1e9) return (n/1e9).toFixed(2)+'B';
if (n>=1e6) return (n/1e6).toFixed(2)+'M';
if (n>=1e3) return (n/1e3).toFixed(1)+'K';
return String(Math.round(n));
}
function fmtPx(p){
var n = Number(p);
if (!isFinite(n)) return '-';
if (n >= 1000) return n.toFixed(1);
if (n >= 1) return n.toFixed(2);
return n.toFixed(4);
}
function emaSeries(values, period){
var out = new Array(values.length).fill(null);
if (!values.length) return out;
var k = 2/(period+1), prev=null, sum=0, n=0;
for (var i=0;i<values.length;i++){
var 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, slow, signal){
fast = fast||12; slow=slow||26; signal=signal||9;
var emaFast = emaSeries(closes, fast);
var emaSlow = emaSeries(closes, slow);
var dif = closes.map(function(_,i){
if (emaFast[i]==null || emaSlow[i]==null) return null;
return emaFast[i]-emaSlow[i];
});
var difFilled = dif.map(function(v,i){
if (v!=null) return v;
for (var j=i-1;j>=0;j--) if (dif[j]!=null) return dif[j];
return 0;
});
var firstValid = dif.findIndex(function(v){return v!=null;});
var dea = new Array(closes.length).fill(null);
if (firstValid>=0){
var slice = difFilled.slice(firstValid);
var deaSlice = emaSeries(slice, signal);
for (var i=0;i<deaSlice.length;i++) dea[firstValid+i]=deaSlice[i];
}
var hist = closes.map(function(_,i){
if (dif[i]==null||dea[i]==null) return null;
return dif[i]-dea[i];
});
return {dif:dif, dea:dea, hist:hist};
}
function drawLevel(ctx, yOf, price, color, label, padL, W, padR, dash){
if (price==null || !isFinite(price)) return;
var 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+' '+fmtPx(price), padL+4, y-3);
ctx.restore();
}
function drawChart(){
var wrap = document.getElementById('chartWrap');
var canvas = document.getElementById('chartCanvas');
var tip = document.getElementById('chartTip');
var legend = document.getElementById('chartLegend');
var dpr = window.devicePixelRatio || 1;
var 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';
var 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);
var bars = chartBars;
if (!bars.length){
tip.textContent = '';
legend.textContent = '';
return;
}
var padL=58, padR=12, padTop=28, padBot=18, gap=6;
var volH = Math.max(48, Math.floor(H*0.14));
var macdH = Math.max(56, Math.floor(H*0.16));
var priceH = Math.max(80, H - padTop - padBot - volH - macdH - gap*2);
var priceTop = padTop;
var volTop = priceTop + priceH + gap;
var macdTop = volTop + volH + gap;
var plotW = Math.max(10, W-padL-padR);
var lo=Infinity, hi=-Infinity;
bars.forEach(function(b){
lo = Math.min(lo, Number(b.low));
hi = Math.max(hi, Number(b.high));
});
var lv = overlay.levels || {};
[lv.range_high, lv.range_low, lv.ma20].forEach(function(p){
if (p!=null){ lo=Math.min(lo,Number(p)); hi=Math.max(hi,Number(p)); }
});
(overlay.zones||[]).forEach(function(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(function(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; }
var span = hi-lo;
lo -= span*0.06; hi += span*0.08;
function yPrice(p){ return priceTop + (hi-p)/(hi-lo)*priceH; }
var slot = plotW/bars.length;
var bodyW = Math.max(1, Math.min(10, slot*0.62));
function xOf(i){ return padL + slot*(i+0.5); }
var vols = bars.map(function(b){ return Number(b.volume)||0; });
var maxVol = Math.max.apply(null, vols.concat([1]));
function yVol(v){ return volTop + volH - (v/maxVol)*(volH-14); }
var closes = bars.map(function(b){ return Number(b.close); });
var macd = calcMacd(closes);
var mLo=0, mHi=0;
macd.dif.forEach(function(v,i){
[v, macd.dea[i], macd.hist[i]].forEach(function(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; }
var mPad=(mHi-mLo)*0.12;
mLo-=mPad; mHi+=mPad;
function yMacd(v){ return macdTop + (mHi-v)/(mHi-mLo)*macdH; }
var usedPhases = {};
(overlay.phases||[]).forEach(function(seg){
var i0=dateIndex(seg.start), i1=dateIndex(seg.end);
if (i0<0||i1<0) return;
var phase = seg.phase||'None';
if (phase==='None'||phase==='Unknown') return;
usedPhases[phase]=1;
var x0=padL+slot*i0, 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';
var label = zh(phase);
if (x1-x0 > label.length*8) ctx.fillText(label, x0+4, priceTop+12);
});
var usedZones = {};
(overlay.zones||[]).forEach(function(z){
var kind = z.kind||'Range';
var i0=dateIndex(z.start), i1=dateIndex(z.end);
if (i0<0||i1<0) return;
var zHi=Number(z.high), zLo=Number(z.low);
if (!isFinite(zHi)||!isFinite(zLo)||zHi<=zLo) return;
usedZones[kind]=1;
var x0=padL+slot*i0, x1=padL+slot*(i1+1);
var y0=yPrice(zHi), y1=yPrice(zLo);
var 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([]);
var 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(fmtPx(zHi)+' ~ '+fmtPx(zLo), x0+6, Math.max(priceTop+26, y0+26));
}
});
var usedCycles = {};
(overlay.cycles||[]).forEach(function(seg){
var i0=dateIndex(seg.start), i1=dateIndex(seg.end);
if (i0<0||i1<0) return;
var cy = seg.cycle||'Unknown';
if (cy==='Unknown') return;
usedCycles[cy]=1;
var x0=padL+slot*i0, 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);
}
});
ctx.strokeStyle = 'rgba(48,54,61,.7)';
ctx.lineWidth = 1;
ctx.fillStyle = '#8b949e';
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
for (var gi=0; gi<=4; gi++){
var gp = lo+(hi-lo)*gi/4;
var gy = yPrice(gp);
ctx.beginPath(); ctx.moveTo(padL,gy); ctx.lineTo(W-padR,gy); ctx.stroke();
ctx.fillText(fmtPx(gp), padL-6, gy+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(function(m){
var map = {entry:['入场','#58a6ff'], stop:['止损','#f85149'], target1:['目标一','#3fb950'], target2:['目标二','#3fb950']};
var conf = map[m.type]; if(!conf) return;
drawLevel(ctx,yPrice,m.price,conf[1],conf[0],padL,W,padR,[6,3]);
});
bars.forEach(function(b,i){
var o=Number(b.open), h=Number(b.high), l=Number(b.low), c=Number(b.close);
var x=xOf(i);
var up=c>=o;
var color=up?'#3fb950':'#f85149';
ctx.strokeStyle=color; ctx.fillStyle=color;
ctx.beginPath(); ctx.moveTo(x, yPrice(h)); ctx.lineTo(x, yPrice(l)); ctx.stroke();
var 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(function(ev){
var i = dateIndex(ev.date);
if (i<0) return;
var x=xOf(i);
var y=yPrice(Number(ev.high||ev.price))-14;
var 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(function(b,i){
var up = Number(b.close)>=Number(b.open);
ctx.fillStyle = up?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)';
var y = yVol(vols[i]);
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);
ctx.fillStyle='#8b949e';
ctx.textAlign='left';
ctx.fillText('MACD', padL+2, macdTop+10);
ctx.strokeStyle='rgba(48,54,61,.8)';
ctx.beginPath(); ctx.moveTo(padL, macdTop); ctx.lineTo(W-padR, macdTop); ctx.stroke();
ctx.strokeStyle='rgba(139,148,158,.35)';
ctx.beginPath(); ctx.moveTo(padL, yMacd(0)); ctx.lineTo(W-padR, yMacd(0)); ctx.stroke();
bars.forEach(function(_,i){
var h=macd.hist[i]; if(h==null) return;
ctx.fillStyle=h>=0?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)';
var y0=yMacd(0), y1=yMacd(h);
var top=Math.min(y0,y1), bot=Math.max(y0,y1);
ctx.fillRect(xOf(i)-bodyW/2, top, bodyW, Math.max(1, bot-top));
});
function strokeLine(series, color){
ctx.strokeStyle=color; ctx.lineWidth=1.2; ctx.beginPath();
var started=false;
series.forEach(function(v,i){
if (v==null||!isFinite(v)){ started=false; return; }
var 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='#8b949e';
ctx.textAlign='center';
var step = Math.max(1, Math.floor(bars.length/5));
for (var di=0; di<bars.length; di+=step){
ctx.fillText(barAxisLabel(bars[di]), xOf(di), H-4);
}
var hiIdx = chartHover>=0 && chartHover<bars.length ? chartHover : bars.length-1;
var hb = bars[hiIdx];
var phaseAt = '';
var dHover = barDate(hb);
(overlay.phases||[]).forEach(function(seg){
if (!phaseAt && dHover>=seg.start && dHover<=seg.end) phaseAt = zh(seg.phase);
});
var difV=macd.dif[hiIdx], deaV=macd.dea[hiIdx], histV=macd.hist[hiIdx];
tip.textContent =
(active?active.ts_code:'')+' · '+barLabel(hb)+
' 开'+fmtPx(hb.open)+' 高'+fmtPx(hb.high)+' 低'+fmtPx(hb.low)+' 收'+fmtPx(hb.close)+
' 量'+fmtVol(hb.volume)+(phaseAt?' · '+phaseAt:'')+
(difV!=null?' DIF'+difV.toFixed(3)+' DEA'+(deaV||0).toFixed(3):'');
var parts = [];
var zoneBits = Object.keys(usedZones).map(function(k){ return ZONE_LABEL[k]||zh(k); });
var phaseBits = Object.keys(usedPhases).map(zh);
var cycleBits = Object.keys(usedCycles).map(zh);
var src = '阶段('+ (overlay.phase_source||'') +')';
if (zoneBits.length) parts.push(zoneBits.join('/'));
if (phaseBits.length) parts.push(src+''+phaseBits.join('/'));
if (cycleBits.length) parts.push('周期:'+cycleBits.join('/'));
legend.textContent = parts.join(' · ');
if (chartHover>=0 && chartHover<bars.length){
var hx = xOf(chartHover);
ctx.strokeStyle='rgba(88,166,255,.45)';
ctx.beginPath(); ctx.moveTo(hx,priceTop); ctx.lineTo(hx, macdTop+macdH); ctx.stroke();
}
}
function loadChart(symbol, opts){
opts = opts || {};
var soft = !!opts.soft;
var sameView = soft && chartSymbol === symbol && chartBars.length > 0;
var req = ++chartReqSeq;
chartSymbol = symbol || '';
if (!sameView){
setHint('加载 K 线中…');
chartBars = [];
overlay = {phases:[], events:[], levels:{}, zones:[], cycles:[]};
chartHover = -1;
drawChart();
}
var q = new URLSearchParams({symbol:symbol, tf:chartFreq, limit:'180', combo_id:comboId()});
var oq = new URLSearchParams({symbol:symbol, tf:chartFreq, bars:'180', combo_id:comboId()});
return Promise.all([
fetch('/api/wyckoff_crypto/klines?'+q).then(function(r){return r.json();}),
fetch('/api/wyckoff_crypto/overlay?'+oq).then(function(r){return r.ok?r.json():{};})
]).then(function(pair){
if (req !== chartReqSeq) return; // stale
var data = pair[0], ov = pair[1]||{};
var items = Array.isArray(data.items) ? data.items.slice() : [];
items.sort(function(a,b){ return barTs(a) - barTs(b); });
chartBars = items.slice(-160);
overlay = {
phases: ov.phases||[],
events: ov.events||[],
levels: ov.levels||{},
zones: ov.zones||[],
cycles: ov.cycles||[],
phase_source: ov.phase_source||chartFreq
};
if (!chartBars.length){
setHint('暂无 K 线(本地 bars 尚未回填该组合周期)');
document.getElementById('chartTip').textContent='';
document.getElementById('chartLegend').textContent='';
return;
}
setHint('');
drawChart();
}).catch(function(){
if (req !== chartReqSeq) return;
if (!sameView) setHint('K 线加载失败');
});
}
function setFreq(freq){
chartFreq = freq;
document.querySelectorAll('#chartTools button').forEach(function(b){
b.classList.toggle('on', b.getAttribute('data-freq')===freq);
});
if (active) loadChart(active.ts_code);
}
function applyCombo(c){
if (!c) return;
var changed = !combo || combo.id !== c.id;
combo = c;
localStorage.setItem('cw_combo', c.id);
if (changed){
chartFreq = c.low;
rebuildChartTools();
} else if (!document.getElementById('chartTools').children.length){
rebuildChartTools();
}
}
function loadMeta(){
return fetch('/api/wyckoff_crypto/meta?combo_id='+encodeURIComponent(comboId()||'')).then(function(r){return r.json()}).then(function(m){
meta = m;
allowedTfs = m.allowed_tfs || [];
symbolNames = m.symbol_names || {};
if (m.default_symbol) DEFAULT_SYMBOL = m.default_symbol;
fillComboSelect(m.combos||[]);
if (m.combo) applyCombo(m.combo);
if (document.getElementById('fDecision').options.length===1){
fillSelect('fDecision', m.decision_signals||[]);
fillSelect('fCycle', m.cycles||[]);
fillSelect('fPhase', m.phases||[]);
fillSelect('fEvent', m.events||[]);
}
fillAddTfSelects();
var st = m.status||{};
document.getElementById('metaLine').textContent =
'engine '+m.engine_version+' · '+combo.label+
' · 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();
p.set('combo_id', comboId());
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 restorePaneWidths(){
var left = parseInt(localStorage.getItem('cw_pane_left')||'360', 10);
var right = parseInt(localStorage.getItem('cw_pane_right')||'300', 10);
if (!isFinite(left) || left < 160) left = 360;
if (!isFinite(right) || right < 160) right = 300;
applyPaneWidths(left, right);
}
function loadScan(opts){
opts = opts || {};
var quiet = !!opts.quiet;
var listWrap = document.querySelector('#paneList .table-wrap');
var detailEl = document.getElementById('detail');
var listScroll = listWrap ? listWrap.scrollTop : 0;
var detailScroll = detailEl ? detailEl.scrollTop : 0;
var keepCode = active ? active.ts_code : '';
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){
var tr = document.createElement('tr');
if (active && active.ts_code===row.ts_code) tr.className='active';
tr.innerHTML =
'<td title="'+esc(row.ts_code)+'"><b>'+esc(nameOf(row))+'</b></td>'+
'<td><span class="tag '+esc(row.decision_signal)+'">'+esc(zh(row.decision_signal))+'</span></td>'+
'<td>'+esc(zh(row.m_cycle))+'</td>'+
'<td>'+esc(zh(row.w_phase))+'</td>'+
'<td>'+esc(zh(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);
});
if (rows.length){
var prefer = null;
if (keepCode) prefer = rows.find(function(r){ return r.ts_code===keepCode; });
if (!prefer) prefer = rows.find(function(r){ return r.ts_code===DEFAULT_SYMBOL; }) || rows[0];
Array.prototype.forEach.call(tb.querySelectorAll('tr'),function(tr, idx){
tr.className = (rows[idx] && rows[idx].ts_code===prefer.ts_code) ? 'active' : '';
});
var same = keepCode && prefer.ts_code === keepCode && chartSymbol === keepCode && chartBars.length > 0;
if (same){
renderDetail(prefer, {keepScroll: true, detailScroll: detailScroll});
// 手动刷新:软更新 K 线;自动刷新:不重拉,避免图面跳动
if (!quiet) loadChart(prefer.ts_code, {soft: true});
} else {
showDetail(prefer);
}
}
if (listWrap) listWrap.scrollTop = listScroll;
restorePaneWidths();
// 栏宽恢复后按当前数据重绘(计划线等可能已变)
if (quiet && chartBars.length) drawChart();
});
}
function renderDetail(row, opts){
opts = opts || {};
active = row;
planLevels = [];
if (row.entry!=null) planLevels.push({type:'entry', price:row.entry});
if (row.stop!=null) planLevels.push({type:'stop', price:row.stop});
if (row.target1!=null) planLevels.push({type:'target1', price:row.target1});
if (row.target2!=null) planLevels.push({type:'target2', price:row.target2});
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);
var detailEl = document.getElementById('detail');
detailEl.innerHTML =
'<h3>'+esc(nameOf(row))+' <span style="color:var(--muted);font-weight:400;font-size:11px">'+esc(row.ts_code)+'</span></h3>'+
'<div class="kv"><b>组合</b> '+esc(combo.label)+'</div>'+
'<div class="kv"><b>决策</b> '+esc(zh(row.decision_signal))+' · 风险 '+esc(zh(row.risk))+'</div>'+
'<div class="kv"><b>高 '+esc(combo.high)+'</b> '+esc(zh(row.m_cycle))+'</div>'+
'<div class="kv"><b>中 '+esc(combo.mid)+'</b> '+esc(zh(row.w_cycle))+' / '+esc(zh(row.w_phase))+' / '+esc(zh(row.w_current_event))+'</div>'+
'<div class="kv"><b>低 '+esc(combo.low)+'</b> '+esc(zh(row.d_current_event))+'</div>'+
'<div class="kv"><b>得分</b> 综合 '+fmt(row.overall_score)+' 一致 '+fmt(row.alignment)+' 入场 '+fmt(row.entry_score)+'</div>'+
'<div class="kv"><b>计划</b> '+(plan.join(' · ')||'无(非可交易)')+'</div>'+
'<div class="kv"><b>更新</b> '+esc(row.scanned_at||'')+'</div>'+
'<ul>'+reasons.map(function(x){return '<li>'+esc(x)+'</li>';}).join('')+'</ul>';
if (opts.keepScroll) detailEl.scrollTop = opts.detailScroll || 0;
}
function showDetail(row){
renderDetail(row);
loadChart(row.ts_code);
}
function fmt(v){ return v==null?'-':Number(v).toFixed(1); }
function refresh(opts){ return loadMeta().then(function(){ return loadScan(opts||{}); }); }
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, 5000);
});
};
document.getElementById('fCombo').addEventListener('change', function(){
localStorage.setItem('cw_combo', comboId());
active = null;
refresh();
});
document.getElementById('btnAddCombo').onclick = function(){
document.getElementById('comboAdd').classList.toggle('open');
document.getElementById('comboErr').textContent = '';
};
document.getElementById('btnCancelCombo').onclick = function(){
document.getElementById('comboAdd').classList.remove('open');
};
document.getElementById('btnSaveCombo').onclick = function(){
var body = {
high: document.getElementById('addHigh').value,
mid: document.getElementById('addMid').value,
low: document.getElementById('addLow').value,
label: document.getElementById('addLabel').value || undefined
};
fetch('/api/wyckoff_crypto/combos', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if (!res.ok){
document.getElementById('comboErr').textContent = res.d.error || '保存失败';
return;
}
document.getElementById('comboAdd').classList.remove('open');
fillComboSelect(res.d.combos||[]);
document.getElementById('fCombo').value = res.d.combo.id;
localStorage.setItem('cw_combo', res.d.combo.id);
refresh();
});
};
document.getElementById('btnDelCombo').onclick = function(){
var id = comboId();
if (!id) return;
if (!confirm('删除组合 '+id+' ?内置组合不可删。')) return;
fetch('/api/wyckoff_crypto/combos/'+encodeURIComponent(id), {method:'DELETE'})
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if (!res.ok){ alert(res.d.error||'删除失败'); return; }
localStorage.removeItem('cw_combo');
fillComboSelect(res.d.combos||[]);
refresh();
});
};
['fDecision','fCycle','fPhase','fEvent','fMinScore','fSort'].forEach(function(id){
document.getElementById(id).addEventListener('change', loadScan);
});
var canvas = document.getElementById('chartCanvas');
canvas.addEventListener('mousemove', function(ev){
if (!chartBars.length) return;
var rect = canvas.getBoundingClientRect();
var x = ev.clientX - rect.left;
var padL=58, padR=12;
var plotW = Math.max(10, rect.width-padL-padR);
var slot = plotW/chartBars.length;
var idx = Math.floor((x-padL)/slot);
chartHover = (idx>=0 && idx<chartBars.length) ? idx : -1;
drawChart();
});
canvas.addEventListener('mouseleave', function(){ chartHover=-1; drawChart(); });
window.addEventListener('resize', function(){ drawChart(); });
function applyPaneWidths(left, right){
var root = document.documentElement;
root.style.setProperty('--pane-left', left + 'px');
root.style.setProperty('--pane-right', right + 'px');
try{
localStorage.setItem('cw_pane_left', String(left));
localStorage.setItem('cw_pane_right', String(right));
}catch(e){}
}
function initPaneResize(){
var left = parseInt(localStorage.getItem('cw_pane_left')||'360', 10);
var right = parseInt(localStorage.getItem('cw_pane_right')||'300', 10);
if (!isFinite(left) || left < 160) left = 360;
if (!isFinite(right) || right < 160) right = 300;
applyPaneWidths(left, right);
function bind(splitEl, side){
var dragging = false, startX = 0, startL = 0, startR = 0;
function onMove(ev){
if (!dragging) return;
var main = document.getElementById('mainPanes');
var total = main.clientWidth;
var dx = ev.clientX - startX;
var nl = startL, nr = startR;
if (side === 'left') nl = startL + dx;
else nr = startR - dx;
var minL = 160, minR = 160, minC = 220;
nl = Math.max(minL, Math.min(nl, total - minR - minC - 10));
nr = Math.max(minR, Math.min(nr, total - nl - minC - 10));
applyPaneWidths(nl, nr);
if (typeof drawChart === 'function') drawChart();
}
function onUp(){
if (!dragging) return;
dragging = false;
splitEl.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
if (typeof drawChart === 'function') drawChart();
}
splitEl.addEventListener('mousedown', function(ev){
if (window.matchMedia('(max-width:900px)').matches) return;
ev.preventDefault();
dragging = true;
startX = ev.clientX;
startL = document.getElementById('paneList').getBoundingClientRect().width;
startR = document.getElementById('paneDetail').getBoundingClientRect().width;
splitEl.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
});
}
bind(document.getElementById('splitLeft'), 'left');
bind(document.getElementById('splitRight'), 'right');
}
initPaneResize();
// seed combo from localStorage before first meta
(function(){
var saved = localStorage.getItem('cw_combo');
var el = document.getElementById('fCombo');
el.innerHTML = '<option value="'+(saved||'h8_4_1')+'">'+(saved||'h8_4_1')+'</option>';
})();
rebuildChartTools();
refresh();
setInterval(function(){ refresh({quiet: true}); }, 30000);
})();
</script>
</body>
</html>