diff --git a/src/pages/trade/api/candles.tsx b/src/pages/trade/api/candles.tsx index 6f9de5db..6ee76ade 100644 --- a/src/pages/trade/api/candles.tsx +++ b/src/pages/trade/api/candles.tsx @@ -2,15 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { useRefetchOnNewBlock } from '@/shared/api/compact-block.ts'; import { CandleApiResponse } from '@/shared/api/server/candles/types.ts'; import { usePathSymbols } from '@/pages/trade/model/use-path.ts'; -import { OhlcData } from 'lightweight-charts'; import { DurationWindow } from '@/shared/utils/duration.ts'; +import { CandleWithVolume } from '@/shared/api/server/candles/utils.ts'; export const useCandles = (durationWindow: DurationWindow) => { const { baseSymbol, quoteSymbol } = usePathSymbols(); const query = useQuery({ queryKey: ['candles', baseSymbol, quoteSymbol, durationWindow], - queryFn: async (): Promise => { + queryFn: async (): Promise => { const paramsObj = { baseAsset: baseSymbol, quoteAsset: quoteSymbol, diff --git a/src/pages/trade/ui/chart.tsx b/src/pages/trade/ui/chart.tsx index b409b7fe..7753cec9 100644 --- a/src/pages/trade/ui/chart.tsx +++ b/src/pages/trade/ui/chart.tsx @@ -1,11 +1,12 @@ import cn from 'clsx'; import { useEffect, useRef, useState } from 'react'; -import { createChart, IChartApi, OhlcData } from 'lightweight-charts'; +import { createChart, IChartApi } from 'lightweight-charts'; import { theme } from '@penumbra-zone/ui/theme'; import { Text } from '@penumbra-zone/ui/Text'; import { useCandles } from '../api/candles'; import { observer } from 'mobx-react-lite'; import { DurationWindow, durationWindows } from '@/shared/utils/duration.ts'; +import { CandleWithVolume } from '@/shared/api/server/candles/utils'; const ChartLoadingState = () => { return ( @@ -119,10 +120,11 @@ const ChartLoadingState = () => { ); }; -const ChartData = observer(({ candles }: { candles: OhlcData[] }) => { +const ChartData = observer(({ candles }: { candles: CandleWithVolume[] }) => { const chartElRef = useRef(null); const chartRef = useRef(); const seriesRef = useRef>(); + const volumeSeriesRef = useRef>(); // Initialize the chart once when the component mounts useEffect(() => { @@ -145,7 +147,7 @@ const ChartData = observer(({ candles }: { candles: OhlcData[] }) => { }, }); - // Initialize the series + // Initialize the candlestick series seriesRef.current = chartRef.current.addCandlestickSeries({ upColor: theme.color.success.light, downColor: theme.color.destructive.light, @@ -154,6 +156,31 @@ const ChartData = observer(({ candles }: { candles: OhlcData[] }) => { wickDownColor: theme.color.destructive.light, }); + // Set the price scale margins for the candlestick series + seriesRef.current.priceScale().applyOptions({ + scaleMargins: { + top: 0.1, + bottom: 0.4, + }, + }); + + // Initialize the volume series + volumeSeriesRef.current = chartRef.current.addHistogramSeries({ + color: theme.color.success.light, + priceFormat: { + type: 'volume', + }, + priceScaleId: '', // Set as overlay + }); + + // Set the price scale margins for the volume series + volumeSeriesRef.current.priceScale().applyOptions({ + scaleMargins: { + top: 0.7, + bottom: 0, + }, + }); + chartRef.current.timeScale().fitContent(); } @@ -167,8 +194,21 @@ const ChartData = observer(({ candles }: { candles: OhlcData[] }) => { // Update chart when candles change useEffect(() => { - if (seriesRef.current) { - seriesRef.current.setData(candles); + if (seriesRef.current && volumeSeriesRef.current) { + // Set OHLC data + seriesRef.current.setData(candles.map(c => c.ohlc)); + + // Set volume data with colors based on price movement + volumeSeriesRef.current.setData( + candles.map(candle => ({ + time: candle.ohlc.time, + value: candle.volume, + color: + candle.ohlc.close >= candle.ohlc.open + ? theme.color.success.light + : theme.color.destructive.light, + })), + ); chartRef.current?.timeScale().fitContent(); } }, [candles]); diff --git a/src/shared/api/server/candles/types.ts b/src/shared/api/server/candles/types.ts index 13ee00e3..c95ac9ee 100644 --- a/src/shared/api/server/candles/types.ts +++ b/src/shared/api/server/candles/types.ts @@ -1,6 +1,6 @@ -import { OhlcData, UTCTimestamp } from 'lightweight-charts'; +import { CandleWithVolume } from './utils'; -export type CandleApiResponse = OhlcData[] | { error: string }; +export type CandleApiResponse = CandleWithVolume[] | { error: string }; export interface DbCandle { close: number; diff --git a/src/shared/api/server/candles/utils.test.ts b/src/shared/api/server/candles/utils.test.ts index f68b598d..b0517a09 100644 --- a/src/shared/api/server/candles/utils.test.ts +++ b/src/shared/api/server/candles/utils.test.ts @@ -1,126 +1,486 @@ import { describe, it, expect } from 'vitest'; import { DurationWindow } from '../../../utils/duration.ts'; -import { OhlcData, UTCTimestamp } from 'lightweight-charts'; -import { insertEmptyCandles } from './utils.ts'; +import { UTCTimestamp } from 'lightweight-charts'; +import { insertEmptyCandles, CandleWithVolume } from './utils'; describe('insertEmptyCandles', () => { const window: DurationWindow = '1m'; it('should return empty array when input data is empty', () => { - const input: OhlcData[] = []; + const input: CandleWithVolume[] = []; const output = insertEmptyCandles(window, input); expect(output).toEqual([]); }); it('should return the same candle when there is only one candle', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(input); }); it('should return the same candles when there are no gaps', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, - { time: 1609459260 as UTCTimestamp, open: 102, high: 106, low: 101, close: 104 }, - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 106, + low: 101, + close: 104, + }, + volume: 200, + }, + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(input); }); it('should insert empty candles when there are gaps', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // Gap of 2 minutes - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, ]; - const expectedOutput: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, + const expectedOutput: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // Inserted empty candle at 1609459260 - { time: 1609459260 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(expectedOutput); }); it('should insert multiple empty candles when multiple gaps exist', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // Gap of 3 minutes - { time: 1609459380 as UTCTimestamp, open: 106, high: 110, low: 105, close: 108 }, + { + ohlc: { + time: 1609459380 as UTCTimestamp, + open: 106, + high: 110, + low: 105, + close: 108, + }, + volume: 200, + }, ]; - const expectedOutput: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, - { time: 1609459260 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, - { time: 1609459320 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, - { time: 1609459380 as UTCTimestamp, open: 106, high: 110, low: 105, close: 108 }, + const expectedOutput: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, + { + ohlc: { + time: 1609459380 as UTCTimestamp, + open: 106, + high: 110, + low: 105, + close: 108, + }, + volume: 200, + }, ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(expectedOutput); }); it('should not insert candles if nextTime is not less than candle.time', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // nextTime after addDurationWindow would be 1609459260 - { time: 1609459260 as UTCTimestamp, open: 102, high: 106, low: 101, close: 104 }, + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 106, + low: 101, + close: 104, + }, + volume: 200, + }, // No gap here - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(input); }); it('should handle multiple insertions and existing candles correctly', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, // 00:00 - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, // 00:02 - { time: 1609459440 as UTCTimestamp, open: 105, high: 108, low: 104, close: 107 }, // 00:04 + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // 00:00 + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, // 00:02 + { + ohlc: { + time: 1609459440 as UTCTimestamp, + open: 105, + high: 108, + low: 104, + close: 107, + }, + volume: 400, + }, // 00:04 ]; - const expectedOutput: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, // 00:00 - { time: 1609459260 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, // 00:01 - { time: 1609459320 as UTCTimestamp, open: 104, high: 107, low: 103, close: 105 }, // 00:02 - { time: 1609459380 as UTCTimestamp, open: 105, high: 105, low: 105, close: 105 }, // 00:03 - { time: 1609459440 as UTCTimestamp, open: 105, high: 108, low: 104, close: 107 }, // 00:04 + const expectedOutput: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // 00:00 + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, // 00:01 + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 104, + high: 107, + low: 103, + close: 105, + }, + volume: 300, + }, // 00:02 + { + ohlc: { + time: 1609459380 as UTCTimestamp, + open: 105, + high: 105, + low: 105, + close: 105, + }, + volume: 0, + }, // 00:03 + { + ohlc: { + time: 1609459440 as UTCTimestamp, + open: 105, + high: 108, + low: 104, + close: 107, + }, + volume: 400, + }, // 00:04 ]; const output = insertEmptyCandles(window, input); expect(output).toEqual(expectedOutput); }); it('should insert multiple empty candles across multiple gaps', () => { - const input: OhlcData[] = [ - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, // 00:00 - { time: 1609459380 as UTCTimestamp, open: 106, high: 110, low: 105, close: 108 }, // 00:03 - { time: 1609459560 as UTCTimestamp, open: 108, high: 112, low: 107, close: 110 }, // 00:06 - { time: 1609459740 as UTCTimestamp, open: 110, high: 115, low: 109, close: 113 }, // 00:09 + const input: CandleWithVolume[] = [ + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // 00:00 + { + ohlc: { + time: 1609459380 as UTCTimestamp, + open: 106, + high: 110, + low: 105, + close: 108, + }, + volume: 200, + }, // 00:03 + { + ohlc: { + time: 1609459560 as UTCTimestamp, + open: 108, + high: 112, + low: 107, + close: 110, + }, + volume: 300, + }, // 00:06 + { + ohlc: { + time: 1609459740 as UTCTimestamp, + open: 110, + high: 115, + low: 109, + close: 113, + }, + volume: 400, + }, // 00:09 ]; - const expectedOutput: OhlcData[] = [ + const expectedOutput: CandleWithVolume[] = [ // Original Candle 1 - { time: 1609459200 as UTCTimestamp, open: 100, high: 105, low: 95, close: 102 }, // 00:00 + { + ohlc: { + time: 1609459200 as UTCTimestamp, + open: 100, + high: 105, + low: 95, + close: 102, + }, + volume: 100, + }, // 00:00 // Inserted Empty Candles for Gap between 00:00 and 00:03 - { time: 1609459260 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, // 00:01 - { time: 1609459320 as UTCTimestamp, open: 102, high: 102, low: 102, close: 102 }, // 00:02 + { + ohlc: { + time: 1609459260 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, // 00:01 + { + ohlc: { + time: 1609459320 as UTCTimestamp, + open: 102, + high: 102, + low: 102, + close: 102, + }, + volume: 0, + }, // 00:02 // Original Candle 2 - { time: 1609459380 as UTCTimestamp, open: 106, high: 110, low: 105, close: 108 }, // 00:03 + { + ohlc: { + time: 1609459380 as UTCTimestamp, + open: 106, + high: 110, + low: 105, + close: 108, + }, + volume: 200, + }, // 00:03 // Inserted Empty Candles for Gap between 00:03 and 00:07 - { time: 1609459440 as UTCTimestamp, open: 108, high: 108, low: 108, close: 108 }, // 00:04 - { time: 1609459500 as UTCTimestamp, open: 108, high: 108, low: 108, close: 108 }, // 00:05 - { time: 1609459560 as UTCTimestamp, open: 108, high: 112, low: 107, close: 110 }, // 00:06 + { + ohlc: { + time: 1609459440 as UTCTimestamp, + open: 108, + high: 108, + low: 108, + close: 108, + }, + volume: 0, + }, // 00:04 + { + ohlc: { + time: 1609459500 as UTCTimestamp, + open: 108, + high: 108, + low: 108, + close: 108, + }, + volume: 0, + }, // 00:05 + { + ohlc: { + time: 1609459560 as UTCTimestamp, + open: 108, + high: 112, + low: 107, + close: 110, + }, + volume: 300, + }, // 00:06 // Inserted Empty Candle for Gap between 00:06 and 00:09 - { time: 1609459620 as UTCTimestamp, open: 110, high: 110, low: 110, close: 110 }, // 00:07 - { time: 1609459680 as UTCTimestamp, open: 110, high: 110, low: 110, close: 110 }, // 00:08 + { + ohlc: { + time: 1609459620 as UTCTimestamp, + open: 110, + high: 110, + low: 110, + close: 110, + }, + volume: 0, + }, // 00:07 + { + ohlc: { + time: 1609459680 as UTCTimestamp, + open: 110, + high: 110, + low: 110, + close: 110, + }, + volume: 0, + }, // 00:08 // Original Candle 4 - { time: 1609459740 as UTCTimestamp, open: 110, high: 115, low: 109, close: 113 }, // 00:09 + { + ohlc: { + time: 1609459740 as UTCTimestamp, + open: 110, + high: 115, + low: 109, + close: 113, + }, + volume: 400, + }, // 00:09 ]; const output = insertEmptyCandles(window, input); diff --git a/src/shared/api/server/candles/utils.ts b/src/shared/api/server/candles/utils.ts index b2ada4c9..33506875 100644 --- a/src/shared/api/server/candles/utils.ts +++ b/src/shared/api/server/candles/utils.ts @@ -2,22 +2,30 @@ import { OhlcData, UTCTimestamp } from 'lightweight-charts'; import { DbCandle } from '@/shared/api/server/candles/types.ts'; import { addDurationWindow, DurationWindow } from '@/shared/utils/duration.ts'; -export const dbCandleToOhlc = (c: DbCandle): OhlcData => { +export interface CandleWithVolume { + ohlc: OhlcData; + volume: number; +} + +export const dbCandleToOhlc = (c: DbCandle): CandleWithVolume => { return { - close: c.close, - high: c.high, - low: c.low, - open: c.open, - time: (c.start_time.getTime() / 1000) as UTCTimestamp, + ohlc: { + close: c.close, + high: c.high, + low: c.low, + open: c.open, + time: (c.start_time.getTime() / 1000) as UTCTimestamp, + }, + volume: c.direct_volume + c.swap_volume, }; }; /** Insert empty candles so that every timestamp as one candle. */ export const insertEmptyCandles = ( window: DurationWindow, - data: OhlcData[], -): OhlcData[] => { - const out: OhlcData[] = []; + data: CandleWithVolume[], +): CandleWithVolume[] => { + const out: CandleWithVolume[] = []; let i = 0; while (i < data.length) { @@ -32,24 +40,27 @@ export const insertEmptyCandles = ( throw new Error('the impossible happened'); } - let nextTime = (addDurationWindow(window, new Date(prev.time * 1000)).getTime() / + let nextTime = (addDurationWindow(window, new Date(prev.ohlc.time * 1000)).getTime() / 1000) as UTCTimestamp; // Ensure we don't go backwards in time - if (nextTime <= prev.time) { + if (nextTime <= prev.ohlc.time) { i += 1; continue; } - while (nextTime < candle.time) { + while (nextTime < candle.ohlc.time) { // Ensure we're not adding a candle before the previous one - if (nextTime > prev.time) { + if (nextTime > prev.ohlc.time) { out.push({ - time: nextTime, - open: prev.close, - close: prev.close, - low: prev.close, - high: prev.close, + ohlc: { + time: nextTime, + open: prev.ohlc.close, + close: prev.ohlc.close, + low: prev.ohlc.close, + high: prev.ohlc.close, + }, + volume: 0, }); } nextTime = (addDurationWindow(window, new Date(nextTime * 1000)).getTime() /