Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/lib/chart.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
125 changes: 125 additions & 0 deletions src/lib/indicators.js
Original file line number Diff line number Diff line change
@@ -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
}