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
+
+
{#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