From 503f1e819f42cd119320c14ad883b5259d5cb760 Mon Sep 17 00:00:00 2001 From: jiangzhewen Date: Tue, 12 May 2026 15:19:15 +0800 Subject: [PATCH] Add chart technical indicators --- src/components/trade/chart/ChartBar.svelte | 44 +++++- src/lib/chart.js | 139 +++++++++++++++- src/lib/indicators.js | 176 +++++++++++++++++++++ src/lib/stores.js | 7 + 4 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 src/lib/indicators.js diff --git a/src/components/trade/chart/ChartBar.svelte b/src/components/trade/chart/ChartBar.svelte index 25c82ca..c4d86f6 100644 --- a/src/components/trade/chart/ChartBar.svelte +++ b/src/components/trade/chart/ChartBar.svelte @@ -1,9 +1,17 @@ @@ -54,5 +83,14 @@ {await setResolution(3600)}}>1h {await setResolution(14400)}}>4h {await setResolution(86400)}}>1D +
+
+ {#each indicators as indicator} + {setChartIndicator(indicator.key, !$chartIndicators[indicator.key])}} + >{indicator.label} + {/each} +
{#if $chartLoading}
{@html LOADING_ICON}
{/if} - \ No newline at end of file + diff --git a/src/lib/chart.js b/src/lib/chart.js index 29e5992..a8511b5 100644 --- a/src/lib/chart.js +++ b/src/lib/chart.js @@ -3,8 +3,9 @@ import { createChart, ColorType, LineStyle } from 'lightweight-charts' import { CURRENCY_DECIMALS } from './config' import { formatUnits, formatOrder, formatPosition, formatForDisplay, formatPriceForDisplay } from './formatters' -import { selectedMarket, orders, positions, chartResolution, chartLoading, showOrdersOnChart, showPositionsOnChart, hoveredOHLC } from './stores' +import { selectedMarket, orders, positions, chartResolution, chartLoading, showOrdersOnChart, showPositionsOnChart, hoveredOHLC, chartIndicators } from './stores' import { saveUserSetting, getPrecision } from './utils' +import { calculateSMA, calculateEMA, calculateRSI, calculateMACD } from './indicators' import { getMarketCandles } from '@api/prices' @@ -16,6 +17,8 @@ let earliestCandleDate; let chart; let candlestickSeries; +let indicatorSeries = []; +let chartIndicatorsUnsubscribe; // how much history to load for each resolution (in ms) const lookbacks = { @@ -50,6 +53,7 @@ export function initChart(cb) { const chartElem = document.getElementById('chart'); chart = createChart(chartElem); + indicatorSeries = []; new ResizeObserver((entries) => { if (entries.length === 0 || entries[0].target !== chartElem) return; @@ -144,13 +148,12 @@ export function initChart(cb) { }); chart.subscribeCrosshairMove(param => { - if (!param?.seriesPrices || param?.seriesPrices.size == 0) { - hoveredOHLC.set(); - } else { - param?.seriesPrices.forEach((value) => { - hoveredOHLC.set(value); - }); - } + hoveredOHLC.set(param?.seriesPrices?.get(candlestickSeries)); + }); + + if (chartIndicatorsUnsubscribe) chartIndicatorsUnsubscribe(); + chartIndicatorsUnsubscribe = chartIndicators.subscribe(() => { + syncIndicators(); }); applyWatermark(); @@ -249,6 +252,7 @@ export async function loadCandles(_end) { // set data candlestickSeries.setData(candles || []); + syncIndicators(); // Set chart precision if (candles.length) { @@ -318,6 +322,125 @@ export function onNewPrice(price) { candlestickSeries.update(lastCandle); } + if (hasEnabledIndicators()) syncIndicators(); + +} + +// Technical indicators + +export function setChartIndicator(indicator, enabled) { + const indicators = Object.assign({}, get(chartIndicators), { + [indicator]: enabled + }); + chartIndicators.set(indicators); + saveUserSetting('chartIndicators', indicators); +} + +function hasEnabledIndicators() { + return Object.values(get(chartIndicators) || {}).some(Boolean); +} + +function syncIndicators() { + if (!chart) return; + + clearIndicatorSeries(); + if (!candles.length || !hasEnabledIndicators()) return; + + const indicators = get(chartIndicators); + + if (indicators.ma20) { + addLineIndicator(calculateSMA(candles, 20), { + color: '#f5b942', + lineWidth: 2, + title: 'MA 20' + }); + } + + if (indicators.ma50) { + addLineIndicator(calculateSMA(candles, 50), { + color: '#4bb3fd', + lineWidth: 2, + title: 'MA 50' + }); + } + + if (indicators.ema20) { + addLineIndicator(calculateEMA(candles, 20), { + color: '#b388ff', + lineWidth: 2, + title: 'EMA 20' + }); + } + + if (indicators.rsi) { + addLineIndicator(calculateRSI(candles), { + color: '#5ce1e6', + lineWidth: 2, + priceScaleId: 'rsi', + title: 'RSI 14' + }); + chart.priceScale('rsi').applyOptions({ + visible: false, + scaleMargins: { + top: indicators.macd ? 0.72 : 0.76, + bottom: indicators.macd ? 0.16 : 0.04 + } + }); + } + + if (indicators.macd) { + const macd = calculateMACD(candles); + addHistogramIndicator(macd.histogram, { + priceScaleId: 'macd', + priceLineVisible: false, + lastValueVisible: false + }); + addLineIndicator(macd.macd, { + color: '#ff8a65', + lineWidth: 2, + priceScaleId: 'macd', + title: 'MACD' + }); + addLineIndicator(macd.signal, { + color: '#ffd54f', + lineWidth: 1, + priceScaleId: 'macd', + title: 'Signal' + }); + chart.priceScale('macd').applyOptions({ + visible: false, + scaleMargins: { + top: indicators.rsi ? 0.86 : 0.78, + bottom: 0.02 + } + }); + } +} + +function addLineIndicator(data, options) { + if (!data.length) return; + + const series = chart.addLineSeries(Object.assign({ + priceLineVisible: false, + lastValueVisible: false + }, options)); + series.setData(data); + indicatorSeries.push(series); +} + +function addHistogramIndicator(data, options) { + if (!data.length) return; + + const series = chart.addHistogramSeries(options); + series.setData(data); + indicatorSeries.push(series); +} + +function clearIndicatorSeries() { + for (const series of indicatorSeries) { + chart.removeSeries(series); + } + indicatorSeries = []; } // Order and position lines diff --git a/src/lib/indicators.js b/src/lib/indicators.js new file mode 100644 index 0000000..f7faa13 --- /dev/null +++ b/src/lib/indicators.js @@ -0,0 +1,176 @@ +function getClose(candle) { + return candle.close * 1; +} + +export function calculateSMA(candles, period) { + if (!candles || candles.length < period) return []; + + const values = []; + let sum = 0; + + for (let index = 0; index < candles.length; index++) { + sum += getClose(candles[index]); + if (index >= period) sum -= getClose(candles[index - period]); + + if (index >= period - 1) { + values.push({ + time: candles[index].time, + value: sum / period + }); + } + } + + return values; +} + +export function calculateEMA(candles, period) { + if (!candles || candles.length < period) return []; + + const values = []; + const multiplier = 2 / (period + 1); + let sum = 0; + + for (let index = 0; index < period; index++) { + sum += getClose(candles[index]); + } + + let previousEMA = sum / period; + values.push({ + time: candles[period - 1].time, + value: previousEMA + }); + + for (let index = period; index < candles.length; index++) { + previousEMA = (getClose(candles[index]) - previousEMA) * multiplier + previousEMA; + values.push({ + time: candles[index].time, + value: previousEMA + }); + } + + return values; +} + +export function calculateRSI(candles, period = 14) { + if (!candles || candles.length <= period) return []; + + const values = []; + let gains = 0; + let losses = 0; + + for (let index = 1; index <= period; index++) { + const change = getClose(candles[index]) - getClose(candles[index - 1]); + if (change >= 0) { + gains += change; + } else { + losses -= change; + } + } + + let averageGain = gains / period; + let averageLoss = losses / period; + + values.push({ + time: candles[period].time, + value: getRSIValue(averageGain, averageLoss) + }); + + for (let index = period + 1; index < candles.length; index++) { + const change = getClose(candles[index]) - getClose(candles[index - 1]); + const gain = change > 0 ? change : 0; + const loss = change < 0 ? -change : 0; + + averageGain = (averageGain * (period - 1) + gain) / period; + averageLoss = (averageLoss * (period - 1) + loss) / period; + + values.push({ + time: candles[index].time, + value: getRSIValue(averageGain, averageLoss) + }); + } + + return values; +} + +function getRSIValue(averageGain, averageLoss) { + if (!averageLoss) return averageGain ? 100 : 50; + + const relativeStrength = averageGain / averageLoss; + return 100 - 100 / (1 + relativeStrength); +} + +export function calculateMACD(candles, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) { + if (!candles || candles.length < slowPeriod + signalPeriod) { + return { + macd: [], + signal: [], + histogram: [] + }; + } + + const fastEMA = calculateEMA(candles, fastPeriod); + const slowEMA = calculateEMA(candles, slowPeriod); + const macd = []; + const fastOffset = slowPeriod - fastPeriod; + + for (let index = 0; index < slowEMA.length; index++) { + const fastPoint = fastEMA[index + fastOffset]; + const slowPoint = slowEMA[index]; + if (!fastPoint || !slowPoint) continue; + + macd.push({ + time: slowPoint.time, + value: fastPoint.value - slowPoint.value + }); + } + + const signal = calculateEMAFromValues(macd, signalPeriod); + const signalOffset = signalPeriod - 1; + const histogram = []; + + for (let index = signalOffset; index < macd.length; index++) { + const signalPoint = signal[index - signalOffset]; + if (!signalPoint) continue; + + const value = macd[index].value - signalPoint.value; + histogram.push({ + time: macd[index].time, + value, + color: value >= 0 ? 'rgba(64, 214, 67, 0.45)' : 'rgba(255, 83, 36, 0.45)' + }); + } + + return { + macd, + signal, + histogram + }; +} + +function calculateEMAFromValues(points, period) { + if (!points || points.length < period) return []; + + const values = []; + const multiplier = 2 / (period + 1); + let sum = 0; + + for (let index = 0; index < period; index++) { + sum += points[index].value; + } + + let previousEMA = sum / period; + values.push({ + time: points[period - 1].time, + value: previousEMA + }); + + for (let index = period; index < points.length; index++) { + previousEMA = (points[index].value - previousEMA) * multiplier + previousEMA; + values.push({ + time: points[index].time, + value: previousEMA + }); + } + + return values; +} diff --git a/src/lib/stores.js b/src/lib/stores.js index d5761f7..ced7bfa 100644 --- a/src/lib/stores.js +++ b/src/lib/stores.js @@ -37,6 +37,13 @@ export const chartHeight = writable(getUserSetting('chartHeight') || 320); export const chartResolution = writable(getUserSetting('chartResolution') || 900) export const chartLoading = writable(false); export const hoveredOHLC = writable(); +export const chartIndicators = writable(Object.assign({ + ma20: false, + ma50: false, + ema20: false, + rsi: false, + macd: false +}, getUserSetting('chartIndicators') || {})); export const accountHeight = writable(getUserSetting('accountHeight') || 250); // Rewards