diff --git a/src/components/charts/CandleChartContainerSimple.vue b/src/components/charts/CandleChartContainerSimple.vue new file mode 100644 index 0000000000..a082f1e7f8 --- /dev/null +++ b/src/components/charts/CandleChartContainerSimple.vue @@ -0,0 +1,144 @@ +<template> + <div class="d-flex h-100"> + <div class="flex-fill w-100 flex-column align-items-stretch d-flex h-100"> + <div class="pair-name"> + {{ pair }} + </div> + <div class="me-1 ms-1 h-100"> + <CandleChartSimple + v-if="hasDataset" + :dataset="dataset" + :trades="trades" + :plot-config="plotStore.plotConfig" + :heikin-ashi="settingsStore.useHeikinAshiCandles" + :use-u-t-c="settingsStore.timezone === 'UTC'" + :theme="settingsStore.chartTheme" + :slider-position="sliderPosition" + > + </CandleChartSimple> + </div> + </div> + </div> +</template> + +<script lang="ts"> +import { Trade, PairHistory, LoadingStatus, ChartSliderPosition } from '@/types'; +import CandleChartSimple from '@/components/charts/CandleChartSimple.vue'; +import { useSettingsStore } from '@/stores/settings'; +import { usePlotConfigStore } from '@/stores/plotConfig'; +import { defineComponent, ref, computed, onMounted } from 'vue'; +import { useBotStore } from '@/stores/ftbotwrapper'; + +export default defineComponent({ + name: 'CandleChartContainerSimple', + components: { CandleChartSimple }, + props: { + trades: { required: false, default: () => [], type: Array as () => Trade[] }, + pair: { required: true, type: String }, + timeframe: { required: true, type: String }, + historicView: { required: false, default: false, type: Boolean }, + plotConfigModal: { required: false, default: true, type: Boolean }, + timerange: { required: false, default: '', type: String }, + strategy: { required: false, default: '', type: String }, + sliderPosition: { + required: false, + type: Object as () => ChartSliderPosition, + default: () => undefined, + }, + }, + setup(props) { + const settingsStore = useSettingsStore(); + const botStore = useBotStore(); + const plotStore = usePlotConfigStore(); + const pair = ref(''); + + const dataset = computed((): PairHistory => { + if (props.historicView) { + return botStore.activeBot.history[`${pair.value}__${props.timeframe}`]?.data; + } + return botStore.activeBot.candleData[`${pair.value}__${props.timeframe}`]?.data; + }); + + const datasetColumns = computed(() => (dataset.value ? dataset.value.columns : [])); + const hasDataset = computed(() => !!dataset.value); + const noDatasetText = computed((): string => { + const status = props.historicView + ? botStore.activeBot.historyStatus + : botStore.activeBot.candleDataStatus; + + switch (status) { + case LoadingStatus.loading: + return 'Loading...'; + + case LoadingStatus.success: + return 'No data available'; + + case LoadingStatus.error: + return 'Failed to load data'; + + default: + return 'Unknown'; + } + }); + + const refresh = () => { + console.log('refresh', pair.value, props.timeframe); + if (pair.value && props.timeframe) { + if (props.historicView) { + botStore.activeBot.getPairHistory({ + pair: pair.value, + timeframe: props.timeframe, + timerange: props.timerange, + strategy: props.strategy, + }); + } else { + botStore.activeBot.getPairCandles({ + pair: pair.value, + timeframe: props.timeframe, + limit: 500, + }); + } + } + }; + + onMounted(() => { + pair.value = props.pair; + plotStore.plotConfigChanged(); + + refresh(); + }); + + return { + botStore, + settingsStore, + plotStore, + history, + dataset, + datasetColumns, + noDatasetText, + hasDataset, + + refresh, + pair, + }; + }, +}); +</script> + +<style scoped lang="scss"> +.fade-enter-active, +.fade-leave-active { + transition: all 0.2s; +} + +.fade-enter, +.fade-leave-to { + opacity: 0; + transform: translateX(30px); +} +.pair-name { + text-align: center; + font-size: 12px; + font-weight: 600; +} +</style> diff --git a/src/components/charts/CandleChartSimple.vue b/src/components/charts/CandleChartSimple.vue new file mode 100644 index 0000000000..2082986983 --- /dev/null +++ b/src/components/charts/CandleChartSimple.vue @@ -0,0 +1,582 @@ +<template> + <div class="d-flex flex-grow-1 chart-wrapper"> + <v-chart v-if="hasData" ref="candleChart" :theme="theme" autoresize manual-update /> + </div> +</template> + +<script lang="ts"> +import { defineComponent, ref, computed, onMounted, watch } from 'vue'; +import { Trade, PairHistory, PlotConfig } from '@/types'; +import randomColor from '@/shared/randomColor'; +import heikinashi from '@/shared/heikinashi'; +import { getTradeEntries } from '@/shared/charts/tradeChartData'; +import ECharts from 'vue-echarts'; +import { format } from 'date-fns-tz'; + +import { use } from 'echarts/core'; +import { EChartsOption, SeriesOption, ScatterSeriesOption } from 'echarts'; +import { CanvasRenderer } from 'echarts/renderers'; +import { CandlestickChart, LineChart, BarChart, ScatterChart } from 'echarts/charts'; +import { + AxisPointerComponent, + CalendarComponent, + DatasetComponent, + GridComponent, + LegendComponent, + TimelineComponent, + TitleComponent, + ToolboxComponent, + VisualMapComponent, + VisualMapPiecewiseComponent, +} from 'echarts/components'; + +use([ + AxisPointerComponent, + CalendarComponent, + DatasetComponent, + GridComponent, + LegendComponent, + TimelineComponent, + TitleComponent, + ToolboxComponent, + VisualMapComponent, + VisualMapPiecewiseComponent, + + CandlestickChart, + BarChart, + LineChart, + ScatterChart, + CanvasRenderer, +]); + +// Chart default options +const MARGINTOP = '0%'; +const MARGINLEFT = '7.5%'; +const MARGINRIGHT = '1%'; +const NAMEGAP = 55; +const SUBPLOTHEIGHT = 8; // Value in % + +// Binance colors +const upColor = '#26A69A'; +const upBorderColor = '#26A69A'; +const downColor = '#EF5350'; +const downBorderColor = '#EF5350'; + +const buySignalColor = '#00ff26'; +const shortEntrySignalColor = '#00ff26'; +const sellSignalColor = '#faba25'; +const shortexitSignalColor = '#faba25'; + +export default defineComponent({ + name: 'CandleChartSimple', + components: { 'v-chart': ECharts }, + props: { + trades: { required: false, default: () => [], type: Array as () => Trade[] }, + dataset: { required: true, type: Object as () => PairHistory }, + heikinAshi: { required: false, default: false, type: Boolean }, + useUTC: { required: false, default: true, type: Boolean }, + plotConfig: { required: true, type: Object as () => PlotConfig }, + theme: { default: 'dark', type: String } + }, + setup(props) { + const candleChart = ref<typeof ECharts>(); + const buyData = ref<number[][]>([]); + const sellData = ref<number[][]>([]); + const chartOptions = ref<EChartsOption>({}); + + const strategy = computed(() => { + return props.dataset ? props.dataset.strategy : ''; + }); + + const pair = computed(() => { + return props.dataset ? props.dataset.pair : ''; + }); + + const timeframe = computed(() => { + return props.dataset ? props.dataset.timeframe : ''; + }); + + const datasetColumns = computed(() => { + return props.dataset ? props.dataset.columns : []; + }); + + const hasData = computed(() => { + return props.dataset !== null && typeof props.dataset === 'object'; + }); + + const filteredTrades = computed(() => { + return props.trades.filter((item: Trade) => item.pair === pair.value); + }); + + const chartTitle = computed(() => { + return `${strategy.value} - ${pair.value} - ${timeframe.value}`; + }); + + const updateChart = (initial = false) => { + if (!hasData.value) { + return; + } + if (chartOptions.value?.title) { + chartOptions.value.title[0].text = chartTitle.value; + } + const colDate = props.dataset.columns.findIndex((el) => el === '__date_ts'); + const colOpen = props.dataset.columns.findIndex((el) => el === 'open'); + const colHigh = props.dataset.columns.findIndex((el) => el === 'high'); + const colLow = props.dataset.columns.findIndex((el) => el === 'low'); + const colClose = props.dataset.columns.findIndex((el) => el === 'close'); + const colVolume = props.dataset.columns.findIndex((el) => el === 'volume'); + const colEntryData = props.dataset.columns.findIndex( + (el) => el === '_buy_signal_close' || el === '_enter_long_signal_close', + ); + const colExitData = props.dataset.columns.findIndex( + (el) => el === '_sell_signal_close' || el === '_exit_long_signal_close', + ); + + const colShortEntryData = props.dataset.columns.findIndex( + (el) => el === '_enter_short_signal_close', + ); + const colShortExitData = props.dataset.columns.findIndex( + (el) => el === '_exit_short_signal_close', + ); + + const subplotCount = + 'subplots' in props.plotConfig ? Object.keys(props.plotConfig.subplots).length + 1 : 1; + + const dataset = props.heikinAshi + ? heikinashi(datasetColumns.value, props.dataset.data) + : props.dataset.data.slice(); + // Add new rows to end to allow slight "scroll past" + const newArray = Array(dataset[dataset.length - 2].length); + newArray[colDate] = dataset[dataset.length - 1][colDate] + props.dataset.timeframe_ms * 3; + dataset.push(newArray); + const options: EChartsOption = { + dataset: { + source: dataset, + }, + grid: [ + { + top: MARGINTOP, + left: MARGINLEFT, + right: MARGINRIGHT, + // Grid Layout from bottom to top + bottom: `${subplotCount * SUBPLOTHEIGHT + 2}%`, + }, + { + // Volume + top: MARGINTOP, + left: MARGINLEFT, + right: MARGINRIGHT, + // Grid Layout from bottom to top + bottom: `${subplotCount * SUBPLOTHEIGHT}%`, + height: `${SUBPLOTHEIGHT}%`, + }, + ], + series: [ + { + name: 'Candles', + type: 'candlestick', + barWidth: '80%', + itemStyle: { + color: upColor, + color0: downColor, + borderColor: upBorderColor, + borderColor0: downBorderColor, + }, + encode: { + x: colDate, + // open, close, low, high + y: [colOpen, colClose, colLow, colHigh], + }, + }, + { + name: 'Volume', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + itemStyle: { + color: '#777777', + }, + large: false, + encode: { + x: colDate, + y: colVolume, + }, + }, + { + name: 'Entry', + type: 'scatter', + symbol: 'triangle', + symbolSize: 10, + xAxisIndex: 0, + yAxisIndex: 0, + itemStyle: { + color: buySignalColor, + }, + encode: { + x: colDate, + y: colEntryData, + }, + }, + ], + }; + + if (colExitData >= 0) { + + if (Array.isArray(options.series)) { + options.series.push({ + name: 'Exit', + type: 'scatter', + symbol: 'diamond', + symbolSize: 8, + xAxisIndex: 0, + yAxisIndex: 0, + itemStyle: { + color: sellSignalColor, + }, + encode: { + x: colDate, + y: colExitData, + }, + }); + } + } + + if (Array.isArray(options.series)) { + if (colShortEntryData >= 0) { + options.series.push({ + // Short entry + name: 'Entry', + type: 'scatter', + symbol: 'triangle', + symbolRotate: 180, + symbolSize: 10, + xAxisIndex: 0, + yAxisIndex: 0, + itemStyle: { + color: shortEntrySignalColor, + }, + tooltip: { + // Hide tooltip - it's already there for longs. + // show: false, + }, + encode: { + x: colDate, + y: colShortEntryData, + }, + }); + } + if (colShortExitData >= 0) { + options.series.push({ + // Short exit + name: 'Exit', + type: 'scatter', + symbol: 'pin', + symbolSize: 8, + xAxisIndex: 0, + yAxisIndex: 0, + itemStyle: { + color: shortexitSignalColor, + }, + tooltip: { + // Hide tooltip - it's already there for longs. + // show: false, + }, + encode: { + x: colDate, + y: colShortExitData, + }, + }); + } + } + + // Merge this into original data + Object.assign(chartOptions.value, options); + + if ('main_plot' in props.plotConfig) { + Object.entries(props.plotConfig.main_plot).forEach(([key, value]) => { + const col = props.dataset.columns.findIndex((el) => el === key); + if (col > 1) { + if (!Array.isArray(chartOptions.value?.legend) && chartOptions.value?.legend?.data) { + chartOptions.value.legend.data.push(key); + } + const sp: SeriesOption = { + name: key, + type: value.type || 'line', + xAxisIndex: 0, + yAxisIndex: 0, + itemStyle: { + color: value.color, + }, + encode: { + x: colDate, + y: col, + }, + showSymbol: false, + }; + if (Array.isArray(chartOptions.value?.series)) { + chartOptions.value?.series.push(sp); + } + } else { + console.log(`element ${key} for main plot not found in columns.`); + } + }); + } + + // START Subplots + if ('subplots' in props.plotConfig) { + let plotIndex = 2; + Object.entries(props.plotConfig.subplots).forEach(([key, value]) => { + // define yaxis + + // Subplots are added from bottom to top - only the "bottom-most" plot stays at the bottom. + // const currGridIdx = totalSubplots - plotIndex > 1 ? totalSubplots - plotIndex : plotIndex; + const currGridIdx = plotIndex; + if ( + Array.isArray(chartOptions.value.yAxis) && + chartOptions.value.yAxis.length <= plotIndex + ) { + chartOptions.value.yAxis.push({ + scale: true, + gridIndex: currGridIdx, + name: key, + nameLocation: 'middle', + nameGap: NAMEGAP, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }); + } + if ( + Array.isArray(chartOptions.value.xAxis) && + chartOptions.value.xAxis.length <= plotIndex + ) { + chartOptions.value.xAxis.push({ + type: 'time', + scale: true, + gridIndex: currGridIdx, + boundaryGap: false, + axisLine: { onZero: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + axisPointer: { + label: { show: false }, + }, + splitLine: { show: false }, + splitNumber: 20, + }); + } + if (chartOptions.value.grid && Array.isArray(chartOptions.value.grid)) { + chartOptions.value.grid.push({ + left: MARGINLEFT, + right: MARGINRIGHT, + bottom: `${(subplotCount - plotIndex + 1) * SUBPLOTHEIGHT}%`, + height: `${SUBPLOTHEIGHT}%`, + }); + } + Object.entries(value).forEach(([sk, sv]) => { + // entries per subplot + const col = props.dataset.columns.findIndex((el) => el === sk); + if (col > 0) { + if (!Array.isArray(chartOptions.value.legend) && chartOptions.value.legend?.data) { + chartOptions.value.legend.data.push(sk); + } + const sp: SeriesOption = { + name: sk, + type: sv.type || 'line', + xAxisIndex: plotIndex, + yAxisIndex: plotIndex, + itemStyle: { + color: sv.color || randomColor(), + }, + encode: { + x: colDate, + y: col, + }, + showSymbol: false, + }; + if (chartOptions.value.series && Array.isArray(chartOptions.value.series)) { + chartOptions.value.series.push(sp); + } + } else { + console.log(`element ${sk} was not found in the columns.`); + } + }); + + plotIndex += 1; + }); + } + if (Array.isArray(chartOptions.value.grid)) { + // Last subplot is bottom + chartOptions.value.grid[chartOptions.value.grid.length - 1].bottom = '50px'; + delete chartOptions.value.grid[chartOptions.value.grid.length - 1].top; + } + const { tradeData } = getTradeEntries(props.dataset, filteredTrades.value); + + const nameTrades = 'Trades'; + if (!Array.isArray(chartOptions.value.legend) && chartOptions.value.legend?.data) { + chartOptions.value.legend.data.push(nameTrades); + } + const tradesSeries: ScatterSeriesOption = { + name: nameTrades, + type: 'scatter', + xAxisIndex: 0, + yAxisIndex: 0, + encode: { + x: 0, + y: 1, + label: 5, + tooltip: 6, + }, + label: { + show: false, + fontSize: 12, + backgroundColor: props.theme !== 'dark' ? '#fff' : '#000', + padding: 2, + color: props.theme === 'dark' ? '#fff' : '#000', + }, + labelLayout: { rotate: 75, align: 'left', dx: 10 }, + itemStyle: { + // color: tradeSellColor, + color: (v) => v.data[4], + opacity: 0.9, + }, + symbol: (v) => v[2], + symbolRotate: (v) => v[3], + symbolSize: 13, + data: tradeData, + }; + if (Array.isArray(chartOptions.value.series)) { + chartOptions.value.series.push(tradesSeries); + } + + // console.log('chartOptions', chartOptions.value); + candleChart.value?.setOption(chartOptions.value, { + replaceMerge: ['series', 'grid', 'yAxis', 'xAxis', 'legend'], + noMerge: !initial, + }); + }; + + const initializeChartOptions = () => { + // Ensure we start empty. + candleChart.value?.setOption({}, { noMerge: true }); + + chartOptions.value = { + backgroundColor: 'rgba(0, 0, 0, 0)', + useUTC: props.useUTC, + animation: false, + axisPointer: { + link: [{ xAxisIndex: 'all' }], + label: { + backgroundColor: '#777', + }, + }, + xAxis: [ + { + type: 'time', + scale: true, + boundaryGap: false, + axisLine: { onZero: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + axisPointer: { + label: { show: false }, + }, + position: 'top', + splitLine: { show: false }, + splitNumber: 20, + min: 'dataMin', + max: 'dataMax', + }, + { + type: 'time', + gridIndex: 1, + scale: true, + boundaryGap: false, + axisLine: { onZero: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + axisPointer: { + label: { show: false }, + }, + splitLine: { show: false }, + splitNumber: 20, + min: 'dataMin', + max: 'dataMax', + }, + ], + yAxis: [ + { + scale: true, + }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + // name: 'volume', + // nameLocation: 'middle', + // position: 'right', + nameGap: NAMEGAP, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisPointer: { + label: { show: false }, + }, + }, + ], + }; + + console.log('Initialized'); + updateChart(true); + }; + + onMounted(() => { + initializeChartOptions(); + }); + watch( + () => props.useUTC, + () => initializeChartOptions(), + ); + watch( + () => props.dataset, + () => updateChart(), + ); + watch( + () => props.plotConfig, + () => initializeChartOptions(), + ); + watch( + () => props.heikinAshi, + () => updateChart(), + ); + return { + candleChart, + buyData, + sellData, + strategy, + pair, + timeframe, + datasetColumns, + hasData, + filteredTrades, + chartTitle, + }; + }, +}); +</script> + +<style scoped> +.chart-wrapper { + width: 100%; + height: 100%; +} +.echarts { + width: 100%; + min-height: 200px; + /* TODO: height calculation is not working correctly - uses min-height for now */ + /* height: 600px; */ + height: 100%; +} +</style> diff --git a/src/components/layout/NavBar.vue b/src/components/layout/NavBar.vue index c29d4872bd..b96d0f6334 100644 --- a/src/components/layout/NavBar.vue +++ b/src/components/layout/NavBar.vue @@ -19,6 +19,7 @@ >Dashboard</router-link > <router-link class="nav-link navbar-nav" to="/graph">Chart</router-link> + <router-link class="nav-link navbar-nav" to="/graph_grid">Grid</router-link> <router-link class="nav-link navbar-nav" to="/logs">Logs</router-link> <router-link v-if="botStore.canRunBacktest" class="nav-link navbar-nav" to="/backtest" >Backtest</router-link diff --git a/src/router/index.ts b/src/router/index.ts index a6dd9b8a52..cdb912c01f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -20,6 +20,11 @@ const routes: Array<RouteRecordRaw> = [ name: 'Freqtrade Graph', component: () => import('@/views/Graphs.vue'), }, + { + path: '/graph_grid', + name: 'Freqtrade Graph Grid', + component: () => import('@/views/GraphsGrid.vue'), + }, { path: '/logs', name: 'Freqtrade Logs', diff --git a/src/views/GraphsGrid.vue b/src/views/GraphsGrid.vue new file mode 100644 index 0000000000..775052e472 --- /dev/null +++ b/src/views/GraphsGrid.vue @@ -0,0 +1,62 @@ +<template> + <div class="d-flex flex-column h-100"> + <div class="graphs-grid"> + <div v-for="pair in botStore.activeBot.whitelist" class="grid"> + <CandleChartContainerSimple + :pair="pair" + :historic-view="botStore.activeBot.isWebserverMode" + :timeframe="botStore.activeBot.timeframe" + :trades="botStore.activeBot.trades" + :timerange="botStore.activeBot.isWebserverMode ? timerange : ''" + :strategy="botStore.activeBot.isWebserverMode ? strategy : ''" + :plot-config-modal="false" + > + </CandleChartContainerSimple> + </div> + </div> + </div> +</template> + +<script lang="ts"> +import CandleChartContainerSimple from '@/components/charts/CandleChartContainerSimple.vue'; +import { defineComponent, onMounted, ref } from 'vue'; +import { useBotStore } from '@/stores/ftbotwrapper'; + +export default defineComponent({ + name: 'GraphsGrid', + components: { + CandleChartContainerSimple, + }, + setup() { + const botStore = useBotStore(); + const strategy = ref(''); + const timerange = ref(''); + const selectedTimeframe = ref(''); + + onMounted(() => { + botStore.activeBot.getWhitelist(); + }); + + return { + botStore, + strategy, + timerange, + selectedTimeframe, + }; + }, +}); +</script> + +<style lang="scss" scoped> + .graphs-grid { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + + .grid { + width: 50%; + height: 50vh; + box-sizing: border-box; + } + } +</style>