From 7638077161da5435d57eb33d059776191288031e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 19:05:55 -0300 Subject: [PATCH] feat(chart): add MA/EMA/Bollinger/RSI/MACD indicators with localStorage-persisted toggles (#16) --- src/lib/chart.js | 140 ++++++++++++++++++++++++++++++++++++++++++ src/lib/indicators.js | 125 +++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/lib/indicators.js diff --git a/src/lib/chart.js b/src/lib/chart.js index 29e5992..08e1a7c 100644 --- a/src/lib/chart.js +++ b/src/lib/chart.js @@ -1,7 +1,147 @@ import { get } from 'svelte/store' +import { + sma, ema, rsi, macd, bollinger, INDICATOR_META, DEFAULT_PREFS +} from "./indicators.js" import { createChart, ColorType, LineStyle } from 'lightweight-charts' +// ---- technical indicators (issue #16) ---------------------------------- +const INDICATOR_PREF_KEY = "cap.chart.indicators.v1" +let indicatorPrefs = readIndicatorPrefs() +const indicatorSeries = {} // key -> series (BB stored as [upper, mid, lower]) + +function readIndicatorPrefs () { + try { + const raw = localStorage.getItem(INDICATOR_PREF_KEY) + if (raw) return Object.assign({}, DEFAULT_PREFS, JSON.parse(raw)) + } catch (e) { /* corrupted value -> fall back to defaults */ } + return Object.assign({}, DEFAULT_PREFS) +} + +function writeIndicatorPrefs () { + try { localStorage.setItem(INDICATOR_PREF_KEY, JSON.stringify(indicatorPrefs)) } catch (e) {} +} + +const lineOpts = (color, width = 1) => ({ + color, + lineWidth: width, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false +}) + +function addOverlayLine (key, data, color, width) { + const s = chart.addLineSeries(Object.assign(lineOpts(color, width), { + priceScaleId: key // private invisible scale: overlays never squash candle autoscale + })) + chart.priceScale(key).applyOptions({ visible: false, autoScale: true }) + s.setData(data) + indicatorSeries[key] = s + return s +} + +function applyIndicators () { + clearIndicators() + if (!candles || !candles.length) return + if (indicatorPrefs.ma20) addOverlayLine("ma20", sma(candles, 20), INDICATOR_META.ma20.color, 2) + if (indicatorPrefs.ma50) addOverlayLine("ma50", sma(candles, 50), INDICATOR_META.ma50.color, 2) + if (indicatorPrefs.ema12) addOverlayLine("ema12", ema(candles, 12), INDICATOR_META.ema12.color, 2) + if (indicatorPrefs.bb) { + const bb = bollinger(candles, 20, 2) + indicatorSeries.bb = [ + addOverlayLine("bbU", bb.upper, INDICATOR_META.bb.color), + addOverlayLine("bbM", bb.mid, INDICATOR_META.bb.color), + addOverlayLine("bbL", bb.lower, INDICATOR_META.bb.color) + ] + } + if (indicatorPrefs.rsi) { + const s = addOverlayLine("rsi", rsi(candles, 14), INDICATOR_META.rsi.color, 2) + s.createPriceLine({ price: 70, color: "#94a3b8", lineWidth: 1, lineStyle: 2, axisLabelVisible: false, title: "" }) + s.createPriceLine({ price: 30, color: "#94a3b8", lineWidth: 1, lineStyle: 2, axisLabelVisible: false, title: "" }) + } + if (indicatorPrefs.macd) { + const data = macd(candles) + const m = addOverlayLine("macd", data.map(d => ({ time: d.time, value: d.macd })), INDICATOR_META.macd.color, 2) + const sig = addOverlayLine("macdSig", data.filter(d => d.signal != null).map(d => ({ time: d.time, value: d.signal })), "#f97316") + const hist = chart.addHistogramSeries({ + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + base: 0 + }) + hist.setData(data.filter(d => d.histogram != null).map(d => ({ + time: d.time, value: d.histogram, color: d.histogram >= 0 ? "#16a34a" : "#dc2626" + }))) + m.createPriceLine({ price: 0, color: "#94a3b8", lineWidth: 1, lineStyle: 2, axisLabelVisible: false, title: "" }) + indicatorSeries.macd = m + indicatorSeries.macdSig = sig + indicatorSeries.macdHist = hist + } +} + +function clearIndicators () { + for (const key of Object.keys(indicatorSeries)) { + const s = indicatorSeries[key] + if (Array.isArray(s)) s.forEach(x => chart.removeSeries(x)) + else chart.removeSeries(s) + delete indicatorSeries[key] + } +} + +// Called on every live candle tick: recompute and push only the last point. +function updateIndicators () { + if (!candles || !candles.length) return + const last = a => a[a.length - 1] + if (indicatorSeries.ma20) { const p = sma(candles, 20); if (p.length) indicatorSeries.ma20.update(last(p)) } + if (indicatorSeries.ma50) { const p = sma(candles, 50); if (p.length) indicatorSeries.ma50.update(last(p)) } + if (indicatorSeries.ema12) { const p = ema(candles, 12); if (p.length) indicatorSeries.ema12.update(last(p)) } + if (indicatorSeries.bb) { + const bb = bollinger(candles, 20, 2) + if (bb.mid.length) { + indicatorSeries.bb[0].update(last(bb.upper)) + indicatorSeries.bb[1].update(last(bb.mid)) + indicatorSeries.bb[2].update(last(bb.lower)) + } + } + if (indicatorSeries.rsi) { const p = rsi(candles, 14); if (p.length) indicatorSeries.rsi.update(last(p)) } + if (indicatorSeries.macd) { + const data = macd(candles) + if (data.length) { + const d = last(data) + indicatorSeries.macd.update({ time: d.time, value: d.macd }) + if (d.signal != null) indicatorSeries.macdSig.update({ time: d.time, value: d.signal }) + if (d.histogram != null) indicatorSeries.macdHist.update({ time: d.time, value: d.histogram, color: d.histogram >= 0 ? "#16a34a" : "#dc2626" }) + } + } +} + +function buildIndicatorToolbar (container) { + const bar = document.createElement("div") + bar.style.cssText = "display:flex;gap:4px;padding:4px 8px;font-size:11px;user-select:none" + for (const key of Object.keys(INDICATOR_META)) { + const btn = document.createElement("button") + btn.textContent = INDICATOR_META[key].label + btn.style.cssText = "cursor:pointer;border:1px solid #2a3140;background:transparent;color:#9aa4b2;border-radius:4px;padding:2px 8px;font-size:11px" + const paint = () => { + btn.style.borderColor = indicatorPrefs[key] ? INDICATOR_META[key].color : "#2a3140" + btn.style.color = indicatorPrefs[key] ? INDICATOR_META[key].color : "#9aa4b2" + } + btn.addEventListener("click", () => { + indicatorPrefs[key] = !indicatorPrefs[key] + writeIndicatorPrefs() + paint() + applyIndicators() + }) + paint() + bar.appendChild(btn) + } + container.insertBefore(bar, container.firstChild) +} +// ------------------------------------------------------------------------ + buildIndicatorToolbar(container) + + applyIndicators() import { CURRENCY_DECIMALS } from './config' + updateIndicators() import { formatUnits, formatOrder, formatPosition, formatForDisplay, formatPriceForDisplay } from './formatters' import { selectedMarket, orders, positions, chartResolution, chartLoading, showOrdersOnChart, showPositionsOnChart, hoveredOHLC } from './stores' import { saveUserSetting, getPrecision } from './utils' diff --git a/src/lib/indicators.js b/src/lib/indicators.js new file mode 100644 index 0000000..c18155c --- /dev/null +++ b/src/lib/indicators.js @@ -0,0 +1,125 @@ +// src/lib/indicators.js +// Simple technical indicators for the lightweight-charts candle chart (issue #16). +// All functions are pure: they take the candle buffer held by chart.js +// ([{ time, open, high, low, close, volume }], oldest -> newest) and return +// point arrays ready for series.setData()/series.update(). + +export const INDICATOR_META = { + ma20: { label: "MA 20", color: "#f5b942" }, + ma50: { label: "MA 50", color: "#4f9cf9" }, + ema12: { label: "EMA 12", color: "#2dd4bf" }, + bb: { label: "BB 20", color: "#94a3b8" }, + rsi: { label: "RSI 14", color: "#f97316" }, + macd: { label: "MACD", color: "#16a34a" } +} + +export const DEFAULT_PREFS = { + ma20: true, ma50: false, ema12: false, bb: false, rsi: false, macd: false +} + +export function sma (candles, period) { + const out = [] + let sum = 0 + for (let i = 0; i < candles.length; i++) { + sum += candles[i].close + if (i >= period) sum -= candles[i - period].close + if (i >= period - 1) out.push({ time: candles[i].time, value: sum / period }) + } + return out +} + +export function ema (candles, period) { + const vals = emaValues(candles.map(c => c.close), period) + const out = [] + for (let i = 0; i < candles.length; i++) { + if (vals[i] != null) out.push({ time: candles[i].time, value: vals[i] }) + } + return out +} + +// Wilder-smoothed RSI. +export function rsi (candles, period = 14) { + const out = [] + if (candles.length <= period) return out + let gain = 0 + let loss = 0 + for (let i = 1; i <= period; i++) { + const d = candles[i].close - candles[i - 1].close + if (d >= 0) gain += d + else loss -= d + } + let avgGain = gain / period + let avgLoss = loss / period + const value = () => avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss) + out.push({ time: candles[period].time, value: value() }) + for (let i = period + 1; i < candles.length; i++) { + const d = candles[i].close - candles[i - 1].close + avgGain = (avgGain * (period - 1) + Math.max(d, 0)) / period + avgLoss = (avgLoss * (period - 1) + Math.max(-d, 0)) / period + out.push({ time: candles[i].time, value: value() }) + } + return out +} + +// Returns { time, macd, signal, histogram } rows; signal/histogram are null +// until enough data exists. +export function macd (candles, fast = 12, slow = 26, signal = 9) { + const closes = candles.map(c => c.close) + const f = emaValues(closes, fast) + const s = emaValues(closes, slow) + const line = closes.map((_, i) => (f[i] != null && s[i] != null) ? f[i] - s[i] : null) + const first = line.findIndex(v => v != null) + const sig = new Array(closes.length).fill(null) + if (first !== -1) { + emaValues(line.slice(first), signal).forEach((v, i) => { sig[first + i] = v }) + } + const out = [] + for (let i = 0; i < candles.length; i++) { + if (line[i] == null) continue + out.push({ + time: candles[i].time, + macd: line[i], + signal: sig[i], + histogram: sig[i] == null ? null : line[i] - sig[i] + }) + } + return out +} + +export function bollinger (candles, period = 20, mult = 2) { + const mid = [] + const upper = [] + const lower = [] + for (let i = period - 1; i < candles.length; i++) { + let sum = 0 + for (let j = i - period + 1; j <= i; j++) sum += candles[j].close + const m = sum / period + let variance = 0 + for (let j = i - period + 1; j <= i; j++) { + const d = candles[j].close - m + variance += d * d + } + const sd = Math.sqrt(variance / period) + const time = candles[i].time + mid.push({ time, value: m }) + upper.push({ time, value: m + mult * sd }) + lower.push({ time, value: m - mult * sd }) + } + return { mid, upper, lower } +} + +// Standard EMA seeded with an SMA; entries before the seed are null. +function emaValues (values, period) { + const out = new Array(values.length).fill(null) + if (values.length < period) return out + const k = 2 / (period + 1) + let prev = 0 + for (let i = 0; i < period; i++) prev += values[i] + prev /= period + out[period - 1] = prev + for (let i = period; i < values.length; i++) { + prev = values[i] * k + prev * (1 - k) + out[i] = prev + } + return out +}