自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
1046 lines
43 KiB
HTML
1046 lines
43 KiB
HTML
<!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 ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|