默认主/次/次次改为 45m/15m/5m、开始时间一周;SD/CD 按 hist 摆位置和箭头;去掉笔线段背驰与第四类勾选。 Co-authored-by: Cursor <cursoragent@cursor.com>
437 lines
14 KiB
JavaScript
437 lines
14 KiB
JavaScript
/* 资金面 + 情绪面。只打本站中转。OI 叠主图左侧,其余叠情绪副图。 */
|
|
window.ChanDeriv = (function () {
|
|
var snapshotReq = 0;
|
|
var latestReq = 0;
|
|
var overlayReq = 0;
|
|
var caches = {};
|
|
|
|
var METRICS = [
|
|
{
|
|
id: 'oi',
|
|
checkbox: 'showOi',
|
|
metric: 'open_interest_history',
|
|
field: 'open_interest_amount',
|
|
seriesKey: 'oiSeries',
|
|
target: 'main',
|
|
color: 'rgba(123, 31, 162, 0.85)',
|
|
title: 'OI',
|
|
priceFormat: { type: 'volume' }
|
|
},
|
|
{
|
|
id: 'taker',
|
|
checkbox: 'showDeriv',
|
|
metric: 'taker_buy_sell_ratio',
|
|
field: 'buy_sell_ratio',
|
|
seriesKey: 'takerSeries',
|
|
target: 'sentiment',
|
|
color: '#0d9488',
|
|
title: '买卖比',
|
|
priceFormat: { type: 'price', precision: 3 }
|
|
},
|
|
{
|
|
id: 'lsAccount',
|
|
checkbox: 'showDeriv',
|
|
metric: 'long_short_account_ratio',
|
|
field: 'long_short_ratio',
|
|
seriesKey: 'lsAccountSeries',
|
|
target: 'sentiment',
|
|
color: '#2563eb',
|
|
title: '多空',
|
|
priceFormat: { type: 'price', precision: 3 }
|
|
},
|
|
{
|
|
id: 'lsTop',
|
|
checkbox: 'showDeriv',
|
|
metric: 'top_long_short_position_ratio',
|
|
field: 'long_short_ratio',
|
|
seriesKey: 'lsTopSeries',
|
|
target: 'sentiment',
|
|
color: '#ea580c',
|
|
title: '大户',
|
|
priceFormat: { type: 'price', precision: 3 }
|
|
},
|
|
{
|
|
id: 'funding',
|
|
checkbox: 'showDeriv',
|
|
metric: 'funding_rate_history',
|
|
field: 'funding_rate',
|
|
seriesKey: 'fundingHistSeries',
|
|
target: 'sentiment',
|
|
color: '#7c3aed',
|
|
title: '费率%',
|
|
scale: 'funding',
|
|
mul: 100,
|
|
priceFormat: { type: 'price', precision: 4 }
|
|
}
|
|
];
|
|
|
|
function isCrypto() {
|
|
return ($('#dataSource').val() || 'crypto') === 'crypto';
|
|
}
|
|
|
|
function currentSymbol() {
|
|
return $('#symbol').val() || 'BTC/USDT:USDT';
|
|
}
|
|
|
|
function fmtOi(n) {
|
|
if (n == null || !isFinite(Number(n))) return '—';
|
|
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 1 });
|
|
}
|
|
|
|
function fmtFunding(n) {
|
|
if (n == null || !isFinite(Number(n))) return '—';
|
|
return (Number(n) * 100).toFixed(4) + '%';
|
|
}
|
|
|
|
function fmtChg(n) {
|
|
if (n == null || !isFinite(Number(n))) return '—';
|
|
var v = Number(n);
|
|
return (v > 0 ? '+' : '') + v.toFixed(2) + '%';
|
|
}
|
|
|
|
function fmtBasis(n) {
|
|
if (n == null || !isFinite(Number(n))) return '—';
|
|
var v = Number(n);
|
|
return (v > 0 ? '+' : '') + v.toFixed(2);
|
|
}
|
|
|
|
function fmtRatio(n) {
|
|
if (n == null || !isFinite(Number(n))) return '—';
|
|
return Number(n).toFixed(3);
|
|
}
|
|
|
|
function setChip(id, text, tone) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.classList.remove('up', 'down');
|
|
if (tone) el.classList.add(tone);
|
|
}
|
|
|
|
function tone(n, invert) {
|
|
if (!isFinite(n) || n === 0) return null;
|
|
var up = n > 0;
|
|
if (invert) up = !up;
|
|
return up ? 'up' : 'down';
|
|
}
|
|
|
|
function renderDerivEmpty() {
|
|
setChip('derivOi', 'OI —');
|
|
setChip('derivOiChg', 'Δ —');
|
|
setChip('derivFunding', '费率 —');
|
|
setChip('derivBasis', '基差 —');
|
|
var src = document.getElementById('derivSrc');
|
|
if (src) src.textContent = '';
|
|
}
|
|
|
|
function renderSentimentEmpty() {
|
|
setChip('derivTaker', '买卖比 —');
|
|
setChip('derivLs', '多空 —');
|
|
setChip('derivTop', '大户 —');
|
|
}
|
|
|
|
function setVisible(on) {
|
|
var bar = document.getElementById('derivBar');
|
|
var wrap = document.getElementById('showSentimentWrap');
|
|
if (bar) bar.style.display = on ? '' : 'none';
|
|
if (wrap) wrap.style.display = on ? '' : 'none';
|
|
if (!on) clearAllSeries();
|
|
}
|
|
|
|
function activeKlines() {
|
|
var data = (typeof currentData !== 'undefined') ? currentData : null;
|
|
if (!data) return [];
|
|
if ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) return data.sub_sub_kline_data;
|
|
if ($('#elementPeriodKline').is(':checked') && data.element_kline_data) return data.element_kline_data;
|
|
return data.kline_data || [];
|
|
}
|
|
|
|
function klineRangeMs() {
|
|
var rows = activeKlines();
|
|
if (!rows.length) return null;
|
|
var start = new Date(rows[0].date).getTime();
|
|
var end = new Date(rows[rows.length - 1].date).getTime();
|
|
if (!isFinite(start) || !isFinite(end)) return null;
|
|
return { start: start, end: end };
|
|
}
|
|
|
|
function toPoints(rows, field, mul) {
|
|
var out = [];
|
|
var lastT = null;
|
|
var factor = mul || 1;
|
|
(rows || []).forEach(function (row) {
|
|
var ms = Number(row.timestamp);
|
|
var val = Number(row[field]);
|
|
if (!isFinite(ms) || !isFinite(val)) return;
|
|
var t = Math.floor(ms / 1000);
|
|
var v = val * factor;
|
|
if (lastT === t) {
|
|
out[out.length - 1].value = v;
|
|
return;
|
|
}
|
|
lastT = t;
|
|
out.push({ time: t, value: v });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function candleTimesSec() {
|
|
var rows = activeKlines();
|
|
var times = [];
|
|
(rows || []).forEach(function (k) {
|
|
var t = Math.floor(new Date(k.date).getTime() / 1000);
|
|
if (isFinite(t)) times.push(t);
|
|
});
|
|
return times;
|
|
}
|
|
|
|
function alignToTimes(src, times) {
|
|
if (!src || !src.length || !times || !times.length) return [];
|
|
var out = [];
|
|
var j = 0;
|
|
var lastVal;
|
|
for (var i = 0; i < times.length; i++) {
|
|
var t = times[i];
|
|
while (j < src.length && src[j].time <= t) {
|
|
lastVal = src[j].value;
|
|
j++;
|
|
}
|
|
if (lastVal !== undefined) out.push({ time: t, value: lastVal });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function syncSentimentTime() {
|
|
if (!tvWidget || !tvWidget.mainChart || !tvWidget.sentimentChart) return;
|
|
try {
|
|
var vr = tvWidget.mainChart.timeScale().getVisibleRange();
|
|
var lr = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
|
var opts = tvWidget.mainChart.timeScale().options ? tvWidget.mainChart.timeScale().options() : null;
|
|
if (opts) {
|
|
tvWidget.sentimentChart.timeScale().applyOptions({
|
|
barSpacing: opts.barSpacing,
|
|
rightOffset: opts.rightOffset
|
|
});
|
|
}
|
|
if (vr) tvWidget.sentimentChart.timeScale().setVisibleRange(vr);
|
|
if (lr) tvWidget.sentimentChart.timeScale().setVisibleLogicalRange(lr);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function targetChart(spec) {
|
|
if (!tvWidget) return null;
|
|
if (spec.target === 'main') return tvWidget.mainChart;
|
|
return tvWidget.sentimentChart || null;
|
|
}
|
|
|
|
function safeRemove(chart, seriesKey) {
|
|
var series = tvWidget && tvWidget.series && tvWidget.series[seriesKey];
|
|
if (series && chart) {
|
|
try { chart.removeSeries(series); } catch (e) {}
|
|
}
|
|
if (tvWidget && tvWidget.series) tvWidget.series[seriesKey] = null;
|
|
}
|
|
|
|
function clearAllSeries() {
|
|
METRICS.forEach(function (spec) {
|
|
safeRemove(targetChart(spec), spec.seriesKey);
|
|
});
|
|
safeRemove(tvWidget && tvWidget.sentimentChart, 'sentimentBaseSeries');
|
|
try {
|
|
if (tvWidget && tvWidget.mainChart) {
|
|
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
function drawSeries(spec, points) {
|
|
var chart = targetChart(spec);
|
|
if (!chart || !points || !points.length) return;
|
|
safeRemove(chart, spec.seriesKey);
|
|
if (spec.target === 'main') {
|
|
chart.applyOptions({
|
|
leftPriceScale: {
|
|
visible: true,
|
|
borderVisible: false,
|
|
scaleMargins: { top: 0.08, bottom: 0.12 }
|
|
}
|
|
});
|
|
}
|
|
var opts = {
|
|
color: spec.color,
|
|
lineWidth: 1,
|
|
title: spec.title,
|
|
lastValueVisible: true,
|
|
priceLineVisible: false,
|
|
priceFormat: spec.priceFormat
|
|
};
|
|
if (spec.target === 'main') opts.priceScaleId = 'left';
|
|
if (spec.scale) {
|
|
opts.priceScaleId = spec.scale;
|
|
chart.priceScale(spec.scale).applyOptions({
|
|
scaleMargins: { top: 0.15, bottom: 0.1 },
|
|
borderVisible: false
|
|
});
|
|
}
|
|
var series = chart.addLineSeries(opts);
|
|
series.setData(points);
|
|
tvWidget.series[spec.seriesKey] = series;
|
|
if (spec.target === 'sentiment') syncSentimentTime();
|
|
if (spec.target === 'main' && typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
|
window._redrawFxBoxVerticalOverlay();
|
|
setTimeout(window._redrawFxBoxVerticalOverlay, 50);
|
|
}
|
|
}
|
|
|
|
function drawRatioBaseline(points) {
|
|
var chart = tvWidget && tvWidget.sentimentChart;
|
|
if (!chart || !points || !points.length) return;
|
|
safeRemove(chart, 'sentimentBaseSeries');
|
|
var baseline = points.map(function (p) { return { time: p.time, value: 1 }; });
|
|
var series = chart.addLineSeries({
|
|
color: 'rgba(120, 120, 120, 0.45)',
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
title: '1.0'
|
|
});
|
|
series.setData(baseline);
|
|
tvWidget.series.sentimentBaseSeries = series;
|
|
}
|
|
|
|
function loadOne(spec, range, symbol, req) {
|
|
var checked = $('#' + spec.checkbox).is(':checked');
|
|
var chart = targetChart(spec);
|
|
if (!checked) {
|
|
safeRemove(chart, spec.seriesKey);
|
|
if (spec.id === 'oi') {
|
|
try {
|
|
if (tvWidget && tvWidget.mainChart) {
|
|
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
|
}
|
|
} catch (e) {}
|
|
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
|
window._redrawFxBoxVerticalOverlay();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (!chart) return;
|
|
var times = candleTimesSec();
|
|
var key = [symbol, spec.metric, times[0] || '', times[times.length - 1] || '', times.length].join(':');
|
|
var cached = caches[spec.id];
|
|
function paint(raw) {
|
|
var aligned = spec.target === 'main' || spec.target === 'sentiment' ? alignToTimes(raw, times) : raw;
|
|
if (!aligned.length) aligned = raw;
|
|
drawSeries(spec, aligned);
|
|
if (spec.target === 'sentiment') maybeDrawBaseline();
|
|
}
|
|
if (cached && cached.raw && cached.raw.length) {
|
|
paint(cached.raw);
|
|
if (cached.key === key) return;
|
|
}
|
|
if (!range || !window.ChanApi || !ChanApi.sentimentMetrics) return;
|
|
ChanApi.sentimentMetrics({
|
|
metric: spec.metric,
|
|
symbol: symbol,
|
|
start: range.start - 8 * 60 * 60 * 1000,
|
|
end: range.end,
|
|
limit: 2000
|
|
}).then(function (payload) {
|
|
if (req !== overlayReq) return;
|
|
var raw = toPoints(payload && payload.data, spec.field, spec.mul);
|
|
if (!raw.length) return;
|
|
caches[spec.id] = { key: key, raw: raw };
|
|
if ($('#' + spec.checkbox).is(':checked') && targetChart(spec)) paint(raw);
|
|
}).catch(function (err) {
|
|
if (req !== overlayReq) return;
|
|
console.warn(spec.title + ' 序列不可用', err);
|
|
});
|
|
}
|
|
|
|
function maybeDrawBaseline() {
|
|
var times = candleTimesSec();
|
|
if (!times.length) return;
|
|
drawRatioBaseline(times.map(function (t) { return { time: t, value: 1 }; }));
|
|
syncSentimentTime();
|
|
}
|
|
|
|
function loadOverlays() {
|
|
if (!isCrypto()) {
|
|
clearAllSeries();
|
|
return;
|
|
}
|
|
if (!tvWidget || !tvWidget.mainChart) return;
|
|
var range = klineRangeMs();
|
|
var symbol = currentSymbol();
|
|
var req = ++overlayReq;
|
|
METRICS.forEach(function (spec) {
|
|
loadOne(spec, range, symbol, req);
|
|
});
|
|
maybeDrawBaseline();
|
|
}
|
|
|
|
function loadSnapshot() {
|
|
if (!isCrypto() || !window.ChanApi || !ChanApi.derivatives) return;
|
|
var req = ++snapshotReq;
|
|
ChanApi.derivatives({ symbol: currentSymbol() }).then(function (row) {
|
|
if (req !== snapshotReq) return;
|
|
var chg = Number(row.oi_change_pct);
|
|
var fund = Number(row.funding_rate);
|
|
var basis = Number(row.basis);
|
|
setChip('derivOi', 'OI ' + fmtOi(row.open_interest));
|
|
setChip('derivOiChg', 'Δ ' + fmtChg(row.oi_change_pct), tone(chg));
|
|
setChip('derivFunding', '费率 ' + fmtFunding(row.funding_rate), tone(fund));
|
|
setChip('derivBasis', '基差 ' + fmtBasis(row.basis), tone(basis));
|
|
var src = document.getElementById('derivSrc');
|
|
if (src) src.textContent = row.exchange || '';
|
|
}).catch(function (err) {
|
|
if (req !== snapshotReq) return;
|
|
console.warn('资金面快照不可用', err);
|
|
renderDerivEmpty();
|
|
var src = document.getElementById('derivSrc');
|
|
if (src) src.textContent = '不可用';
|
|
});
|
|
}
|
|
|
|
function loadLatest() {
|
|
if (!isCrypto() || !window.ChanApi || !ChanApi.sentimentLatest) return;
|
|
var req = ++latestReq;
|
|
ChanApi.sentimentLatest({ symbol: currentSymbol() }).then(function (payload) {
|
|
if (req !== latestReq) return;
|
|
var data = (payload && payload.data) || {};
|
|
var taker = data.taker_buy_sell_ratio || {};
|
|
var ls = data.long_short_account_ratio || {};
|
|
var top = data.top_long_short_position_ratio || {};
|
|
var takerN = Number(taker.buy_sell_ratio);
|
|
var lsN = Number(ls.long_short_ratio);
|
|
var topN = Number(top.long_short_ratio);
|
|
setChip('derivTaker', '买卖比 ' + fmtRatio(taker.buy_sell_ratio), isFinite(takerN) ? (takerN >= 1 ? 'up' : 'down') : null);
|
|
setChip('derivLs', '多空 ' + fmtRatio(ls.long_short_ratio), isFinite(lsN) ? (lsN >= 1 ? 'up' : 'down') : null);
|
|
setChip('derivTop', '大户 ' + fmtRatio(top.long_short_ratio), isFinite(topN) ? (topN >= 1 ? 'up' : 'down') : null);
|
|
}).catch(function (err) {
|
|
if (req !== latestReq) return;
|
|
console.warn('情绪快照不可用', err);
|
|
renderSentimentEmpty();
|
|
});
|
|
}
|
|
|
|
function sync(opts) {
|
|
opts = opts || {};
|
|
var show = isCrypto();
|
|
setVisible(show);
|
|
if (!show) return;
|
|
loadSnapshot();
|
|
loadLatest();
|
|
if (opts.overlay !== false) loadOverlays();
|
|
}
|
|
|
|
return {
|
|
sync: sync,
|
|
setVisible: setVisible,
|
|
loadOiOverlay: loadOverlays,
|
|
loadOverlays: loadOverlays
|
|
};
|
|
})();
|