diff --git a/package.json b/package.json index 02ae2ca72..c5ba594fe 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "eslint-plugin-react-hooks": "^1.6.1", "eth-block-tracker-es5": "2.3.2", "ethereum-blockies": "0.1.0", + "fast-deep-equal": "^3.1.1", "file-loader": "4.3.0", "fontfaceobserver": "2.1.0", "fs-extra": "^8.1.0", @@ -87,6 +88,7 @@ "jest-styled-components": "7.0.2", "jest-watch-typeahead": "0.4.2", "jquery": "3.5.1", + "lightweight-charts": "3.3.0", "lint-staged": "10.0.8", "local-storage": "2.0.0", "mathjs": "7.1.0", diff --git a/src/app/components/TradingChart/index.tsx b/src/app/components/TradingChart/index.tsx new file mode 100644 index 000000000..0201d8aff --- /dev/null +++ b/src/app/components/TradingChart/index.tsx @@ -0,0 +1,178 @@ +/** + * + * TradingChart + * + */ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import Chart from 'utils/kaktana-react-lightweight-charts/Chart'; +import { Skeleton } from '../PageSkeleton'; +import { backendUrl, currentChainId } from 'utils/classifiers'; + +export enum Theme { + LIGHT = 'Light', + DARK = 'Dark', +} + +export enum ChartType { + LINE = 'line', + CANDLE = 'candle', +} + +export interface ChartContainerProps { + rate: number; + symbol: string; + type: ChartType; + theme: Theme; +} + +// detailed description of series options https://github.com/kaktana/kaktana-react-lightweight-charts#seriesobject +interface ChartData { + data: any[]; + options?: any; + markers?: any; + priceLines?: any; + legend?: string; + linearInterpolation?: number; +} + +export function TradingChart(props: ChartContainerProps) { + const threeMonths = 7257600000; //3 months in ms + const initData: ChartData[] = [{ data: [], legend: '' }]; + const [hasCharts, setHasCharts] = useState(false); + const [chartData, setChartData] = useState(initData.slice()); + const [lastTime, setLastTime] = useState( + new Date().getTime() - threeMonths, + ); + + const resetChart = () => { + setHasCharts(false); + setChartData(initData.slice()); + setLastTime(new Date().getTime() - threeMonths); + }; + + const seriesProps = { + candlestickSeries: props.type === ChartType.CANDLE ? chartData : undefined, + lineSeries: props.type === ChartType.LINE ? chartData : undefined, + }; + + useEffect(() => { + function getData() { + axios + .get(`${backendUrl[currentChainId]}/datafeed/price/${props.symbol}`, { + params: { + type: props.type, + startTime: lastTime, + }, + }) + .then(response => { + //console.log(response); + if ( + response.data && + response.data.series && + response.data.series.length > 0 + ) { + let newSeries: Array = []; + if (chartData[0].data.length > 0) { + // remove old datums, starting at the one with same time as first new one that was received + newSeries = chartData[0].data.slice(); + const firstVal = response.data.series[0]; + const len = newSeries.length - 1; + let i = -1; + for (let x = len; x >= 0; x--) + if (newSeries[x].time === firstVal.time) { + i = x; + break; + } + if (i > -1) newSeries = newSeries.slice(0, i); + } + newSeries = newSeries.concat(response.data.series); + // console.log(newSeries); + setChartData([{ data: newSeries, legend: '' }]); + const latest = newSeries[newSeries.length - 1]; + setLastTime(latest.time * 1e3); // datum time is in seconds, lastTime is ms + // console.log(latest.time, latest.close); + setHasCharts(true); + } + }) + .catch(error => { + console.log(error); + setHasCharts(false); + }); + } + + getData(); + const interval = setInterval(() => getData(), props.rate * 1e3); + return () => { + clearInterval(interval); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastTime]); + + useEffect(() => { + resetChart(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.symbol, props.rate, props.type]); + + return ( +
+ {hasCharts ? ( + + ) : ( + <> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + )} +
+ ); +} + +TradingChart.defaultProps = { + rate: 15, + type: ChartType.CANDLE, + theme: Theme.DARK, +}; diff --git a/src/app/components/TradingViewChart/index.tsx b/src/app/components/TradingViewChart/index.tsx deleted file mode 100644 index 567e40c6f..000000000 --- a/src/app/components/TradingViewChart/index.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * - * TradingViewChart - * - */ -import React, { useEffect, useState } from 'react'; -import { Skeleton } from '../PageSkeleton'; - -enum Theme { - LIGHT = 'Light', - DARK = 'Dark', -} - -export interface ChartContainerProps { - symbol: string; - theme: Theme; -} - -export function TradingViewChart(props: ChartContainerProps) { - const [hasCharts, setHasCharts] = useState(true); - - useEffect(() => { - try { - // @ts-ignore - const widget = new TradingView.widget({ - width: 980, - height: 610, - symbol: props.symbol.toLowerCase(), - interval: '30' as any, - timezone: 'Etc/UTC', - // theme: 'Dark', - locale: 'en', - toolbar_bg: '#171717', - enable_publishing: false, - allow_symbol_change: true, - container_id: 'trading-view-container', - autosize: true, - fullscreen: false, - studies_overrides: { - 'volume.volume.color.0': '#fec006', - 'volume.volume.color.1': '#3fcfb4', - 'volume.volume.transparency': 75, - }, - disabled_features: [ - 'left_toolbar', - 'header_compare', - 'header_undo_redo', - 'header_saveload', - 'header_settings', - 'header_screenshot', - 'use_localstorage_for_settings', - 'header_fullscreen_button', - 'go_to_date', - ], - // enabled_features: ['study_templates'], - loading_screen: { backgroundColor: '#171717' }, - overrides: { - 'paneProperties.background': '#171717', - 'paneProperties.vertGridProperties.color': '#363c4e', - 'paneProperties.horzGridProperties.color': '#363c4e', - 'symbolWatermarkProperties.transparency': 200, - 'scalesProperties.textColor': '#AAA', - // 'mainSeriesProperties.candleStyle.wickUpColor': '#4ecdc4', - // 'mainSeriesProperties.candleStyle.upColor': '#4ecdc4', - // 'mainSeriesProperties.candleStyle.wickDownColor': '#fec006', - // 'mainSeriesProperties.candleStyle.downColor': '#fec006', - // 'mainSeriesProperties.candleStyle.borderColor': '#4ecdc4', - // 'mainSeriesProperties.candleStyle.borderUpColor': '#4ecdc4', - // 'mainSeriesProperties.candleStyle.borderDownColor': '#fec006', - // 'mainSeriesProperties.hollowCandleStyle.wickUpColor': '#4ecdc4', - // 'mainSeriesProperties.hollowCandleStyle.upColor': '#4ecdc4', - // 'mainSeriesProperties.hollowCandleStyle.wickDownColor': '#fec006', - // 'mainSeriesProperties.hollowCandleStyle.downColor': '#fec006', - // 'mainSeriesProperties.hollowCandleStyle.borderColor': '#4ecdc4', - // 'mainSeriesProperties.hollowCandleStyle.borderUpColor': '#4ecdc4', - // 'mainSeriesProperties.hollowCandleStyle.borderDownColor': '#fec006', - // 'mainSeriesProperties.haStyle.wickUpColor': '#4ecdc4', - // 'mainSeriesProperties.haStyle.upColor': '#4ecdc4', - // 'mainSeriesProperties.haStyle.wickDownColor': '#fec006', - // 'mainSeriesProperties.haStyle.downColor': '#fec006', - // 'mainSeriesProperties.haStyle.borderColor': '#4ecdc4', - // 'mainSeriesProperties.haStyle.borderUpColor': '#4ecdc4', - // 'mainSeriesProperties.haStyle.borderDownColor': '#fec006', - // 'mainSeriesProperties.lineStyle.color': '#4ecdc4', - // 'mainSeriesProperties.areaStyle.color1': '#4ecdc4', - // 'mainSeriesProperties.areaStyle.color2': '#0098c4', - // 'mainSeriesProperties.areaStyle.linecolor': '#4ecdc4', - 'mainSeriesProperties.areaStyle.transparency': 90, - }, - }); - setHasCharts(true); - return () => { - widget.remove(); - }; - } catch (e) { - setHasCharts(false); - } - }, [props.symbol]); - - return ( -
- {!hasCharts && ( - <> -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- - )} -
- ); -} - -TradingViewChart.defaultProps = { - theme: Theme.DARK, -}; diff --git a/src/app/containers/TradingPage/index.tsx b/src/app/containers/TradingPage/index.tsx index ae9477897..cae6dd573 100644 --- a/src/app/containers/TradingPage/index.tsx +++ b/src/app/containers/TradingPage/index.tsx @@ -16,7 +16,7 @@ import { reducer, sliceKey } from './slice'; import { selectTradingPage } from './selectors'; import { tradingPageSaga } from './saga'; import { TradingPairSelector } from '../TradingPairSelector/Loadable'; -import { TradingViewChart } from '../../components/TradingViewChart'; +import { TradingChart, Theme, ChartType } from '../../components/TradingChart'; import { TradeOrSwapTabs } from '../../components/TradeOrSwapTabs/Loadable'; import { TradingActivity } from '../TradingActivity/Loadable'; import { Header } from 'app/components/Header'; @@ -52,7 +52,11 @@ export function TradingPage(props: Props) { tradingPage.isMobileStatsOpen && `d-block` } d-lg-block`} > - +
{tradingPage.tab === TabType.TRADE && } diff --git a/src/utils/dictionaries/trading-pair-dictionary.ts b/src/utils/dictionaries/trading-pair-dictionary.ts index 4c42236c2..128c6dc16 100644 --- a/src/utils/dictionaries/trading-pair-dictionary.ts +++ b/src/utils/dictionaries/trading-pair-dictionary.ts @@ -19,7 +19,7 @@ export class TradingPairDictionary { 'BTC', // asset Asset.BTC, - 'Bitfinex:BTCUSD', + 'BTC:USDT', // asset for long position Asset.USDT, // asset for sort position @@ -34,7 +34,7 @@ export class TradingPairDictionary { 'BPRO', // asset Asset.BPRO, - 'Bitfinex:BTCUSD', + 'BPRO:USDT', // asset for long position Asset.USDT, // asset for sort position diff --git a/src/utils/kaktana-react-lightweight-charts/Chart.d.ts b/src/utils/kaktana-react-lightweight-charts/Chart.d.ts new file mode 100644 index 000000000..127d8cf75 --- /dev/null +++ b/src/utils/kaktana-react-lightweight-charts/Chart.d.ts @@ -0,0 +1,28 @@ +import { + MouseEventHandler, + TimeRangeChangeEventHandler, +} from 'lightweight-charts'; +import React from 'react'; + +interface Props { + candlestickSeries?: Array; + lineSeries?: Array; + areaSeries?: Array; + barSeries?: Array; + histogramSeries?: Array; + width?: number; + height?: number; + options?: object; + autoWidth?: boolean; + autoHeight?: boolean; + legend?: string; + from?: number; + to?: number; + onClick?: MouseEventHandler; + onCrosshairMove?: MouseEventHandler; + onTimeRangeMove?: TimeRangeChangeEventHandler; + darkTheme?: boolean; + className?: string; +} + +export default class Chart extends React.Component {} diff --git a/src/utils/kaktana-react-lightweight-charts/Chart.js b/src/utils/kaktana-react-lightweight-charts/Chart.js new file mode 100644 index 000000000..ca5e303ae --- /dev/null +++ b/src/utils/kaktana-react-lightweight-charts/Chart.js @@ -0,0 +1,386 @@ +// Code adapted from https://github.com/Kaktana/kaktana-react-lightweight-charts to use v3.3.0 of lightweight-charts, as the project is longer maintained. +// kaktana-react-lightweight-charts is released under MIT license. You are free to use, modify and distribute this software, as long as the copyright header is left intact. +import React from 'react'; +import equal from 'fast-deep-equal'; +import { createChart } from 'lightweight-charts'; + +const addSeriesFunctions = { + candlestick: 'addCandlestickSeries', + line: 'addLineSeries', + area: 'addAreaSeries', + bar: 'addBarSeries', + histogram: 'addHistogramSeries', +}; + +const colors = [ + '#008FFB', + '#00E396', + '#FEB019', + '#FF4560', + '#775DD0', + '#F86624', + '#A5978B', +]; + +const darkTheme = { + layout: { + backgroundColor: '#171717', + lineColor: '#2B2B43', + textColor: '#D9D9D9', + }, + grid: { + vertLines: { + color: '#363c4e', + }, + horzLines: { + color: '#363c4e', + }, + }, + styles: { + border: '1px solid #ffffff', + }, +}; + +const lightTheme = { + layout: { + backgroundColor: '#FFFFFF', + lineColor: '#2B2B43', + textColor: '#191919', + }, + grid: { + vertLines: { + color: '#e1ecf2', + }, + horzLines: { + color: '#e1ecf2', + }, + }, + styles: { + border: '1px solid #000000', + }, +}; + +class Chart extends React.Component { + constructor(props) { + super(props); + this.chartDiv = React.createRef(); + this.legendDiv = React.createRef(); + this.legendDivLabel = null; + this.chart = null; + this.series = []; + this.legends = []; + } + + componentDidMount() { + this.chart = createChart(this.chartDiv.current); + this.handleUpdateChart(); + this.resizeHandler(); + } + + componentDidUpdate(prevProps) { + if (!this.props.autoWidth && !this.props.autoHeight) + window.removeEventListener('resize', this.resizeHandler); + if ( + !equal( + [ + prevProps.onCrosshairMove, + prevProps.onTimeRangeMove, + prevProps.onClick, + ], + [ + this.props.onCrosshairMove, + this.props.onTimeRangeMove, + this.props.onClick, + ], + ) + ) + this.unsubscribeEvents(prevProps); + if ( + !equal( + [ + prevProps.options, + prevProps.darkTheme, + prevProps.className, + prevProps.candlestickSeries, + prevProps.lineSeries, + prevProps.areaSeries, + prevProps.barSeries, + prevProps.histogramSeries, + ], + [ + this.props.options, + this.props.darkTheme, + this.props.className, + this.props.candlestickSeries, + this.props.lineSeries, + this.props.areaSeries, + this.props.barSeries, + this.props.histogramSeries, + ], + ) + ) { + this.removeSeries(); + this.handleUpdateChart(); + } else if ( + prevProps.from !== this.props.from || + prevProps.to !== this.props.to + ) + this.handleTimeRange(); + } + + resizeHandler = () => { + let width = + this.props.autoWidth && + this.chartDiv.current && + this.chartDiv.current.parentNode.clientWidth; + let height = + this.props.autoHeight && this.chartDiv.current + ? this.chartDiv.current.parentNode.clientHeight + : this.props.height || 500; + this.chart.resize(width, height); + }; + + removeSeries = () => { + this.series.forEach(serie => this.chart.removeSeries(serie)); + this.series = []; + }; + + addSeries = (serie, type) => { + const func = addSeriesFunctions[type]; + let color = + (serie.options && serie.options.color) || + colors[this.series.length % colors.length]; + const series = this.chart[func]({ + color, + ...serie.options, + }); + let data = this.handleLinearInterpolation( + serie.data, + serie.linearInterpolation, + ); + series.setData(data); + if (serie.markers) series.setMarkers(serie.markers); + if (serie.priceLines) + serie.priceLines.forEach(line => series.createPriceLine(line)); + if (serie.legend !== null && typeof serie.legend !== 'undefined') + this.addLegend(series, color, serie.legend); + return series; + }; + + handleSeries = () => { + let series = this.series; + let props = this.props; + props.candlestickSeries && + props.candlestickSeries.forEach(serie => { + series.push(this.addSeries(serie, 'candlestick')); + }); + + props.lineSeries && + props.lineSeries.forEach(serie => { + series.push(this.addSeries(serie, 'line')); + }); + + props.areaSeries && + props.areaSeries.forEach(serie => { + series.push(this.addSeries(serie, 'area')); + }); + + props.barSeries && + props.barSeries.forEach(serie => { + series.push(this.addSeries(serie, 'bar')); + }); + + props.histogramSeries && + props.histogramSeries.forEach(serie => { + series.push(this.addSeries(serie, 'histogram')); + }); + }; + + unsubscribeEvents = prevProps => { + let chart = this.chart; + chart.unsubscribeClick(prevProps.onClick); + chart.unsubscribeCrosshairMove(prevProps.onCrosshairMove); + chart + .timeScale() + .unsubscribeVisibleTimeRangeChange(prevProps.onTimeRangeMove); + }; + + handleEvents = () => { + let chart = this.chart; + let props = this.props; + props.onClick && chart.subscribeClick(props.onClick); + props.onCrosshairMove && + chart.subscribeCrosshairMove(props.onCrosshairMove); + props.onTimeRangeMove && + chart.timeScale().subscribeVisibleTimeRangeChange(props.onTimeRangeMove); + + // handle legend dynamical change + chart.subscribeCrosshairMove(this.handleLegends); + }; + + handleTimeRange = () => { + let { from, to } = this.props; + from && to && this.chart.timeScale().setVisibleRange({ from, to }); + }; + + handleLinearInterpolation = (data, candleTime) => { + if (!candleTime || data.length < 2 || !data[0].value) return data; + let first = data[0].time; + let last = data[data.length - 1].time; + let newData = new Array(Math.floor((last - first) / candleTime)); + newData[0] = data[0]; + let index = 1; + for (let i = 1; i < data.length; i++) { + newData[index++] = data[i]; + let prevTime = data[i - 1].time; + let prevValue = data[i - 1].value; + let { time, value } = data[i]; + for ( + let interTime = prevTime; + interTime < time - candleTime; + interTime += candleTime + ) { + // interValue get from the Taylor-Young formula + let interValue = + prevValue + + (interTime - prevTime) * ((value - prevValue) / (time - prevTime)); + newData[index++] = { time: interTime, value: interValue }; + } + } + // return only the valid values + return newData.filter(x => x); + }; + + handleUpdateChart = () => { + window.removeEventListener('resize', this.resizeHandler); + let { chart, chartDiv } = this; + let props = this.props; + let options = this.props.darkTheme ? darkTheme : lightTheme; + options = mergeDeep(options, { + width: props.autoWidth + ? chartDiv.current.parentNode.clientWidth + : props.width, + height: props.autoHeight + ? chartDiv.current.parentNode.clientHeight + : props.height || 500, + ...props.options, + }); + chart.applyOptions(options); + if (this.chartDiv.current) { + let cDiv = this.chartDiv.current.getElementsByClassName( + 'tv-lightweight-charts', + ); + if (cDiv && cDiv.length > 0) { + let styleOpts = this.props.darkTheme + ? darkTheme.styles + : lightTheme.styles; + for (let key in styleOpts) { + if (cDiv[0]) cDiv[0].style[key] = styleOpts[key]; + } + } + } + if (this.legendDiv.current) this.legendDiv.current.innerHTML = ''; + this.legends = []; + if (this.props.legend) this.handleMainLegend(); + + this.handleSeries(); + this.handleEvents(); + this.handleTimeRange(); + + if (props.autoWidth || props.autoHeight) + // resize the chart with the window + window.addEventListener('resize', this.resizeHandler); + }; + + addLegend = (series, color, title) => { + this.legends.push({ series, color, title }); + }; + + handleLegends = param => { + let div = this.legendDivLabel; + if (param.time && div && this.legends) { + div.innerHTML = ''; + this.legends.forEach(({ series, color, title }) => { + let price = param.seriesPrices.get(series); + if (price !== undefined) { + if (typeof price == 'object') { + color = + price.open < price.close + ? 'rgba(0, 150, 136, 0.8)' + : 'rgba(255,82,82, 0.8)'; + price = `O: ${price.open}, H: ${price.high}, L: ${price.low}, C: ${price.close}`; + } + const stamp = new Date(param.time * 1e3); + // prettier-ignore + const dateStamp = `${stamp.getFullYear()}-${stamp.getUTCMonth()+1}-${stamp.getUTCDate()}`; + let row = document.createElement('div'); + row.style.fontSize = 'smaller'; + row.innerText = `${title} ${dateStamp} `; + let priceElem = document.createElement('span'); + priceElem.style.color = color; + priceElem.style.fontSize = 'smaller'; + priceElem.innerText = ' ' + price; + row.appendChild(priceElem); + div.appendChild(row); + } + }); + } + }; + + handleMainLegend = () => { + if (this.legendDiv.current) { + let row = document.createElement('div'); + row.innerText = this.props.legend; + this.legendDiv.current.appendChild(row); + this.legendDivLabel = document.createElement('div'); + this.legendDiv.current.appendChild(row); + this.legendDiv.current.appendChild(this.legendDivLabel); + } + }; + + render() { + let color = this.props.darkTheme + ? darkTheme.layout.textColor + : lightTheme.layout.textColor; + + return ( +
+
+
+ ); + } +} + +export default Chart; + +const isObject = item => + item && typeof item === 'object' && !Array.isArray(item); + +const mergeDeep = (target, source) => { + let output = Object.assign({}, target); + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach(key => { + if (isObject(source[key])) { + if (!(key in target)) Object.assign(output, { [key]: source[key] }); + else output[key] = mergeDeep(target[key], source[key]); + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + return output; +}; diff --git a/yarn.lock b/yarn.lock index 076dcdbf5..ad37502d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10389,6 +10389,11 @@ fake-merkle-patricia-tree@^1.0.1: dependencies: checkpoint-store "^1.1.0" +fancy-canvas@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/fancy-canvas/-/fancy-canvas-0.2.2.tgz#33fd4976724169a1eda5015f515a2a1302d1ec91" + integrity sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA== + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -13962,6 +13967,13 @@ liftoff@^2.5.0: rechoir "^0.6.2" resolve "^1.1.7" +lightweight-charts@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lightweight-charts/-/lightweight-charts-3.3.0.tgz#cbaafe8977ac342d45e847e12eb5c7b36007358a" + integrity sha512-W5jeBrXcHG8eHnIQ0L2CB9TLkrrsjNPlQq5SICPO8PnJ3dJ8jZkLCAwemZ7Ym7ZGCfKCz6ow1EPbyzNYxblnkw== + dependencies: + fancy-canvas "0.2.2" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"