import { dispose, init, getSupportedIndicators } from 'klinecharts' const MINUTE = 60 * 1000 const LOAD_BAR_COUNT = 500 const BAR_SPACE_STEP = 5 const BAR_SPACE_MIN = 2 const BAR_SPACE_MAX = 50 const PERIODS = [ { key: '1m', period: { span: 1, type: 'minute' }, candleType: 'candle_solid' }, { key: '5m', period: { span: 5, type: 'minute' }, candleType: 'candle_solid' }, { key: '15m', period: { span: 15, type: 'minute' }, candleType: 'candle_solid' }, { key: '1h', period: { span: 1, type: 'hour' }, candleType: 'candle_solid' }, { key: '1d', period: { span: 1, type: 'day' }, candleType: 'candle_solid' } ] const PRICE_OVERLAY_INDICATORS = new Set(['MA', 'EMA', 'BOLL', 'SMA', 'BBI', 'SAR']) const INDICATORS = getSupportedIndicators().map(name => ({ key: name, name, stacked: PRICE_OVERLAY_INDICATORS.has(name) })) let chart = null let rawBars = null let rawLoaded = false let activePeriodKey = PERIODS[0].key let isDark = false let activeIndicators = new Set() let closePopup = () => {} let closeCrosshair = () => {} let fileName = null let dataIntervalMin = 1 function getActivePeriodConfig() { return PERIODS.find(({ key }) => key === activePeriodKey) ?? PERIODS[0] } function parseTimestamp(line, parts, fmt) { const tsNum = Number(parts[0]) if (!isNaN(tsNum) && tsNum > 1e12 && tsNum < 1e14) return tsNum if (!isNaN(tsNum) && tsNum > 1e9 && tsNum < 1e12) return tsNum * 1000 if (fmt === 'binance') { let ts = tsNum if (ts > 0 && ts < 1e13) ts *= 1000 return ts } const dateStr = parts[0] const timeStr = parts[1] ?? '' if (dateStr.includes('.')) { const y = Number(dateStr.slice(0, 4)) const m = Number(dateStr.slice(5, 7)) - 1 const d = Number(dateStr.slice(8, 10)) const h = Number(timeStr.slice(0, 2)) const mi = Number(timeStr.slice(3, 5)) return Date.UTC(y, m, d, h, mi, 0, 0) } if (dateStr.includes('-')) { const y = Number(dateStr.slice(0, 4)) const m = Number(dateStr.slice(5, 7)) - 1 const d = Number(dateStr.slice(8, 10)) const h = Number(timeStr.slice(0, 2) || '0') const mi = Number(timeStr.slice(3, 5) || '0') return Date.UTC(y, m, d, h, mi, 0, 0) } if (dateStr.length === 8 && /^\d+$/.test(dateStr)) { const y = Number(dateStr.slice(0, 4)) const m = Number(dateStr.slice(4, 6)) - 1 const d = Number(dateStr.slice(6, 8)) const h = Number((parts[1] || '').slice(0, 2) || '0') const mi = Number((parts[1] || '').slice(3, 5) || '0') return Date.UTC(y, m, d, h, mi, 0, 0) } if (line.includes(' ')) { const dt = line.split(/[,\t]/)[0] const [ds, ts] = dt.split(' ') if (ds && ts) { const y = Number(ds.slice(0, 4)) const m = Number(ds.slice(4, 6)) - 1 const d = Number(ds.slice(6, 8)) const h = Number(ts.slice(0, 2)) const mi = Number(ts.slice(3, 5)) return Date.UTC(y, m, d, h, mi, 0, 0) } } return NaN } function detectFormat(firstLine) { const trimmed = firstLine.trim() const lower = trimmed.toLowerCase() if (lower.startsWith('timestamp') || lower.startsWith('datetime') || lower.startsWith('time') || lower.startsWith('date')) { const cols = trimmed.split(/[,\t]/) const remaining = cols.slice(1).join(',') return { type: 'header', sub: detectFormat(remaining) ?? 'generic', headerCols: cols } } const parts = trimmed.split(/[,\t]/) if (parts.length >= 6 && /^\d{10,13}$/.test(parts[0])) return 'binance' if (parts.length >= 6 && parts[0].includes('.')) return 'dot' if (parts.length >= 5 && parts[0].includes('-')) return 'dash' if (parts.length >= 4 && parts[0].length === 8 && /^\d+$/.test(parts[0])) return 'yyyymmdd' return 'generic' } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0) if (lines.length < 1) return null const fmt = detectFormat(lines[0]) const hasHeader = fmt.type === 'header' const startIndex = hasHeader ? 1 : 0 if (lines.length <= startIndex) return null const count = lines.length - startIndex const tsArr = new Float64Array(count) const oArr = new Float64Array(count) const hArr = new Float64Array(count) const lArr = new Float64Array(count) const cArr = new Float64Array(count) const vArr = new Float64Array(count) let n = 0 for (let i = startIndex; i < lines.length; i++) { const line = lines[i] if (!line) continue const parts = line.split(/[,\t]/) if (parts.length < 5) continue let ts, o, h, l, c, v = 0 ts = parseTimestamp(line, parts, fmt) if (hasHeader) { const oIdx = (parts[1] || '').includes(':') ? 2 : 1 o = Number(parts[oIdx]) h = Number(parts[oIdx + 1]) l = Number(parts[oIdx + 2]) c = Number(parts[oIdx + 3]) v = Number(parts[oIdx + 4] || 0) } else { o = Number(parts[2]) h = Number(parts[3]) l = Number(parts[4]) c = Number(parts[5]) v = Number(parts[6] || 0) } if (Number.isNaN(ts) || !isFinite(o)) continue tsArr[n] = ts oArr[n] = o hArr[n] = h lArr[n] = l cArr[n] = c vArr[n] = v n++ } return { ts: tsArr.slice(0, n), o: oArr.slice(0, n), h: hArr.slice(0, n), l: lArr.slice(0, n), c: cArr.slice(0, n), v: vArr.slice(0, n), len: n } } function loadRawCSV(source) { const loadText = source instanceof File ? () => source.text() : () => fetch(source).then(r => { if (!r.ok) throw new Error('CSV fetch failed: ' + r.status) return r.text() }) return loadText().then(text => { const data = parseCSV(text) if (!data || data.len === 0) throw new Error('No data parsed from CSV') return data }) } function aggregateBars(data, startIndex, endIndex, spanMinutes) { if (spanMinutes === 1) { const result = [] for (let i = startIndex; i < endIndex; i++) { result.push({ timestamp: data.ts[i], open: data.o[i], high: data.h[i], low: data.l[i], close: data.c[i], volume: data.v[i] }) } return result } const result = [] const spanMs = spanMinutes * MINUTE let groupStart = startIndex while (groupStart < endIndex) { let groupEnd = groupStart + 1 const groupStartTs = Math.floor(data.ts[groupStart] / spanMs) * spanMs while (groupEnd < endIndex && Math.floor(data.ts[groupEnd] / spanMs) * spanMs === groupStartTs) { groupEnd++ } let o = data.o[groupStart] let h = data.h[groupStart] let l = data.l[groupStart] let c = data.c[groupEnd - 1] let v = 0 for (let i = groupStart; i < groupEnd; i++) { h = Math.max(h, data.h[i]) l = Math.min(l, data.l[i]) v += data.v[i] } result.push({ timestamp: groupStartTs, open: o, high: h, low: l, close: c, volume: v }) groupStart = groupEnd } return result } function findIndex(data, targetTs) { let lo = 0 let hi = data.len - 1 while (lo <= hi) { const mid = (lo + hi) >> 1 if (data.ts[mid] < targetTs) lo = mid + 1 else hi = mid - 1 } return lo } function getSpanMinutes(period) { if (period.type === 'minute') return period.span if (period.type === 'hour') return period.span * 60 if (period.type === 'day') return period.span * 1440 return 1 } function loadDataLoader() { return { getBars: ({ type, timestamp, period, callback }) => { if (!rawLoaded) { callback([], { forward: false, backward: false }) return } const span = getSpanMinutes(period) const duration = span * MINUTE const bars = [] let forwardMore = false let backwardMore = false if (type === 'init') { const latestTs = rawBars.ts[rawBars.len - 1] const startTs = latestTs - LOAD_BAR_COUNT * duration const startIdx = findIndex(rawBars, startTs) const endIdx = findIndex(rawBars, latestTs + 1) bars.push(...aggregateBars(rawBars, startIdx, Math.min(endIdx, rawBars.len), span)) forwardMore = startIdx > 0 } else if (type === 'forward') { const startIdx = findIndex(rawBars, timestamp) const endIdx = Math.max(0, startIdx - LOAD_BAR_COUNT) bars.push(...aggregateBars(rawBars, endIdx, startIdx, span)) forwardMore = endIdx > 0 } else if (type === 'backward') { const startIdx = findIndex(rawBars, timestamp) const endIdx = Math.min(rawBars.len, startIdx + LOAD_BAR_COUNT) bars.push(...aggregateBars(rawBars, startIdx, endIdx, span)) forwardMore = false backwardMore = endIdx < rawBars.len } callback(bars, { forward: forwardMore, backward: backwardMore }) updateStatus() }, subscribeBar: () => {}, unsubscribeBar: () => {} } } // ---- UI helpers ---- function setActivePeriodButton() { document.querySelectorAll('#period-switcher button').forEach((btn) => { btn.classList.toggle('active', btn.dataset.period === activePeriodKey) }) } function setActiveIndicatorButtons() { document.querySelectorAll('#indicator-switcher button').forEach((btn) => { btn.classList.toggle('active', activeIndicators.has(btn.dataset.indicator)) }) } function updateStatus() { const el = document.getElementById('status') if (!el || chart === null) return const bars = chart.getDataList() if (bars.length > 0) { const from = new Date(bars[0].timestamp).toLocaleDateString() const to = new Date(bars[bars.length - 1].timestamp).toLocaleDateString() el.textContent = `${from} → ${to} | ${bars.length} bars` } } function formatDate(ts) { const d = new Date(ts) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } // ---- Chart actions ---- function switchPeriod(periodKey) { const config = PERIODS.find(({ key }) => key === periodKey) if (chart === null || config === undefined || activePeriodKey === periodKey) return const targetSpan = getSpanMinutes(config.period) if (targetSpan < dataIntervalMin) { alert(`数据原始间隔为 ${dataIntervalMin}m,无法切换到 ${targetSpan}m(需要更高级别的源数据)`) return } activePeriodKey = periodKey setActivePeriodButton() chart.setStyles({ candle: { type: config.candleType } }) chart.setPeriod(config.period) } function toggleIndicator(key) { const spec = INDICATORS.find((i) => i.key === key) if (!spec || chart === null) return if (activeIndicators.has(key)) { activeIndicators.delete(key) chart.removeIndicator({ name: spec.name }) } else { activeIndicators.add(key) const opts = spec.stacked ? { name: spec.name, paneId: 'candle_pane' } : { name: spec.name, paneId: 'new' } chart.createIndicator(opts, spec.stacked) } setActiveIndicatorButtons() } function setTheme(dark) { isDark = dark document.body.classList.toggle('dark', dark) const btn = document.getElementById('btn-theme') btn.textContent = dark ? '☀' : '☾' btn.classList.toggle('active-icon', dark) chart.setStyles(dark ? 'dark' : 'light') } function doScreenshot() { if (chart === null) return const url = chart.getConvertPictureUrl(false, 'png', isDark ? '#0d1117' : '#ffffff') const a = document.createElement('a') a.href = url a.download = `kline-${activePeriodKey}-${Date.now()}.png` document.body.appendChild(a) a.click() document.body.removeChild(a) } function setBarSpace(value) { if (chart === null) return chart.setBarSpace(value) updateStatus() } function doZoomIn() { if (chart === null) return const current = chart.getBarSpace().bar setBarSpace(Math.min(current + BAR_SPACE_STEP, BAR_SPACE_MAX)) } function doZoomOut() { if (chart === null) return const current = chart.getBarSpace().bar setBarSpace(Math.max(current - BAR_SPACE_STEP, BAR_SPACE_MIN)) } function doZoomReset() { if (chart === null) return setBarSpace(Math.round((BAR_SPACE_MIN + BAR_SPACE_MAX) / 2)) chart.scrollToRealTime() } // ---- Crosshair info ---- function formatPrice(v) { return v != null ? v.toFixed(5) : '-' } function formatVol(v) { if (v == null) return '-' if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B' if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M' if (v >= 1e3) return (v / 1e3).toFixed(2) + 'K' return String(v) } function onCrosshairHandler(crosshair) { const el = document.getElementById('crosshair-info') closeCrosshair() closeCrosshair = () => { el.classList.add('hidden') } if (!crosshair || !crosshair.kLineData) { el.classList.add('hidden') return } el.classList.remove('hidden') const k = crosshair.kLineData const d = new Date(k.timestamp) const timeStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` const change = k.close - k.open const changePct = k.open > 0 ? (change / k.open * 100) : 0 const color = change >= 0 ? (isDark ? '#ff7b72' : '#cf222e') : (isDark ? '#7ee787' : '#1a7f37') el.innerHTML = `