bbgo_origin/apps/backtest-report/components/TradingViewChart.tsx

641 lines
17 KiB
TypeScript
Raw Normal View History

2022-05-16 18:07:05 +00:00
import React, {useEffect, useRef, useState} from 'react';
import {tsvParse} from "d3-dsv";
2022-06-27 08:24:00 +00:00
import {Checkbox, Group, SegmentedControl} from '@mantine/core';
2022-06-26 16:10:58 +00:00
2022-05-16 14:24:25 +00:00
// https://github.com/tradingview/lightweight-charts/issues/543
// const createChart = dynamic(() => import('lightweight-charts'));
import {createChart, CrosshairMode, MouseEventParams, TimeRange} from 'lightweight-charts';
2022-06-27 08:24:00 +00:00
import {Order, ReportSummary} from "../types";
2022-06-15 08:24:28 +00:00
import moment from "moment";
2022-06-27 06:05:08 +00:00
import {format} from 'date-fns';
2022-06-27 08:17:06 +00:00
import OrderListTable from './OrderListTable';
2022-06-27 06:05:08 +00:00
// See https://codesandbox.io/s/ve7w2?file=/src/App.js
2022-06-27 05:49:05 +00:00
import TimeRangeSlider from './TimeRangeSlider';
2022-05-16 14:24:25 +00:00
2022-05-16 18:07:05 +00:00
const parseKline = () => {
return (d: any) => {
2022-05-16 18:07:05 +00:00
d.startTime = new Date(Number(d.startTime) * 1000);
d.endTime = new Date(Number(d.endTime) * 1000);
d.time = d.startTime.getTime() / 1000;
2022-05-16 14:24:25 +00:00
for (const key in d) {
// convert number fields
2022-05-16 18:07:05 +00:00
if (Object.prototype.hasOwnProperty.call(d, key)) {
switch (key) {
case "open":
case "high":
case "low":
case "close":
case "volume":
d[key] = +d[key];
break
}
2022-05-16 14:24:25 +00:00
}
}
return d;
};
};
2022-06-27 05:49:05 +00:00
const selectKLines = (klines: KLine[], startTime: Date, endTime: Date): KLine[] => {
const selected = [];
for (let i = 0; i < klines.length; i++) {
const k = klines[i]
if (k.startTime < startTime) {
continue
}
if (k.startTime > endTime) {
break
}
selected.push(k)
}
return selected
}
2022-05-16 14:24:25 +00:00
const parseOrder = () => {
2022-05-23 07:39:56 +00:00
return (d: any) => {
for (const key in d) {
// convert number fields
if (Object.prototype.hasOwnProperty.call(d, key)) {
switch (key) {
case "order_id":
case "price":
case "quantity":
2022-05-17 04:40:25 +00:00
d[key] = +d[key];
break;
case "update_time":
case "creation_time":
2022-05-17 04:40:25 +00:00
case "time":
d[key] = new Date(d[key]);
break;
}
}
}
return d;
};
}
const parsePosition = () => {
2022-05-23 07:39:56 +00:00
return (d: any) => {
2022-05-17 04:40:25 +00:00
for (const key in d) {
// convert number fields
if (Object.prototype.hasOwnProperty.call(d, key)) {
switch (key) {
case "accumulated_profit":
case "average_cost":
case "quote":
case "base":
d[key] = +d[key];
break
case "time":
d[key] = new Date(d[key]);
2022-05-17 04:40:25 +00:00
break
}
}
}
return d;
};
}
2022-05-23 07:39:56 +00:00
const fetchPositionHistory = (basePath: string, runID: string, filename: string) => {
2022-05-17 04:40:25 +00:00
return fetch(
2022-05-17 18:30:17 +00:00
`${basePath}/${runID}/${filename}`,
2022-05-17 04:40:25 +00:00
)
.then((response) => response.text())
2022-05-23 07:39:56 +00:00
.then((data) => tsvParse(data, parsePosition()) as Array<PositionHistoryEntry>)
2022-05-17 04:40:25 +00:00
.catch((e) => {
console.error("failed to fetch orders", e)
});
};
2022-05-23 07:39:56 +00:00
const fetchOrders = (basePath: string, runID: string) => {
return fetch(
2022-05-17 17:53:48 +00:00
`${basePath}/${runID}/orders.tsv`,
)
.then((response) => response.text())
2022-05-23 07:39:56 +00:00
.then((data: string) => tsvParse(data, parseOrder()) as Array<Order>)
.catch((e) => {
console.error("failed to fetch orders", e)
});
}
2022-05-23 07:39:56 +00:00
const parseInterval = (s: string) => {
2022-05-17 07:52:24 +00:00
switch (s) {
case "1m":
return 60;
case "5m":
return 60 * 5;
case "15m":
return 60 * 15;
case "30m":
return 60 * 30;
case "1h":
return 60 * 60;
case "4h":
return 60 * 60 * 4;
case "6h":
return 60 * 60 * 6;
case "12h":
return 60 * 60 * 12;
case "1d":
return 60 * 60 * 24;
2022-05-17 07:52:24 +00:00
}
return 60;
};
2022-05-23 07:39:56 +00:00
interface Marker {
time: number;
position: string;
color: string;
shape: string;
text: string;
2022-05-17 07:52:24 +00:00
}
const ordersToMarkers = (interval: string, orders: Array<Order> | void): Array<Marker> => {
2022-05-23 07:39:56 +00:00
const markers: Array<Marker> = [];
2022-05-17 07:52:24 +00:00
const intervalSecs = parseInterval(interval);
2022-05-23 07:39:56 +00:00
if (!orders) {
return markers;
}
// var markers = [{ time: data[data.length - 48].time, position: 'aboveBar', color: '#f68410', shape: 'circle', text: 'D' }];
for (let i = 0; i < orders.length; i++) {
let order = orders[i];
let t = (order.update_time || order.time).getTime() / 1000.0;
2022-05-17 07:52:24 +00:00
let lastMarker = markers.length > 0 ? markers[markers.length - 1] : null;
if (lastMarker) {
let remainder = lastMarker.time % intervalSecs;
let startTime = lastMarker.time - remainder;
let endTime = (startTime + intervalSecs);
// skip the marker in the same interval of the last marker
if (t < endTime) {
// continue
2022-05-17 07:52:24 +00:00
}
}
switch (order.side) {
case "BUY":
markers.push({
2022-05-17 07:52:24 +00:00
time: t,
position: 'belowBar',
color: '#239D10',
2022-06-05 05:46:43 +00:00
shape: 'arrowUp',
text: '' + order.price
2022-06-05 05:46:43 +00:00
//text: 'B',
});
break;
case "SELL":
markers.push({
2022-05-17 07:52:24 +00:00
time: t,
position: 'aboveBar',
color: '#e91e63',
shape: 'arrowDown',
text: '' + order.price
2022-06-05 05:46:43 +00:00
//text: 'S',
});
break;
}
}
return markers;
};
2022-05-23 07:39:56 +00:00
const removeDuplicatedKLines = (klines: Array<KLine>): Array<KLine> => {
2022-05-17 09:35:11 +00:00
const newK = [];
2022-05-17 17:53:48 +00:00
for (let i = 0; i < klines.length; i++) {
2022-05-17 09:35:11 +00:00
const k = klines[i];
2022-05-17 17:53:48 +00:00
if (i > 0 && k.time === klines[i - 1].time) {
2022-05-19 04:02:42 +00:00
console.warn(`duplicated kline at index ${i}`, k)
2022-05-17 09:35:11 +00:00
continue
}
newK.push(k);
}
return newK;
}
2022-06-15 08:24:28 +00:00
function fetchKLines(basePath: string, runID: string, symbol: string, interval: string, startTime: Date, endTime: Date) {
var duration = [moment(startTime).format('YYYYMMDD'), moment(endTime).format('YYYYMMDD')];
return fetch(
2022-06-15 08:24:28 +00:00
`${basePath}/shared/klines_${duration.join('-')}/${symbol}-${interval}.tsv`,
)
.then((response) => response.text())
.then((data) => tsvParse(data, parseKline()))
.catch((e) => {
console.error("failed to fetch klines", e)
});
}
2022-05-23 07:39:56 +00:00
interface KLine {
time: Date;
startTime: Date;
endTime: Date;
interval: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
const klinesToVolumeData = (klines: Array<KLine>) => {
2022-05-17 06:56:41 +00:00
const volumes = [];
2022-05-17 17:53:48 +00:00
for (let i = 0; i < klines.length; i++) {
2022-05-17 06:56:41 +00:00
const kline = klines[i];
volumes.push({
time: (kline.startTime.getTime() / 1000),
value: kline.volume,
})
}
return volumes;
}
2022-05-23 07:39:56 +00:00
interface PositionHistoryEntry {
time: Date;
base: number;
quote: number;
average_cost: number;
}
const positionBaseHistoryToLineData = (interval: string, hs: Array<PositionHistoryEntry>) => {
const bases = [];
const intervalSeconds = parseInterval(interval);
for (let i = 0; i < hs.length; i++) {
2022-05-17 10:10:37 +00:00
const pos = hs[i];
2022-05-19 04:02:42 +00:00
if (!pos.time) {
console.warn('position history record missing time field', pos)
continue
}
2022-05-23 07:39:56 +00:00
// ignore duplicated entry
if (i > 0 && hs[i].time.getTime() === hs[i - 1].time.getTime()) {
2022-05-23 07:39:56 +00:00
continue
}
let t = pos.time.getTime() / 1000;
t = (t - t % intervalSeconds)
2022-05-23 07:39:56 +00:00
if (i > 0 && (pos.base === hs[i - 1].base)) {
continue;
}
bases.push({
time: t,
value: pos.base,
});
}
return bases;
}
2022-05-23 07:39:56 +00:00
const positionAverageCostHistoryToLineData = (interval: string, hs: Array<PositionHistoryEntry>) => {
2022-05-17 04:40:25 +00:00
const avgCosts = [];
2022-05-17 07:52:24 +00:00
const intervalSeconds = parseInterval(interval);
2022-05-17 04:40:25 +00:00
for (let i = 0; i < hs.length; i++) {
2022-05-17 10:10:37 +00:00
const pos = hs[i];
2022-05-19 04:02:42 +00:00
if (!pos.time) {
console.warn('position history record missing time field', pos)
continue
}
2022-05-23 07:39:56 +00:00
// ignore duplicated entry
if (i > 0 && hs[i].time.getTime() === hs[i - 1].time.getTime()) {
2022-05-23 07:39:56 +00:00
continue
}
2022-05-17 07:52:24 +00:00
let t = pos.time.getTime() / 1000;
t = (t - t % intervalSeconds)
2022-05-17 04:40:25 +00:00
2022-05-23 07:39:56 +00:00
if (i > 0 && (pos.average_cost === hs[i - 1].average_cost)) {
2022-05-17 06:56:41 +00:00
continue;
}
2022-05-17 07:52:52 +00:00
if (pos.base === 0) {
2022-05-17 04:40:25 +00:00
avgCosts.push({
2022-05-17 07:52:24 +00:00
time: t,
2022-05-17 04:40:25 +00:00
value: 0,
});
} else {
avgCosts.push({
2022-05-17 07:52:24 +00:00
time: t,
2022-05-17 04:40:25 +00:00
value: pos.average_cost,
});
}
}
return avgCosts;
}
2022-05-23 07:39:56 +00:00
const createBaseChart = (chartContainerRef: React.RefObject<any>) => {
2022-05-19 04:02:42 +00:00
return createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight,
timeScale: {
timeVisible: true,
borderColor: '#D1D4DC',
},
rightPriceScale: {
borderColor: '#D1D4DC',
},
leftPriceScale: {
visible: true,
borderColor: 'rgba(197, 203, 206, 1)',
},
layout: {
backgroundColor: '#ffffff',
textColor: '#000',
},
crosshair: {
mode: CrosshairMode.Normal,
},
grid: {
horzLines: {
color: '#F0F3FA',
},
vertLines: {
color: '#F0F3FA',
},
},
});
};
2022-05-23 07:39:56 +00:00
interface TradingViewChartProps {
basePath: string;
runID: string;
reportSummary: ReportSummary;
symbol: string;
}
const TradingViewChart = (props: TradingViewChartProps) => {
const chartContainerRef = useRef<any>();
const chart = useRef<any>();
const resizeObserver = useRef<any>();
2022-05-19 16:48:15 +00:00
const intervals = props.reportSummary.intervals || [];
const [currentInterval, setCurrentInterval] = useState(intervals.length > 0 ? intervals[0] : '1m');
const [showPositionBase, setShowPositionBase] = useState(false);
const [showCanceledOrders, setShowCanceledOrders] = useState(false);
const [showPositionAverageCost, setShowPositionAverageCost] = useState(false);
const [orders, setOrders] = useState<Order[]>([]);
2022-06-27 05:49:05 +00:00
const reportTimeRange = [
new Date(props.reportSummary.startTime),
new Date(props.reportSummary.endTime),
]
const [selectedTimeRange, setSelectedTimeRange] = useState(reportTimeRange)
const [timeRange, setTimeRange] = useState(reportTimeRange);
2022-05-16 14:24:25 +00:00
useEffect(() => {
if (!chartContainerRef.current || chartContainerRef.current.children.length > 0) {
2022-05-16 14:24:25 +00:00
return;
}
2022-05-23 07:39:56 +00:00
const chartData: any = {};
2022-05-19 04:02:42 +00:00
const fetchers = [];
const ordersFetcher = fetchOrders(props.basePath, props.runID).then((orders: Order[] | void) => {
if (orders) {
const markers = ordersToMarkers(currentInterval, orders);
chartData.orders = orders;
chartData.markers = markers;
setOrders(orders);
}
2022-05-23 07:39:56 +00:00
return orders;
2022-05-19 04:02:42 +00:00
});
fetchers.push(ordersFetcher);
if (props.reportSummary && props.reportSummary.manifests && props.reportSummary.manifests.length === 1) {
const manifest = props.reportSummary?.manifests[0];
if (manifest && manifest.type === "strategyProperty" && manifest.strategyProperty === "position") {
const positionHistoryFetcher = fetchPositionHistory(props.basePath, props.runID, manifest.filename).then((data) => {
chartData.positionHistory = data;
});
fetchers.push(positionHistoryFetcher);
2022-05-17 18:30:17 +00:00
}
2022-05-19 04:02:42 +00:00
}
2022-06-15 08:24:28 +00:00
const kLinesFetcher = fetchKLines(props.basePath, props.runID, props.symbol, currentInterval, new Date(props.reportSummary.startTime), new Date(props.reportSummary.endTime)).then((klines) => {
2022-06-27 05:49:05 +00:00
if (klines) {
chartData.allKLines = removeDuplicatedKLines(klines)
chartData.klines = selectKLines(chartData.allKLines, selectedTimeRange[0], selectedTimeRange[1])
}
2022-05-19 04:02:42 +00:00
});
fetchers.push(kLinesFetcher);
2022-05-16 14:24:25 +00:00
2022-05-19 04:02:42 +00:00
Promise.all(fetchers).then(() => {
console.log("createChart")
chart.current = createBaseChart(chartContainerRef);
const series = chart.current.addCandlestickSeries({
upColor: 'rgb(38,166,154)',
downColor: 'rgb(255,82,82)',
wickUpColor: 'rgb(38,166,154)',
wickDownColor: 'rgb(255,82,82)',
borderVisible: false,
});
series.setData(chartData.klines);
series.setMarkers(chartData.markers);
2022-06-26 17:26:46 +00:00
[9, 27, 99].forEach((w, i) => {
const emaValues = calculateEMA(chartData.klines, w)
const emaColor = 'rgba(' + w + ', ' + (111 - w) + ', 232, 0.9)'
const emaLine = chart.current.addLineSeries({
color: emaColor,
lineWidth: 1,
2022-06-27 06:38:11 +00:00
lastValueVisible: false,
2022-06-26 17:26:46 +00:00
});
emaLine.setData(emaValues);
2022-06-27 08:24:00 +00:00
const legend = createLegend(i, emaColor)
2022-06-26 17:26:46 +00:00
chartContainerRef.current.appendChild(legend);
2022-06-27 08:24:00 +00:00
const updateLegendText = createLegendUpdater(legend, 'EMA ' + w)
2022-06-26 17:26:46 +00:00
2022-06-27 08:24:00 +00:00
updateLegendText(emaValues[emaValues.length - 1].value);
2022-06-26 17:26:46 +00:00
chart.current.subscribeCrosshairMove((param: MouseEventParams) => {
2022-06-27 08:24:00 +00:00
updateLegendText(param.seriesPrices.get(emaLine));
2022-06-26 17:26:46 +00:00
});
})
2022-05-19 04:02:42 +00:00
const volumeData = klinesToVolumeData(chartData.klines);
const volumeSeries = chart.current.addHistogramSeries({
color: '#182233',
lineWidth: 2,
priceFormat: {
type: 'volume',
},
2022-05-19 04:02:42 +00:00
overlay: true,
scaleMargins: {
top: 0.8,
bottom: 0,
},
2022-05-19 04:02:42 +00:00
});
volumeSeries.setData(volumeData);
2022-05-19 04:02:42 +00:00
if (chartData.positionHistory) {
if (showPositionAverageCost) {
const costLineSeries = chart.current.addLineSeries();
const costLine = positionAverageCostHistoryToLineData(currentInterval, chartData.positionHistory);
costLineSeries.setData(costLine);
}
if (showPositionBase) {
const baseLineSeries = chart.current.addLineSeries({
priceScaleId: 'left',
color: '#98338C',
});
const baseLine = positionBaseHistoryToLineData(currentInterval, chartData.positionHistory)
baseLineSeries.setData(baseLine);
}
2022-05-19 04:02:42 +00:00
}
2022-05-19 04:02:42 +00:00
chart.current.timeScale().fitContent();
/*
chart.current.timeScale().setVisibleRange({
from: (new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0))).getTime() / 1000,
to: (new Date(Date.UTC(2018, 1, 1, 0, 0, 0, 0))).getTime() / 1000,
});
*/
2022-06-26 17:42:12 +00:00
// see:
// https://codesandbox.io/s/9inkb?file=/src/styles.css
resizeObserver.current = new ResizeObserver(entries => {
if (!chart.current) {
return;
}
const {width, height} = entries[0].contentRect;
chart.current.applyOptions({width, height});
setTimeout(() => {
if (chart.current) {
chart.current.timeScale().fitContent();
}
}, 0);
});
resizeObserver.current.observe(chartContainerRef.current);
2022-05-17 06:56:41 +00:00
});
2022-05-17 17:53:48 +00:00
return () => {
2022-06-26 17:42:12 +00:00
console.log("removeChart")
2022-06-27 06:05:08 +00:00
if (resizeObserver.current) {
resizeObserver.current.disconnect();
}
2022-06-26 17:42:12 +00:00
2022-05-19 04:02:42 +00:00
if (chart.current) {
chart.current.remove();
}
2022-06-26 17:26:46 +00:00
if (chartContainerRef.current) {
2022-06-26 17:42:12 +00:00
// remove all the children because we created the legend elements
2022-06-26 17:26:46 +00:00
chartContainerRef.current.replaceChildren();
}
2022-06-26 17:42:12 +00:00
2022-05-17 17:53:48 +00:00
};
2022-06-27 05:49:05 +00:00
}, [props.runID, props.reportSummary, currentInterval, showPositionBase, showPositionAverageCost, selectedTimeRange])
return (
<div>
<Group>
2022-06-26 16:10:58 +00:00
<SegmentedControl
data={intervals.map((interval) => {
return {label: interval, value: interval}
})}
2022-06-26 16:10:58 +00:00
onChange={setCurrentInterval}
/>
<Checkbox label="Position Base" checked={showPositionBase}
onChange={(event) => setShowPositionBase(event.currentTarget.checked)}/>
<Checkbox label="Position Average Cost" checked={showPositionAverageCost}
onChange={(event) => setShowPositionAverageCost(event.currentTarget.checked)}/>
</Group>
2022-06-26 16:10:58 +00:00
2022-06-26 17:26:46 +00:00
<div ref={chartContainerRef} style={{'flex': 1, 'minHeight': 500, position: 'relative'}}>
</div>
2022-06-27 05:49:05 +00:00
<TimeRangeSlider
selectedInterval={selectedTimeRange}
timelineInterval={timeRange}
2022-06-27 08:24:00 +00:00
formatTick={(ms: Date) => format(new Date(ms), 'M d HH')}
2022-06-27 06:05:08 +00:00
step={1000 * parseInterval(currentInterval)}
2022-06-27 05:49:05 +00:00
onChange={(tr: any) => {
console.log("selectedTimeRange", tr)
setSelectedTimeRange(tr)
}}
/>
2022-06-27 08:17:06 +00:00
<OrderListTable orders={orders} onClick={(order) => {
console.log("selected order", order);
const visibleRange = chart.current.timeScale().getVisibleRange()
const seconds = parseInterval(currentInterval)
const bars = 12
const orderTime = order.creation_time.getTime() / 1000
const from = orderTime - bars * seconds
const to = orderTime + bars * seconds
console.log("orderTime", orderTime)
console.log("visibleRange", visibleRange)
console.log("setVisibleRange", from, to, to - from)
2022-06-27 05:49:05 +00:00
chart.current.timeScale().setVisibleRange({from, to} as TimeRange);
// chart.current.timeScale().scrollToPosition(20, true);
}}/>
2022-05-16 14:24:25 +00:00
</div>
);
2022-05-16 14:24:25 +00:00
};
2022-06-26 17:26:46 +00:00
const calculateEMA = (a: KLine[], r: number) => {
return a.map((k) => {
return {time: k.time, value: k.close}
}).reduce((p: any[], n: any, i: number) => {
if (i) {
const last = p[p.length - 1]
const v = 2 * n.value / (r + 1) + last.value * (r - 1) / (r + 1)
return p.concat({value: v, time: n.time})
}
return p
}, [{
value: a[0].close,
time: a[0].time
}])
}
2022-06-27 08:24:00 +00:00
const createLegend = (i: number, color: string) => {
const legend = document.createElement('div');
legend.className = 'ema-legend';
legend.style.display = 'block';
legend.style.position = 'absolute';
legend.style.left = 3 + 'px';
legend.style.color = color;
legend.style.zIndex = '99';
legend.style.top = 3 + (i * 22) + 'px';
return legend;
}
2022-06-26 17:26:46 +00:00
2022-06-27 08:24:00 +00:00
const createLegendUpdater = (legend: HTMLDivElement, prefix: string) => {
return (priceValue: any) => {
let val = '-';
if (priceValue !== undefined) {
val = (Math.round(priceValue * 100) / 100).toFixed(2);
}
legend.innerHTML = prefix + ' <span>' + val + '</span>';
}
}
2022-06-26 17:26:46 +00:00
2022-06-27 08:24:00 +00:00
export default TradingViewChart;