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 = `
${timeStr}
O ${formatPrice(k.open)}   H ${formatPrice(k.high)}
L ${formatPrice(k.low)}   C ${formatPrice(k.close)}
Δ ${change >= 0 ? '+' : ''}${change.toFixed(5)} (${changePct >= 0 ? '+' : ''}${changePct.toFixed(3)}%)
Vol ${formatVol(k.volume)}
` } // ---- Bar click detail ---- function onBarClickHandler(crosshair) { const popup = document.getElementById('bar-detail') closePopup() if (!crosshair || !crosshair.kLineData || crosshair.x == null) { popup.classList.add('hidden') return } const k = crosshair.kLineData const bull = k.close >= k.open const cls = bull ? 'bull' : 'bear' popup.classList.remove('hidden') popup.style.left = Math.min(crosshair.x + 20, window.innerWidth - 200) + 'px' popup.style.top = Math.min(crosshair.y + 20, window.innerHeight - 200) + 'px' popup.innerHTML = `
${formatDate(k.timestamp)}
Open${formatPrice(k.open)}
High${formatPrice(k.high)}
Low${formatPrice(k.low)}
Close${formatPrice(k.close)}
Volume${formatVol(k.volume)}
` closePopup = () => { popup.classList.add('hidden') } } // ---- Right-click context menu ---- function showContextMenu(x, y) { const menu = document.getElementById('context-menu') const items = INDICATORS.map((ind) => { const on = activeIndicators.has(ind.key) return `
${on ? '✓' : ''}${ind.name}
` }).join('') menu.innerHTML = ` ${items}
切换主题 D
截图 S
` menu.style.left = Math.min(x, window.innerWidth - 200) + 'px' menu.style.top = Math.min(y, window.innerHeight - 260) + 'px' menu.classList.remove('hidden') closePopup() closePopup = () => { menu.classList.add('hidden') menu.querySelectorAll('.item').forEach((el) => el.removeEventListener('click', handleMenuItem)) } menu.querySelectorAll('.item').forEach((el) => el.addEventListener('click', handleMenuItem)) } function handleMenuItem(e) { const el = e.currentTarget const ind = el.dataset.indicator const action = el.dataset.action if (ind) toggleIndicator(ind) if (action === 'theme') setTheme(!isDark) if (action === 'screenshot') doScreenshot() closePopup() } // ---- Keyboard ---- function onKeydown(e) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (chart === null) return switch (e.key) { case 'ArrowLeft': chart.scrollByDistance(-3 * chart.getBarSpace().bar) e.preventDefault() break case 'ArrowRight': chart.scrollByDistance(3 * chart.getBarSpace().bar) e.preventDefault() break case '+': case '=': doZoomIn() e.preventDefault() break case '-': doZoomOut() e.preventDefault() break case 'Escape': closePopup() closeCrosshair() break case 'd': setTheme(!isDark) break case 's': if (!e.ctrlKey && !e.metaKey) doScreenshot() break } } // ---- Toolbar binding ---- function bindToolbar() { const picker = document.getElementById('file-picker') const nameEl = document.getElementById('file-name') const btnFile = document.getElementById('btn-file') const input = document.createElement('input') input.type = 'file' input.accept = '.csv,.txt,.tsv' input.style.display = 'none' document.body.appendChild(input) function setFile(file) { fileName = file.name.replace(/\.[^.]+$/, '').replace(/[_\s]+/g, ' ').slice(0, 30) if (nameEl) nameEl.textContent = fileName mountChart(file) } btnFile.addEventListener('click', () => input.click()) input.addEventListener('change', () => { if (input.files[0]) setFile(input.files[0]) }) picker.addEventListener('dragover', (e) => { e.preventDefault(); picker.classList.add('over') }) picker.addEventListener('dragleave', () => picker.classList.remove('over')) picker.addEventListener('drop', (e) => { e.preventDefault() picker.classList.remove('over') const file = e.dataTransfer.files[0] if (file && (file.name.endsWith('.csv') || file.name.endsWith('.txt') || file.name.endsWith('.tsv'))) { setFile(file) } }) document.querySelectorAll('#period-switcher button').forEach((btn) => { btn.addEventListener('click', () => switchPeriod(btn.dataset.period)) }) const indicatorContainer = document.getElementById('indicator-switcher') INDICATORS.forEach(ind => { const btn = document.createElement('button') btn.type = 'button' btn.dataset.indicator = ind.key btn.textContent = ind.key btn.className = 'toolbar-btn' btn.addEventListener('click', () => toggleIndicator(ind.key)) indicatorContainer.appendChild(btn) }) document.getElementById('zoom-in').addEventListener('click', doZoomIn) document.getElementById('zoom-out').addEventListener('click', doZoomOut) document.getElementById('zoom-reset').addEventListener('click', doZoomReset) document.getElementById('btn-theme').addEventListener('click', () => setTheme(!isDark)) document.getElementById('btn-screenshot').addEventListener('click', doScreenshot) document.addEventListener('keydown', onKeydown) document.addEventListener('click', () => { closePopup(); closeCrosshair() }) document.addEventListener('contextmenu', (e) => { e.preventDefault() showContextMenu(e.clientX, e.clientY) }) setActivePeriodButton() setActiveIndicatorButtons() } function detectDefaultPeriod(data) { if (data.len < 2) { dataIntervalMin = 1 return '1m' } let minGap = Infinity for (let i = 1; i < Math.min(data.len, 100); i++) { const gap = data.ts[i] - data.ts[i - 1] if (gap > 0 && gap < minGap) minGap = gap } const gapMin = Math.round(minGap / MINUTE) dataIntervalMin = gapMin const candidates = PERIODS.map(p => ({ key: p.key, span: getSpanMinutes(p.period) })) let best = candidates[0] for (const c of candidates) { if (c.span <= gapMin + 0.01) best = c } return best.key } function mountChart(source) { const chartDom = document.getElementById('chart') if (chartDom === null) return const loadingEl = document.getElementById('loading') if (loadingEl) loadingEl.style.display = 'block' loadRawCSV(source).then(data => { rawBars = data rawLoaded = true if (loadingEl) loadingEl.style.display = 'none' console.log(`Loaded ${data.len} bars, ${new Date(data.ts[0])} → ${new Date(data.ts[data.len - 1])}`) const defaultPeriod = detectDefaultPeriod(data) if (defaultPeriod !== activePeriodKey) { activePeriodKey = defaultPeriod setActivePeriodButton() } if (chart) dispose(chartDom) chart = init(chartDom, { styles: { candle: { type: getActivePeriodConfig().candleType }, indicator: { lastValueMark: { show: true } } }, layout: { barSpaceLimit: { min: BAR_SPACE_MIN, max: BAR_SPACE_MAX }, basicParams: {} } }) chart.setSymbol({ ticker: fileName || 'CSV', pricePrecision: 5, volumePrecision: 0 }) chart.setPeriod(getActivePeriodConfig().period) chart.setDataLoader(loadDataLoader()) chart.subscribeAction('onCrosshairChange', onCrosshairHandler) chart.subscribeAction('onCandleBarClick', onBarClickHandler) setTimeout(updateStatus, 200) }).catch(err => { if (loadingEl) loadingEl.textContent = 'Error: ' + err.message console.error(err) }) } window.addEventListener('beforeunload', () => { const chartDom = document.getElementById('chart') if (chartDom !== null) dispose(chartDom) }) bindToolbar() mountChart('/data/eurgbp_m1.csv')