Merge branch 'main' into pr/qiweiii/1363

This commit is contained in:
Matthias 2023-09-14 20:13:09 +02:00
commit e39c436b67
61 changed files with 2176 additions and 1402 deletions

View File

@ -30,6 +30,7 @@ module.exports = {
// disable eslint no-shadow as it's causing false positives on typescript enums // disable eslint no-shadow as it's causing false positives on typescript enums
'no-shadow': 'off', 'no-shadow': 'off',
'prettier/prettier': ['error'], 'prettier/prettier': ['error'],
'@typescript-eslint/no-explicit-any': 'warn',
// '@typescript-eslint/naming-convention': [ // '@typescript-eslint/naming-convention': [
// 'error', // 'error',
// { // {

View File

@ -7,6 +7,11 @@ assignees: ''
--- ---
**FreqUI Version**
- Version of freqUI: _____ (Available in the top right corner of the UI)
- version of freqtrade: _____ (`freqtrade -V` or `docker compose run --rm freqtrade -V` for Freqtrade running in docker)
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.

View File

@ -23,7 +23,7 @@ updates:
day: "wednesday" day: "wednesday"
time: "03:00" time: "03:00"
timezone: "UTC" timezone: "UTC"
open-pull-requests-limit: 10 open-pull-requests-limit: 20
target-branch: main target-branch: main
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"

View File

@ -21,10 +21,10 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ ubuntu-22.04 ] os: [ ubuntu-22.04 ]
node: [ "16", "18", "19", "20"] node: [ "16", "18", "20"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
@ -42,7 +42,7 @@ jobs:
run: yarn test:unit run: yarn test:unit
- name: Run Component tests - name: Run Component tests
uses: cypress-io/github-action@v5 uses: cypress-io/github-action@v6
with: with:
# we have already installed everything # we have already installed everything
install: false install: false
@ -50,7 +50,7 @@ jobs:
command: yarn cypress run --component command: yarn cypress run --component
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v5 uses: cypress-io/github-action@v6
with: with:
# build: yarn build # build: yarn build
start: yarn serve --host start: yarn serve --host

View File

@ -1,4 +1,4 @@
FROM node:20.4.0-alpine as ui-builder FROM node:20.6.1-alpine as ui-builder
RUN mkdir /app RUN mkdir /app
@ -19,7 +19,7 @@ COPY . /app
# webpack and node17 # webpack and node17
RUN NODE_OPTIONS=--openssl-legacy-provider yarn build RUN NODE_OPTIONS=--openssl-legacy-provider yarn build
FROM nginx:1.25.1-alpine FROM nginx:1.25.2-alpine
COPY --from=ui-builder /app/dist /etc/nginx/html COPY --from=ui-builder /app/dist /etc/nginx/html
COPY --from=ui-builder /app/nginx.conf /etc/nginx/nginx.conf COPY --from=ui-builder /app/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80 EXPOSE 80

View File

@ -9,6 +9,9 @@ function tradeMocks() {
cy.intercept('GET', '**/api/v1/blacklist', { fixture: 'blacklist.json' }).as('Blacklist'); cy.intercept('GET', '**/api/v1/blacklist', { fixture: 'blacklist.json' }).as('Blacklist');
cy.intercept('GET', '**/api/v1/locks', { fixture: 'locks_empty.json' }).as('Locks'); cy.intercept('GET', '**/api/v1/locks', { fixture: 'locks_empty.json' }).as('Locks');
cy.intercept('GET', '**/api/v1/performance', { fixture: 'performance.json' }).as('Performance'); cy.intercept('GET', '**/api/v1/performance', { fixture: 'performance.json' }).as('Performance');
cy.intercept('POST', '**/api/v1/reload_config', { fixture: 'reload_config.json' }).as(
'ReloadConfig',
);
} }
describe('Trade', () => { describe('Trade', () => {
@ -60,5 +63,18 @@ describe('Trade', () => {
cy.get('.drag-header').contains('Closed Trades').scrollIntoView().should('be.visible'); cy.get('.drag-header').contains('Closed Trades').scrollIntoView().should('be.visible');
cy.get('span').contains('TRX/USDT').should('be.visible'); cy.get('span').contains('TRX/USDT').should('be.visible');
cy.get('td').contains('8070.5').should('be.visible'); cy.get('td').contains('8070.5').should('be.visible');
// Scroll to top
cy.contains('Multi Pane').scrollIntoView().should('be.visible');
cy.get('button[title*="Reload Config "]').click();
// Reload Modal open
cy.get('.modal-dialog > .modal-content > .modal-footer > .btn-primary')
.filter(':visible')
.contains('Ok')
.should('be.visible')
.click();
// Alert is visible
cy.contains('Config reloaded successfully.').scrollIntoView().should('be.visible');
}); });
}); });

View File

@ -6,6 +6,13 @@
"progress": 1.0, "progress": 1.0,
"trade_count": null, "trade_count": null,
"backtest_result": { "backtest_result": {
"metadata": {
"SampleStrategy": {
"strategy": "SampleStrategy",
"run_id": "0afc3cf414b6da90210f458078a6b06055b0c4e7",
"filename": "backtest-result-2023-08-03_04-02-20"
}
},
"strategy": { "strategy": {
"SampleStrategy": { "SampleStrategy": {
"trades": [ "trades": [

View File

@ -19,6 +19,7 @@
"closed_trade_count": 149, "closed_trade_count": 149,
"first_trade_date": "a year ago", "first_trade_date": "a year ago",
"first_trade_timestamp": 1626021644985, "first_trade_timestamp": 1626021644985,
"bot_start_timestamp": 1625021644985,
"latest_trade_date": "5 days ago", "latest_trade_date": "5 days ago",
"latest_trade_timestamp": 1661828800529, "latest_trade_timestamp": 1661828800529,
"avg_duration": "7:59:54", "avg_duration": "7:59:54",

View File

@ -0,0 +1,3 @@
{
"status": "Config reloaded successfully."
}

View File

@ -17,55 +17,55 @@
}, },
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@vuepic/vue-datepicker": "^5.4.0", "@vuepic/vue-datepicker": "^6.1.0",
"@vueuse/core": "^10.2.1", "@vueuse/core": "^10.4.1",
"@vueuse/integrations": "^10.2.1", "@vueuse/integrations": "^10.4.1",
"axios": "^1.4.0", "axios": "^1.5.0",
"bootstrap": "^5.3.0", "bootstrap": "^5.3.1",
"bootstrap-vue-next": "^0.8.13", "bootstrap-vue-next": "^0.12.3",
"core-js": "^3.31.1", "core-js": "^3.32.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0", "date-fns-tz": "^2.0.0",
"echarts": "^5.4.2", "echarts": "^5.4.3",
"favico.js": "^0.3.10", "favico.js": "^0.3.10",
"humanize-duration": "^3.29.0", "humanize-duration": "^3.29.0",
"pinia": "^2.1.4", "pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.1.0", "pinia-plugin-persistedstate": "^3.2.0",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"vue": "^3.3.2", "vue": "^3.3.2",
"vue-class-component": "^7.2.5", "vue-class-component": "^7.2.5",
"vue-demi": "^0.14.1", "vue-demi": "^0.14.6",
"vue-echarts": "^6.6.0", "vue-echarts": "^6.6.1",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
"vue-select": "^4.0.0-beta.6", "vue-select": "^4.0.0-beta.6",
"vue3-drr-grid-layout": "^1.9.7" "vue3-drr-grid-layout": "^1.9.7"
}, },
"devDependencies": { "devDependencies": {
"@cypress/vite-dev-server": "^5.0.5", "@cypress/vite-dev-server": "^5.0.6",
"@cypress/vue": "^5.0.5", "@cypress/vue": "^6.0.0",
"@iconify-json/mdi": "^1.1.53", "@iconify-json/mdi": "^1.1.54",
"@types/echarts": "^4.9.18", "@types/echarts": "^4.9.18",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^6.7.0",
"@vitejs/plugin-vue": "^4.2.3", "@vitejs/plugin-vue": "^4.3.4",
"@vue/compiler-sfc": "3.3.4", "@vue/compiler-sfc": "3.3.4",
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/runtime-dom": "^3.3.4", "@vue/runtime-dom": "^3.3.4",
"@vue/test-utils": "^2.4.0", "@vue/test-utils": "^2.4.1",
"cypress": "^12.17.1", "cypress": "^13.2.0",
"eslint": "^8.44.0", "eslint": "^8.49.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.17.0",
"mutationobserver-shim": "^0.3.7", "mutationobserver-shim": "^0.3.7",
"portal-vue": "^3.0.0", "portal-vue": "^3.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.3",
"sass": "^1.63.6", "sass": "^1.66.1",
"typescript": "~5.1.6", "typescript": "~5.2.2",
"unplugin-icons": "^0.16.3", "unplugin-icons": "^0.17.0",
"unplugin-vue-components": "^0.25.1", "unplugin-vue-components": "^0.25.2",
"vite": "^4.4.3", "vite": "^4.4.9",
"vitest": "^0.33.0", "vitest": "^0.34.4",
"vue-tsc": "^1.8.4" "vue-tsc": "^1.8.11"
} }
} }

View File

@ -24,15 +24,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
import { BotDescriptor } from '@/types'; import { BotDescriptor } from '@/types';
import { ref } from 'vue'; import { onMounted, ref } from 'vue';
const props = defineProps({ const props = defineProps({
bot: { type: Object as () => BotDescriptor, required: true }, bot: { type: Object as () => BotDescriptor, required: true },
}); });
const emit = defineEmits(['cancelled', 'saved']); const emit = defineEmits(['cancelled', 'saved']);
const botStore = useBotStore(); const botStore = useBotStore();
const newName = ref<string>('');
const newName = ref<string>(props.bot.botName); onMounted(() => {
newName.value = props.bot.botName;
});
const save = () => { const save = () => {
botStore.updateBot(props.bot.botId, { botStore.updateBot(props.bot.botId, {

View File

@ -1,31 +1,34 @@
<template> <template>
<e-charts <e-charts
v-if="currencies" v-if="currencies"
ref="balanceChart"
:option="balanceChartOptions" :option="balanceChartOptions"
:theme="settingsStore.chartTheme" :theme="settingsStore.chartTheme"
:style="{ height: width * 0.6 + 'px' }"
autoresize autoresize
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ECharts from 'vue-echarts';
import { EChartsOption } from 'echarts'; import { EChartsOption } from 'echarts';
import ECharts from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts'; import { PieChart } from 'echarts/charts';
import { LabelLayout } from 'echarts/features';
import { import {
DatasetComponent, DatasetComponent,
LegendComponent, LegendComponent,
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { use } from 'echarts/core';
import { LabelLayout } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import { BalanceValues } from '@/types';
import { formatPriceCurrency } from '@/shared/formatters'; import { formatPriceCurrency } from '@/shared/formatters';
import { computed } from 'vue';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { BalanceValues } from '@/types';
import { useElementSize } from '@vueuse/core';
import { computed, ref } from 'vue';
use([ use([
PieChart, PieChart,
@ -37,6 +40,9 @@ use([
LabelLayout, LabelLayout,
]); ]);
const balanceChart = ref(null);
const { width } = useElementSize(balanceChart);
const props = defineProps({ const props = defineProps({
currencies: { required: true, type: Array as () => BalanceValues[] }, currencies: { required: true, type: Array as () => BalanceValues[] },
showTitle: { required: false, type: Boolean }, showTitle: { required: false, type: Boolean },
@ -93,8 +99,6 @@ const balanceChartOptions = computed((): EChartsOption => {
<style lang="scss" scoped> <style lang="scss" scoped>
.echarts { .echarts {
width: 100%; min-height: 20px;
height: 100%;
min-height: 240px;
} }
</style> </style>

View File

@ -37,6 +37,9 @@ import {
TooltipComponent, TooltipComponent,
VisualMapComponent, VisualMapComponent,
VisualMapPiecewiseComponent, VisualMapPiecewiseComponent,
// MarkAreaComponent,
MarkLineComponent,
// MarkPointComponent,
} from 'echarts/components'; } from 'echarts/components';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
@ -54,6 +57,9 @@ use([
TooltipComponent, TooltipComponent,
VisualMapComponent, VisualMapComponent,
VisualMapPiecewiseComponent, VisualMapPiecewiseComponent,
// MarkAreaComponent,
MarkLineComponent,
// MarkPointComponent,
CandlestickChart, CandlestickChart,
BarChart, BarChart,
@ -135,7 +141,8 @@ function updateChart(initial = false) {
if (chartOptions.value?.title) { if (chartOptions.value?.title) {
chartOptions.value.title[0].text = chartTitle.value; chartOptions.value.title[0].text = chartTitle.value;
} }
const columns = props.dataset.columns; // Avoid mutation of dataset.columns array
const columns = props.dataset.columns.slice();
const colDate = columns.findIndex((el) => el === '__date_ts'); const colDate = columns.findIndex((el) => el === '__date_ts');
const colOpen = columns.findIndex((el) => el === 'open'); const colOpen = columns.findIndex((el) => el === 'open');

View File

@ -18,22 +18,24 @@
<b-button class="ms-2" :disabled="!!!pair" size="sm" @click="refresh"> <b-button class="ms-2" :disabled="!!!pair" size="sm" @click="refresh">
<i-mdi-refresh /> <i-mdi-refresh />
</b-button> </b-button>
<small v-if="dataset" class="ms-2 text-nowrap" title="Long entry signals" <div class="d-flex flex-row flex-wrap">
>Long signals: {{ dataset.enter_long_signals || dataset.buy_signals }}</small <small v-if="dataset" class="ms-2 text-nowrap" title="Long entry signals"
> >Long signals: {{ dataset.enter_long_signals || dataset.buy_signals }}</small
<small v-if="dataset" class="ms-2 text-nowrap" title="Long exit signals" >
>Long exit: {{ dataset.exit_long_signals || dataset.sell_signals }}</small <small v-if="dataset" class="ms-2 text-nowrap" title="Long exit signals"
> >Long exit: {{ dataset.exit_long_signals || dataset.sell_signals }}</small
<small v-if="dataset && dataset.enter_short_signals" class="ms-2 text-nowrap" >
>Short entries: {{ dataset.enter_short_signals }}</small <small v-if="dataset && dataset.enter_short_signals" class="ms-2 text-nowrap"
> >Short entries: {{ dataset.enter_short_signals }}</small
<small v-if="dataset && dataset.exit_short_signals" class="ms-2 text-nowrap" >
>Short exits: {{ dataset.exit_short_signals }}</small <small v-if="dataset && dataset.exit_short_signals" class="ms-2 text-nowrap"
> >Short exits: {{ dataset.exit_short_signals }}</small
>
</div>
</div> </div>
<div class="ms-auto d-flex align-items-center w-auto"> <div class="ms-auto d-flex align-items-center w-auto">
<b-form-checkbox v-model="settingsStore.useHeikinAshiCandles" <b-form-checkbox v-model="settingsStore.useHeikinAshiCandles"
>Heikin Ashi</b-form-checkbox ><span class="text-nowrap">Heikin Ashi</span></b-form-checkbox
> >
<div class="ms-2"> <div class="ms-2">
@ -121,7 +123,7 @@ const botStore = useBotStore();
const plotStore = usePlotConfigStore(); const plotStore = usePlotConfigStore();
const pair = ref(''); const pair = ref('');
const showPlotConfig = ref(props.plotConfigModal); const showPlotConfig = ref<boolean>();
const dataset = computed((): PairHistory => { const dataset = computed((): PairHistory => {
if (props.historicView) { if (props.historicView) {
@ -159,14 +161,16 @@ const noDatasetText = computed((): string => {
} }
}); });
const showPlotConfigModal = ref(false); const showPlotConfigModal = ref(false);
const showConfigurator = () => {
function showConfigurator() {
if (props.plotConfigModal) { if (props.plotConfigModal) {
showPlotConfigModal.value = !showPlotConfigModal.value; showPlotConfigModal.value = !showPlotConfigModal.value;
} else { } else {
showPlotConfig.value = !showPlotConfig.value; showPlotConfig.value = !showPlotConfig.value;
} }
}; }
const refresh = () => {
function refresh() {
console.log('refresh', pair.value, props.timeframe); console.log('refresh', pair.value, props.timeframe);
if (pair.value && props.timeframe) { if (pair.value && props.timeframe) {
if (props.historicView) { if (props.historicView) {
@ -181,11 +185,10 @@ const refresh = () => {
botStore.activeBot.getPairCandles({ botStore.activeBot.getPairCandles({
pair: pair.value, pair: pair.value,
timeframe: props.timeframe, timeframe: props.timeframe,
limit: 500,
}); });
} }
} }
}; }
watch( watch(
() => props.availablePairs, () => props.availablePairs,
@ -206,6 +209,7 @@ watch(
); );
onMounted(() => { onMounted(() => {
showPlotConfig.value = props.plotConfigModal;
if (botStore.activeBot.selectedPair) { if (botStore.activeBot.selectedPair) {
pair.value = botStore.activeBot.selectedPair; pair.value = botStore.activeBot.selectedPair;
} else if (props.availablePairs.length > 0) { } else if (props.availablePairs.length > 0) {

View File

@ -56,7 +56,6 @@ const props = defineProps({
}); });
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
// const botList = ref<string[]>([]); // const botList = ref<string[]>([]);
// const cumulativeData = ref<{ date: number; profit: any }[]>([]);
const chart = ref<typeof ECharts>(); const chart = ref<typeof ECharts>();
@ -107,20 +106,26 @@ const cumulativeData = computed<CumProfitChartData[]>(() => {
}, },
); );
if (props.openTrades.length > 0 && valueArray.length > 0) { if (props.openTrades.length > 0) {
const lastPoint = valueArray[valueArray.length - 1]; let lastProfit = 0;
if (lastPoint) { let lastDate = 0;
const resultWitHOpen = (lastPoint.profit ?? 0) + openProfit.value; if (valueArray.length > 0) {
valueArray.push({ date: lastPoint.date, currentProfit: lastPoint.profit }); const lastPoint = valueArray[valueArray.length - 1];
// Add one day to date to ensure it's showing properly lastProfit = lastPoint.profit ?? 0;
const tomorrow = Date.now() + 24 * 60 * 60 * 1000; lastDate = lastPoint.date ?? 0;
valueArray.push({ date: tomorrow, currentProfit: resultWitHOpen }); } else {
lastDate = props.openTrades[0].open_timestamp;
} }
const resultWitHOpen = (lastProfit ?? 0) + openProfit.value;
valueArray.push({ date: lastDate, currentProfit: lastProfit });
// Add one day to date to ensure it's showing properly
const tomorrow = Date.now() + 24 * 60 * 60 * 1000;
valueArray.push({ date: tomorrow, currentProfit: resultWitHOpen });
} }
return valueArray; return valueArray;
}); });
function updateChart(initial = false) { function generateChart(initial = false) {
const { colorProfit, colorLoss } = settingsStore; const { colorProfit, colorLoss } = settingsStore;
const chartOptionsLoc: EChartsOption = { const chartOptionsLoc: EChartsOption = {
dataset: { dataset: {
@ -167,7 +172,6 @@ function updateChart(initial = false) {
}, },
], ],
}; };
// TODO: maybe have profit lines per bot? // TODO: maybe have profit lines per bot?
// this.botList.forEach((botId: string) => { // this.botList.forEach((botId: string) => {
// console.log('bot', botId); // console.log('bot', botId);
@ -184,6 +188,10 @@ function updateChart(initial = false) {
// // symbol: 'none', // // symbol: 'none',
// }); // });
// }); // });
return chartOptionsLoc;
}
function updateChart(initial = false) {
const chartOptionsLoc = generateChart(initial);
chart.value?.setOption(chartOptionsLoc, { chart.value?.setOption(chartOptionsLoc, {
replaceMerge: ['series', 'dataset'], replaceMerge: ['series', 'dataset'],
noMerge: !initial, noMerge: !initial,
@ -256,6 +264,11 @@ function initializeChart() {
}, },
], ],
}; };
const chartOptionsLoc1 = generateChart(true);
// Merge the series and dataset, but not the rest
chartOptionsLoc.series = chartOptionsLoc1.series;
chartOptionsLoc.dataset = chartOptionsLoc1.dataset;
chart.value?.setOption(chartOptionsLoc, { noMerge: true }); chart.value?.setOption(chartOptionsLoc, { noMerge: true });
updateChart(true); updateChart(true);
} }

View File

@ -36,7 +36,7 @@
v-model="fillTo" v-model="fillTo"
:columns="columns" :columns="columns"
class="mt-1" class="mt-1"
label="Select indicator to add" label="Area chart - Fill to (leave empty for line chart)"
/> />
</div> </div>
</template> </template>

View File

@ -17,7 +17,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import vSelect from 'vue-select'; import vSelect from 'vue-select';
const props = defineProps({ const props = defineProps({
@ -27,7 +27,7 @@ const props = defineProps({
}); });
const emit = defineEmits(['update:modelValue', 'indicatorSelected']); const emit = defineEmits(['update:modelValue', 'indicatorSelected']);
const selAvailableIndicator = ref(props.modelValue || ''); const selAvailableIndicator = ref('');
function emitIndicator() { function emitIndicator() {
emit('indicatorSelected', selAvailableIndicator.value); emit('indicatorSelected', selAvailableIndicator.value);
@ -37,6 +37,11 @@ function abort() {
selAvailableIndicator.value = ''; selAvailableIndicator.value = '';
emitIndicator(); emitIndicator();
} }
onMounted(() => {
selAvailableIndicator.value = props.modelValue;
});
watch( watch(
() => props.modelValue, () => props.modelValue,
(newValue) => { (newValue) => {

View File

@ -1,14 +1,16 @@
<template> <template>
<e-charts <e-charts
v-if="dailyStats.data" v-if="dailyStats.data"
ref="dailyChart"
:option="dailyChartOptions" :option="dailyChartOptions"
:theme="settingsStore.chartTheme" :theme="settingsStore.chartTheme"
:style="{ height: width * 0.6 + 'px' }"
autoresize autoresize
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ComputedRef } from 'vue'; import { computed, ComputedRef, ref } from 'vue';
import ECharts from 'vue-echarts'; import ECharts from 'vue-echarts';
// import { EChartsOption } from 'echarts'; // import { EChartsOption } from 'echarts';
@ -24,9 +26,10 @@ import {
VisualMapComponent, VisualMapComponent,
} from 'echarts/components'; } from 'echarts/components';
import { DailyReturnValue } from '@/types'; import { TimeSummaryReturnValue } from '@/types';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { EChartsOption } from 'echarts'; import { EChartsOption } from 'echarts';
import { useElementSize } from '@vueuse/core';
use([ use([
BarChart, BarChart,
@ -46,7 +49,7 @@ const CHART_TRADE_COUNT = 'Trade Count';
const props = defineProps({ const props = defineProps({
dailyStats: { dailyStats: {
type: Object as () => DailyReturnValue, type: Object as () => TimeSummaryReturnValue,
required: true, required: true,
}, },
showTitle: { showTitle: {
@ -56,6 +59,10 @@ const props = defineProps({
}); });
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const dailyChart = ref(null);
const { width } = useElementSize(dailyChart);
const absoluteMin = computed(() => const absoluteMin = computed(() =>
props.dailyStats.data.reduce( props.dailyStats.data.reduce(
(min, p) => (p.abs_profit < min ? p.abs_profit : min), (min, p) => (p.abs_profit < min ? p.abs_profit : min),
@ -125,7 +132,7 @@ const dailyChartOptions: ComputedRef<EChartsOption> = computed(() => {
}, },
nameRotate: 90, nameRotate: 90,
nameLocation: 'middle', nameLocation: 'middle',
nameGap: 40, nameGap: 35,
}, },
{ {
type: 'value', type: 'value',
@ -156,8 +163,6 @@ const dailyChartOptions: ComputedRef<EChartsOption> = computed(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.echarts { .echarts {
width: 100%;
height: 100%;
min-height: 240px; min-height: 240px;
} }
</style> </style>

View File

@ -12,32 +12,64 @@
Load Historic results from disk. You can click on multiple results to load all of them into Load Historic results from disk. You can click on multiple results to load all of them into
freqUI. freqUI.
</p> </p>
<b-list-group v-if="botStore.activeBot.backtestHistoryList" class="ms-2"> <b-list-group v-if="botStore.activeBot.backtestHistoryList" class="ms-2 mb-1">
<b-list-group-item <b-list-group-item
v-for="(res, idx) in botStore.activeBot.backtestHistoryList" v-for="(res, idx) in botStore.activeBot.backtestHistoryList"
:key="idx" :key="idx"
class="d-flex justify-content-between align-items-center py-1 mb-1" class="d-flex justify-content-between align-items-center py-1 pe-2"
button button
:disabled="res.run_id in botStore.activeBot.backtestHistory"
@click="botStore.activeBot.getBacktestHistoryResult(res)" @click="botStore.activeBot.getBacktestHistoryResult(res)"
> >
<strong>{{ res.strategy }}</strong> <strong>{{ res.strategy }}</strong>
backtested on: {{ timestampms(res.backtest_start_time * 1000) }} backtested on: {{ timestampms(res.backtest_start_time * 1000) }}
<small>{{ res.filename }}</small> <small>{{ res.filename }}</small>
<InfoBox
v-if="botStore.activeBot.botApiVersion >= 2.32"
:class="res.notes ? 'opacity-100' : 'opacity-0'"
:hint="res.notes ?? ''"
></InfoBox>
<b-button
v-if="botStore.activeBot.botApiVersion >= 2.31"
class="ms-1"
size="sm"
title="Delete this Result."
:disabled="res.run_id in botStore.activeBot.backtestHistory"
@click.stop="deleteBacktestResult(res)"
>
<i-mdi-delete />
</b-button>
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</div> </div>
<MessageBox ref="msgBox" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { onMounted, ref } from 'vue';
import MessageBox, { MsgBoxObject } from '@/components/general/MessageBox.vue';
import { timestampms } from '@/shared/formatters'; import { timestampms } from '@/shared/formatters';
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
import { BacktestHistoryEntry } from '@/types';
import InfoBox from '../general/InfoBox.vue';
const botStore = useBotStore(); const botStore = useBotStore();
const msgBox = ref<typeof MessageBox>();
onMounted(() => { onMounted(() => {
botStore.activeBot.getBacktestHistory(); botStore.activeBot.getBacktestHistory();
}); });
function deleteBacktestResult(result: BacktestHistoryEntry) {
const msg: MsgBoxObject = {
title: 'Stop Bot',
message: `Delete result ${result.filename} from disk?`,
accept: () => {
botStore.activeBot.deleteBacktestHistoryResult(result);
},
};
msgBox.value?.show(msg);
}
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="container-fluid px-0 backtestresult-container"> <div class="px-0 mw-100">
<div class="row d-flex justify-content-center"> <div class="d-flex justify-content-center">
<h3>Backtest-result for {{ backtestResult.strategy_name }}</h3> <h3>Backtest-result for {{ backtestResult.strategy_name }}</h3>
</div> </div>
<div class="row text-start ms-0"> <div class="d-flex flex-column text-start ms-0 me-2 gap-2">
<div class="row w-100"> <div class="d-flex flex-column flex-xl-row">
<div class="col-12 col-xl-6 px-0 px-xl-0 pe-xl-1"> <div class="px-0 px-xl-0 pe-xl-1 flex-fill">
<b-card header="Strategy settings"> <b-card header="Strategy settings">
<b-table <b-table
small small
@ -17,14 +17,14 @@
</b-table> </b-table>
</b-card> </b-card>
</div> </div>
<div class="col-12 col-xl-6 px-0 px-xl-0 pt-2 pt-xl-0 ps-xl-1"> <div class="px-0 px-xl-0 pt-2 pt-xl-0 ps-xl-1 flex-fill">
<b-card header="Metrics"> <b-card header="Metrics">
<b-table small borderless :items="backtestResultStats" :fields="backtestResultFields"> <b-table small borderless :items="backtestResultStats" :fields="backtestResultFields">
</b-table> </b-table>
</b-card> </b-card>
</div> </div>
</div> </div>
<b-card header="Results per Exit-reason" class="row mt-2 w-100"> <b-card header="Results per Exit-reason">
<b-table <b-table
small small
hover hover
@ -37,7 +37,7 @@
> >
</b-table> </b-table>
</b-card> </b-card>
<b-card header="Results per pair" class="row mt-2 w-100"> <b-card header="Results per pair">
<b-table <b-table
small small
hover hover
@ -47,18 +47,13 @@
> >
</b-table> </b-table>
</b-card> </b-card>
<b-card <b-card v-if="backtestResult.periodic_breakdown" header="Periodic breakdown">
v-if="backtestResult.periodic_breakdown"
header="Periodic breakdown"
class="row mt-2 w-100"
>
<BacktestResultPeriodBreakdown :periodic-breakdown="backtestResult.periodic_breakdown"> <BacktestResultPeriodBreakdown :periodic-breakdown="backtestResult.periodic_breakdown">
</BacktestResultPeriodBreakdown> </BacktestResultPeriodBreakdown>
</b-card> </b-card>
<b-card header="Single trades" class="row mt-2 w-100"> <b-card header="Single trades">
<TradeList <TradeList
class="row trade-history mt-2 w-100"
:trades="backtestResult.trades" :trades="backtestResult.trades"
:show-filter="true" :show-filter="true"
:stake-currency="backtestResult.stake_currency" :stake-currency="backtestResult.stake_currency"
@ -79,6 +74,7 @@ import {
formatPercent, formatPercent,
formatPrice, formatPrice,
humanizeDurationFromSeconds, humanizeDurationFromSeconds,
isNotUndefined,
} from '@/shared/formatters'; } from '@/shared/formatters';
import { TableField, TableItem } from 'bootstrap-vue-next'; import { TableField, TableItem } from 'bootstrap-vue-next';
@ -115,6 +111,10 @@ const worstPair = computed((): string => {
return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`; return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`;
}); });
const pairSummary = computed(() => {
return props.backtestResult.results_per_pair[props.backtestResult.results_per_pair.length - 1];
});
const backtestResultStats = computed(() => { const backtestResultStats = computed(() => {
// Transpose Result into readable format // Transpose Result into readable format
const shortMetrics = const shortMetrics =
@ -164,9 +164,16 @@ const backtestResultStats = computed(() => {
value: `${props.backtestResult.calmar ? props.backtestResult.calmar.toFixed(2) : 'N/A'}`, value: `${props.backtestResult.calmar ? props.backtestResult.calmar.toFixed(2) : 'N/A'}`,
}, },
{ {
metric: 'Expectancy', metric: `Expectancy ${props.backtestResult.expectancy_ratio ? '(ratio)' : ''}`,
value: `${ value: `${
props.backtestResult.expectancy ? props.backtestResult.expectancy.toFixed(2) : 'N/A' props.backtestResult.expectancy
? props.backtestResult.expectancy_ratio
? props.backtestResult.expectancy.toFixed(2) +
' (' +
props.backtestResult.expectancy_ratio.toFixed(2) +
')'
: props.backtestResult.expectancy.toFixed(2)
: 'N/A'
}`, }`,
}, },
{ {
@ -198,14 +205,19 @@ const backtestResultStats = computed(() => {
{ {
metric: 'Win/Draw/Loss', metric: 'Win/Draw/Loss',
value: `${ value: `${pairSummary.value.wins} / ${pairSummary.value.draws} / ${
props.backtestResult.results_per_pair[props.backtestResult.results_per_pair.length - 1].wins pairSummary.value.losses
} / ${ } ${
props.backtestResult.results_per_pair[props.backtestResult.results_per_pair.length - 1] isNotUndefined(pairSummary.value.winrate)
.draws ? '(WR: ' +
} / ${ formatPercent(
props.backtestResult.results_per_pair[props.backtestResult.results_per_pair.length - 1] props.backtestResult.results_per_pair[
.losses props.backtestResult.results_per_pair.length - 1
].winrate ?? 0,
2,
) +
')'
: ''
}`, }`,
}, },
{ {
@ -221,6 +233,13 @@ const backtestResultStats = computed(() => {
metric: 'Avg. Duration Losers', metric: 'Avg. Duration Losers',
value: humanizeDurationFromSeconds(props.backtestResult.loser_holding_avg_s), value: humanizeDurationFromSeconds(props.backtestResult.loser_holding_avg_s),
}, },
{
metric: 'Max Consecutive Wins / Loss',
value:
props.backtestResult.max_consecutive_wins === undefined
? 'N/A'
: `${props.backtestResult.max_consecutive_wins} / ${props.backtestResult.max_consecutive_losses}`,
},
{ metric: 'Rejected entry signals', value: props.backtestResult.rejected_signals }, { metric: 'Rejected entry signals', value: props.backtestResult.rejected_signals },
{ {
metric: 'Entry/Exit timeouts', metric: 'Entry/Exit timeouts',
@ -413,10 +432,4 @@ const backtestsettingFields: TableField[] = [
]; ];
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
.backtestresult-container {
@media (min-width: 1200px) {
max-width: 1200px;
}
}
</style>

View File

@ -15,7 +15,7 @@ const periodicBreakdownSelections = [
{ value: 'month', text: 'Months' }, { value: 'month', text: 'Months' },
]; ];
const periodicBreakdownPeriod = ref<string>('day'); const periodicBreakdownPeriod = ref<string>('month');
const periodicBreakdownFields = computed<TableField[]>(() => { const periodicBreakdownFields = computed<TableField[]>(() => {
return [ return [

View File

@ -1,16 +1,56 @@
<template> <template>
<div class="container d-flex flex-column align-items-center"> <div class="container d-flex flex-column align-items-stretch">
<h3>Available results:</h3> <h3>Available results:</h3>
<b-list-group class="ms-2"> <b-list-group class="ms-2">
<b-list-group-item <b-list-group-item
v-for="[key, strat] in Object.entries(backtestHistory)" v-for="[key, result] in Object.entries(backtestHistory)"
:key="key" :key="key"
button button
:active="key === selectedBacktestResultKey" :active="key === selectedBacktestResultKey"
class="d-flex justify-content-between align-items-center py-1" class="d-flex justify-content-between align-items-center py-1 pe-1"
@click="setBacktestResult(key)" @click="setBacktestResult(key)"
> >
{{ key }} {{ strat.total_trades }} {{ formatPercent(strat.profit_total) }} <template v-if="!result.metadata.editing">
<div class="d-flex flex-column me-2 text-start">
<div class="fw-bold">
{{ result.metadata.strategyName }} - {{ result.strategy.timeframe }}
</div>
<div class="text-small">
TradeCount: {{ result.strategy.total_trades }} - Profit:
{{ formatPercent(result.strategy.profit_total) }}
</div>
<div v-if="canUseModify" class="text-small" style="white-space: pre-wrap">
{{ result.metadata.notes }}
</div>
</div>
<div class="d-flex">
<b-button
v-if="canUseModify"
class="flex-nowrap"
size="sm"
title="Modify"
@click.stop="result.metadata.editing = !result.metadata.editing"
>
<i-mdi-pencil />
</b-button>
<b-button
size="sm"
class="flex-nowrap"
title="Delete this Result."
@click.stop="emit('removeResult', key)"
>
<i-mdi-delete />
</b-button>
</div>
</template>
<template v-if="result.metadata.editing">
<b-form-textarea v-model="result.metadata.notes" placeholder="notes" size="sm">
</b-form-textarea>
<b-button size="sm" title="Confirm" @click.stop="confirmInput(key, result)">
<i-mdi-check />
</b-button>
</template>
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</div> </div>
@ -18,19 +58,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatPercent } from '@/shared/formatters'; import { formatPercent } from '@/shared/formatters';
import { StrategyBacktestResult } from '@/types'; import { BacktestResultInMemory, BacktestResultUpdate } from '@/types';
defineProps({ defineProps({
backtestHistory: { backtestHistory: {
required: true, required: true,
type: Object as () => Record<string, StrategyBacktestResult>, type: Object as () => Record<string, BacktestResultInMemory>,
}, },
selectedBacktestResultKey: { required: false, default: '', type: String }, selectedBacktestResultKey: { required: false, default: '', type: String },
canUseModify: { required: false, default: false, type: Boolean },
}); });
const emit = defineEmits(['selectionChange']); const emit = defineEmits<{
const setBacktestResult = (key) => { selectionChange: [value: string];
removeResult: [value: string];
updateResult: [value: BacktestResultUpdate];
}>();
const setBacktestResult = (key: string) => {
emit('selectionChange', key); emit('selectionChange', key);
}; };
function confirmInput(run_id: string, result: BacktestResultInMemory) {
result.metadata.editing = !result.metadata.editing;
if (result.metadata.filename) {
emit('updateResult', {
run_id: run_id,
notes: result.metadata.notes ?? '',
filename: result.metadata.filename,
strategy: result.metadata.strategyName,
});
}
}
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -0,0 +1,258 @@
<template>
<div class="mb-2">
<span>Strategy</span>
<StrategySelect v-model="btStore.strategy"></StrategySelect>
</div>
<b-card :disabled="botStore.activeBot.backtestRunning">
<!-- Backtesting parameters -->
<b-form-group
label-cols-lg="2"
label="Backtest params"
label-size="sm"
label-class="fw-bold pt-0"
class="mb-0"
>
<b-form-group
label-cols-sm="5"
label="Timeframe:"
label-align-sm="right"
label-for="timeframe-select"
>
<TimeframeSelect id="timeframe-select" v-model="btStore.selectedTimeframe" />
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Detail Timeframe:"
label-align-sm="right"
label-for="timeframe-detail-select"
title="Detail timeframe, to simulate intra-candle results. Not setting this will not use this functionality."
>
<TimeframeSelect
id="timeframe-detail-select"
v-model="btStore.selectedDetailTimeframe"
:below-timeframe="btStore.selectedTimeframe"
/>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Max open trades:"
label-align-sm="right"
label-for="max-open-trades"
>
<b-form-input
id="max-open-trades"
v-model="btStore.maxOpenTrades"
placeholder="Use strategy default"
type="number"
></b-form-input>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Starting capital:"
label-align-sm="right"
label-for="starting-capital"
>
<b-form-input
id="starting-capital"
v-model="btStore.startingCapital"
type="number"
step="0.001"
></b-form-input>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Stake amount:"
label-align-sm="right"
label-for="stake-amount"
>
<div class="d-flex">
<b-form-checkbox
id="stake-amount-bool"
v-model="btStore.stakeAmountUnlimited"
class="col-md-6"
>Unlimited stake</b-form-checkbox
>
<b-form-input
id="stake-amount"
v-model="btStore.stakeAmount"
type="number"
placeholder="Use strategy default"
step="0.01"
:disabled="btStore.stakeAmountUnlimited"
></b-form-input>
</div>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Enable Protections:"
label-align-sm="right"
label-for="enable-protections"
>
<b-form-checkbox
id="enable-protections"
v-model="btStore.enableProtections"
></b-form-checkbox>
</b-form-group>
<b-form-group
v-if="botStore.activeBot.botApiVersion >= 2.22"
label-cols-sm="5"
label="Cache Backtest results:"
label-align-sm="right"
label-for="enable-cache"
>
<b-form-checkbox id="enable-cache" v-model="btStore.allowCache"></b-form-checkbox>
</b-form-group>
<template v-if="botStore.activeBot.botApiVersion >= 2.22">
<b-form-group
label-cols-sm="5"
label="Enable FreqAI:"
label-align-sm="right"
label-for="enable-freqai"
>
<template #label>
<div class="d-flex justify-content-center">
<span class="me-2">Enable FreqAI:</span>
<InfoBox
hint="Assumes freqAI configuration is setup in the configuration, and the strategy is a freqAI strategy. Will fail if that's not the case."
/>
</div>
</template>
<b-form-checkbox id="enable-freqai" v-model="btStore.freqAI.enabled"></b-form-checkbox>
</b-form-group>
<b-form-group
v-if="btStore.freqAI.enabled"
label-cols-sm="5"
label="FreqAI identifier:"
label-align-sm="right"
label-for="freqai-identifier"
>
<b-form-input
id="freqai-identifier"
v-model="btStore.freqAI.identifier"
placeholder="Use config default"
></b-form-input>
</b-form-group>
<b-form-group
v-if="btStore.freqAI.enabled"
label-cols-sm="5"
label="FreqAI Model"
label-align-sm="right"
label-for="freqai-model"
>
<FreqaiModelSelect id="freqai-model" v-model="btStore.freqAI.model"></FreqaiModelSelect>
</b-form-group>
</template>
<!-- <b-form-group label-cols-sm="5" label="Fee:" label-align-sm="right" label-for="fee">
<b-form-input
id="fee"
type="number"
placeholder="Use exchange default"
step="0.01"
></b-form-input>
</b-form-group> -->
<hr />
<TimeRangeSelect v-model="btStore.timerange" class="mt-2"></TimeRangeSelect>
</b-form-group>
</b-card>
<h3 class="mt-3">Backtesting summary</h3>
<div class="d-flex flex-wrap flex-md-nowrap justify-content-between justify-content-md-center">
<b-button
id="start-backtest"
variant="primary"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
class="mx-1"
@click="clickBacktest"
>
Start backtest
</b-button>
<b-button
variant="primary"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
class="mx-1"
@click="botStore.activeBot.pollBacktest"
>
Load backtest result
</b-button>
<b-button
variant="primary"
class="mx-1"
:disabled="!botStore.activeBot.backtestRunning"
@click="botStore.activeBot.stopBacktest"
>Stop Backtest</b-button
>
<b-button
variant="primary"
class="mx-1"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
@click="botStore.activeBot.removeBacktest"
>Reset Backtest</b-button
>
</div>
</template>
<script setup lang="ts">
import TimeRangeSelect from '@/components/ftbot/TimeRangeSelect.vue';
import FreqaiModelSelect from '@/components/ftbot/FreqaiModelSelect.vue';
import StrategySelect from '@/components/ftbot/StrategySelect.vue';
import TimeframeSelect from '@/components/ftbot/TimeframeSelect.vue';
import InfoBox from '@/components/general/InfoBox.vue';
import { useBotStore } from '@/stores/ftbotwrapper';
import { BacktestPayload } from '@/types';
import { useBtStore } from '@/stores/btStore';
const botStore = useBotStore();
const btStore = useBtStore();
function clickBacktest() {
const btPayload: BacktestPayload = {
strategy: btStore.strategy,
timerange: btStore.timerange,
enable_protections: btStore.enableProtections,
};
const openTradesInt = parseInt(btStore.maxOpenTrades, 10);
if (openTradesInt) {
btPayload.max_open_trades = openTradesInt;
}
if (btStore.stakeAmountUnlimited) {
btPayload.stake_amount = 'unlimited';
} else {
const stakeAmountLoc = Number(btStore.stakeAmount);
if (stakeAmountLoc) {
btPayload.stake_amount = stakeAmountLoc.toString();
}
}
const startingCapitalLoc = Number(btStore.startingCapital);
if (startingCapitalLoc) {
btPayload.dry_run_wallet = startingCapitalLoc;
}
if (btStore.selectedTimeframe) {
btPayload.timeframe = btStore.selectedTimeframe;
}
if (btStore.selectedDetailTimeframe) {
btPayload.timeframe_detail = btStore.selectedDetailTimeframe;
}
if (!btStore.allowCache) {
btPayload.backtest_cache = 'none';
}
if (btStore.freqAI.enabled) {
btPayload.freqaimodel = btStore.freqAI.model;
if (btStore.freqAI.identifier !== '') {
btPayload.freqai = { identifier: btStore.freqAI.identifier };
}
}
botStore.activeBot.startBacktest(btPayload);
}
</script>
<style scoped></style>

View File

@ -33,23 +33,26 @@
</p> </p>
<b-table class="table-sm" :items="balanceCurrencies" :fields="tableFields"> <b-table class="table-sm" :items="balanceCurrencies" :fields="tableFields">
<template #custom-foot> <template #custom-foot>
<td><strong>Total</strong></td> <td class="pt-1"><strong>Total</strong></td>
<td> <td class="pt-1">
<span <span
class="font-italic" class="font-italic"
:title="`Increase over initial capital of ${formatCurrency( :title="`Increase over initial capital of ${formatCurrency(
botStore.activeBot.balance.starting_capital, botStore.activeBot.balance.starting_capital,
)} ${botStore.activeBot.balance.stake}`" )} ${botStore.activeBot.balance.stake}`"
>{{ formatPercent(botStore.activeBot.balance.starting_capital_ratio) }}</span
> >
{{ formatPercent(botStore.activeBot.balance.starting_capital_ratio) }}
</span>
</td> </td>
<!-- this is a computed prop that adds up all the expenses in the visible rows --> <!-- this is a computed prop that adds up all the expenses in the visible rows -->
<td> <td class="pt-1">
<strong>{{ <strong>
showBotOnly && canUseBotBalance {{
? formatCurrency(botStore.activeBot.balance.total_bot) showBotOnly && canUseBotBalance
: formatCurrency(botStore.activeBot.balance.total) ? formatCurrency(botStore.activeBot.balance.total_bot)
}}</strong> : formatCurrency(botStore.activeBot.balance.total)
}}
</strong>
</td> </td>
</template> </template>
</b-table> </b-table>

View File

@ -12,41 +12,50 @@
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<b-form-checkbox <b-form-checkbox
v-if="row.item.botId && botStore.botCount > 1" v-if="row.item.botId && botStore.botCount > 1"
v-model="botStore.botStores[row.item.botId].isSelected" v-model="
botStore.botStores[(row.item as unknown as ComparisonTableItems).botId ?? ''].isSelected
"
title="Show bot in Dashboard" title="Show bot in Dashboard"
/> />
<span>{{ row.value }}</span> <span>{{ row.value }}</span>
</div> </div>
</template> </template>
<template #cell(profitOpen)="row"> <template #cell(profitOpen)="{ item }">
<profit-pill <profit-pill
v-if="row.item.profitOpen && row.item.botId != 'Summary'" v-if="item.profitOpen && item.botId != 'Summary'"
:profit-ratio="row.item.profitOpenRatio" :profit-ratio="(item as unknown as ComparisonTableItems).profitOpenRatio"
:profit-abs="row.item.profitOpen" :profit-abs="(item as unknown as ComparisonTableItems).profitOpen"
:stake-currency="row.item.stakeCurrency" :stake-currency="(item as unknown as ComparisonTableItems).stakeCurrency"
/> />
</template> </template>
<template #cell(profitClosed)="row"> <template #cell(profitClosed)="{ item }">
<profit-pill <profit-pill
v-if="row.item.profitClosed && row.item.botId != 'Summary'" v-if="item.profitClosed && item.botId != 'Summary'"
:profit-ratio="row.item.profitClosedRatio" :profit-ratio="(item as unknown as ComparisonTableItems).profitClosedRatio"
:profit-abs="row.item.profitClosed" :profit-abs="(item as unknown as ComparisonTableItems).profitClosed"
:stake-currency="row.item.stakeCurrency" :stake-currency="(item as unknown as ComparisonTableItems).stakeCurrency"
/> />
</template> </template>
<template #cell(balance)="row"> <template #cell(balance)="{ item }">
<div v-if="row.item.balance"> <div v-if="item.balance">
<span :title="row.item.stakeCurrency" <span :title="(item as unknown as ComparisonTableItems).stakeCurrency"
>{{ formatPrice(row.item.balance, row.item.stakeCurrencyDecimals) }} >{{
formatPrice(
(item as unknown as ComparisonTableItems).balance ?? 0,
(item as unknown as ComparisonTableItems).stakeCurrencyDecimals,
)
}}
</span> </span>
<span class="text-small">{{ row.item.stakeCurrency }}</span> <span class="text-small">{{
` ${item.stakeCurrency}${item.isDryRun ? ' (dry)' : ''}`
}}</span>
</div> </div>
</template> </template>
<template #cell(winVsLoss)="row"> <template #cell(winVsLoss)="{ item }">
<div v-if="row.item.losses !== undefined"> <div v-if="item.losses !== undefined">
<span class="text-profit">{{ row.item.wins }}</span> / <span class="text-profit">{{ item.wins }}</span> /
<span class="text-loss">{{ row.item.losses }}</span> <span class="text-loss">{{ item.losses }}</span>
</div> </div>
</template> </template>
</b-table> </b-table>
@ -113,13 +122,20 @@ const tableItems = computed<TableItem[]>(() => {
losses: v.losing_trades, losses: v.losing_trades,
balance: botStore.allBalance[k]?.total_bot ?? botStore.allBalance[k]?.total, balance: botStore.allBalance[k]?.total_bot ?? botStore.allBalance[k]?.total,
stakeCurrencyDecimals: botStore.allBotState[k]?.stake_currency_decimals || 3, stakeCurrencyDecimals: botStore.allBotState[k]?.stake_currency_decimals || 3,
isDryRun: botStore.allBotState[k]?.dry_run,
}); });
if (v.profit_closed_coin !== undefined) { if (v.profit_closed_coin !== undefined) {
summary.profitClosed += v.profit_closed_coin; if (botStore.botStores[k].isSelected) {
summary.profitOpen += profitOpen; // Summary should only include selected bots
summary.wins += v.winning_trades; summary.profitClosed += v.profit_closed_coin;
summary.losses += v.losing_trades; summary.profitOpen += profitOpen;
// summary.decimals = this.allBotState[k]?.stake_currency_decimals || summary.decimals; summary.wins += v.winning_trades;
summary.losses += v.losing_trades;
// summary.decimals = this.allBotState[k]?.stake_currency_decimals || summary.decimals;
// This will always take the last bot's stake currency
// And therefore may result in wrong values.
summary.stakeCurrency = botStore.allBotState[k]?.stake_currency || summary.stakeCurrency;
}
} }
}); });
val.push(summary); val.push(summary);

View File

@ -0,0 +1,131 @@
<template>
<b-table class="text-start" small borderless :items="profitItems" :fields="profitFields">
<template #cell(value)="row">
<DateTimeTZ v-if="row.item.isTs && row.value" :date="row.value as number"></DateTimeTZ>
<template v-else>{{ row.value }}</template>
</template>
</b-table>
</template>
<script setup lang="ts">
import { formatPercent, formatPriceCurrency, timestampms } from '@/shared/formatters';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import { ProfitInterface } from '@/types';
import { TableField, TableItem } from 'bootstrap-vue-next';
import { computed } from 'vue';
const props = defineProps({
profit: { required: true, type: Object as () => ProfitInterface },
stakeCurrency: { required: true, type: String },
stakeCurrencyDecimals: { required: true, type: Number },
});
const profitFields: TableField[] = [
{ key: 'metric', label: 'Metric' },
{ key: 'value', label: 'Value' },
];
const profitItems = computed<TableItem[]>(() => {
if (!props.profit) return [];
return [
{
metric: 'ROI open trades',
value: props.profit.profit_closed_coin
? `${formatPriceCurrency(
props.profit.profit_closed_coin,
props.stakeCurrency,
props.stakeCurrencyDecimals,
)} (${formatPercent(props.profit.profit_closed_ratio_mean, 2)})`
: 'N/A',
// (&sum; ${formatPercent(props.profit.profit_closed_ratio_sum, 2,)})`
},
{
metric: 'ROI all trades',
value: props.profit.profit_all_coin
? `${formatPriceCurrency(
props.profit.profit_all_coin,
props.stakeCurrency,
props.stakeCurrencyDecimals,
)} (${formatPercent(props.profit.profit_all_ratio_mean, 2)})`
: 'N/A',
// (&sum; ${formatPercent(props.profit.profit_all_ratio_sum,2,)})`
},
{
metric: 'Total Trade count',
value: `${props.profit.trade_count ?? 0}`,
},
{
metric: 'Bot started',
value: props.profit.bot_start_timestamp,
isTs: true,
},
{
metric: 'First Trade opened',
value: props.profit.first_trade_timestamp,
isTs: true,
},
{
metric: 'Latest Trade opened',
value: props.profit.latest_trade_timestamp,
isTs: true,
},
{
metric: 'Win / Loss',
value: `${props.profit.winning_trades ?? 0} / ${props.profit.losing_trades ?? 0}`,
},
{
metric: 'Winrate',
value: `${props.profit.winrate ? formatPercent(props.profit.winrate) : 'N/A'}`,
},
{
metric: 'Expectancy (ratio)',
value: `${props.profit.expectancy ? props.profit.expectancy.toFixed(2) : 'N/A'} (${
props.profit.expectancy_ratio ? props.profit.expectancy_ratio.toFixed(2) : 'N/A'
})`,
},
{
metric: 'Avg. Duration',
value: `${props.profit.avg_duration ?? 'N/A'}`,
},
{
metric: 'Best performing',
value: props.profit.best_pair
? `${props.profit.best_pair}: ${formatPercent(props.profit.best_pair_profit_ratio, 2)}`
: 'N/A',
},
{
metric: 'Trading volume',
value: `${formatPriceCurrency(
props.profit.trading_volume ?? 0,
props.stakeCurrency,
props.stakeCurrencyDecimals,
)}`,
},
{
metric: 'Profit factor',
value: `${props.profit.profit_factor ? props.profit.profit_factor.toFixed(2) : 'N/A'}`,
},
{
metric: 'Max Drawdown',
value: `${props.profit.max_drawdown ? formatPercent(props.profit.max_drawdown, 2) : 'N/A'} (${
props.profit.max_drawdown_abs
? formatPriceCurrency(
props.profit.max_drawdown_abs,
props.stakeCurrency,
props.stakeCurrencyDecimals,
)
: 'N/A'
}) ${
props.profit.max_drawdown_start_timestamp && props.profit.max_drawdown_end_timestamp
? 'from ' +
timestampms(props.profit.max_drawdown_start_timestamp) +
' to ' +
timestampms(props.profit.max_drawdown_end_timestamp)
: ''
}`,
},
];
});
</script>

View File

@ -76,13 +76,20 @@
}} }}
</span> </span>
</p> </p>
<BotProfit
class="mx-1"
:profit="botStore.activeBot.profit"
:stake-currency="botStore.activeBot.botState.stake_currency"
:stake-currency-decimals="botStore.activeBot.botState.stake_currency_decimals ?? 3"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { formatPercent, formatPriceCurrency } from '@/shared/formatters';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue'; import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import { formatPercent, formatPriceCurrency } from '@/shared/formatters';
import BotProfit from '@/components/ftbot/BotProfit.vue';
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
const botStore = useBotStore(); const botStore = useBotStore();

View File

@ -1,56 +0,0 @@
<template>
<div>
<div class="mb-2">
<label class="me-auto h3">Daily Stats</label>
<b-button class="float-end" size="sm" @click="botStore.activeBot.getDaily">
<i-mdi-refresh />
</b-button>
</div>
<div>
<DailyChart
v-if="botStore.activeBot.dailyStats.data"
:daily-stats="botStore.activeBot.dailyStatsSorted"
/>
</div>
<div>
<b-table class="table-sm" :items="botStore.activeBot.dailyStats.data" :fields="dailyFields">
</b-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import DailyChart from '@/components/charts/DailyChart.vue';
import { formatPercent } from '@/shared/formatters';
import { useBotStore } from '@/stores/ftbotwrapper';
import { TableField } from 'bootstrap-vue-next';
const botStore = useBotStore();
const dailyFields = computed<TableField[]>(() => {
const res: TableField[] = [
{ key: 'date', label: 'Day' },
{
key: 'abs_profit',
label: 'Profit',
// formatter: (value: unknown) => formatPrice(value as number),
},
{
key: 'fiat_value',
label: `In ${botStore.activeBot.dailyStats.fiat_display_currency}`,
// formatter: (value: unknown) => formatPrice(value as number, 2),
},
{ key: 'trade_count', label: 'Trades' },
];
if (botStore.activeBot.botApiVersion >= 2.16)
res.push({
key: 'rel_profit',
label: 'Profit%',
formatter: (value: unknown) => formatPercent(value as number, 2),
});
return res;
});
onMounted(() => {
botStore.activeBot.getDaily();
});
</script>

View File

@ -1,21 +1,28 @@
<template> <template>
<!-- TODO We could move the list into a component since we are reusing the same code for both lists. -->
<div> <div>
<div> <div>
<h3>Whitelist Methods</h3> <h3>Whitelist Methods</h3>
<div v-if="botStore.activeBot.pairlistMethods.length" class="list"> <div v-if="botStore.activeBot.pairlistMethods.length" class="list wide">
<b-list-group v-for="(method, key) in botStore.activeBot.pairlistMethods" :key="key"> <div
<b-list-group-item href="#" class="pair white">{{ method }}</b-list-group-item> v-for="(method, key) in botStore.activeBot.pairlistMethods"
</b-list-group> :key="key"
class="pair white align-middle border border-secondary"
>
{{ method }}
</div>
</div> </div>
</div> </div>
<!-- Show Whitelist --> <!-- Show Whitelist -->
<h3 :title="`${botStore.activeBot.whitelist.length} pairs`">Whitelist</h3> <h3 :title="`${botStore.activeBot.whitelist.length} pairs`">Whitelist</h3>
<div v-if="botStore.activeBot.whitelist.length" class="list"> <div v-if="botStore.activeBot.whitelist.length" class="list">
<b-list-group v-for="(pair, key) in botStore.activeBot.whitelist" :key="key"> <div
<b-list-group-item class="pair white">{{ pair }}</b-list-group-item> v-for="(pair, key) in botStore.activeBot.whitelist"
</b-list-group> :key="key"
class="pair white align-middle border border-secondary text-small"
>
{{ pair }}
</div>
</div> </div>
<p v-else>List Unavailable. Please Login and make sure server is running.</p> <p v-else>List Unavailable. Please Login and make sure server is running.</p>
<hr /> <hr />
@ -76,14 +83,15 @@
</b-popover> </b-popover>
</div> </div>
<div v-if="botStore.activeBot.blacklist.length" class="list"> <div v-if="botStore.activeBot.blacklist.length" class="list">
<b-list-group v-for="(pair, key) in botStore.activeBot.blacklist" :key="key"> <div
<b-list-group-item v-for="(pair, key) in botStore.activeBot.blacklist"
class="pair black" :key="key"
:active="blacklistSelect.indexOf(key) > -1" class="pair black border border-secondary"
@click="blacklistSelectClick(key)" :class="blacklistSelect.indexOf(key) > -1 ? 'active' : ''"
><span class="check"><i-mdi-check-circle /></span>{{ pair }}</b-list-group-item @click="blacklistSelectClick(key)"
> >
</b-list-group> <span class="check"><i-mdi-check-circle /></span>{{ pair }}
</div>
</div> </div>
<p v-else>BlackList Unavailable. Please Login and make sure server is running.</p> <p v-else>BlackList Unavailable. Please Login and make sure server is running.</p>
<!-- Pagination --> <!-- Pagination -->
@ -119,7 +127,6 @@ const addBlacklistPair = () => {
}; };
const blacklistSelectClick = (key) => { const blacklistSelectClick = (key) => {
console.log(key);
const index = blacklistSelect.value.indexOf(key); const index = blacklistSelect.value.indexOf(key);
if (index > -1) { if (index > -1) {
blacklistSelect.value.splice(index, 1); blacklistSelect.value.splice(index, 1);
@ -162,7 +169,7 @@ onMounted(() => {
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.list-group-item.active .check { .pair.active .check {
opacity: 1; opacity: 1;
} }
@ -172,14 +179,14 @@ onMounted(() => {
grid-gap: 0.5rem; grid-gap: 0.5rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.wide {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.pair { .pair {
border: 1px solid #ccc;
background: #41b883; background: #41b883;
padding: 0.5rem; padding: 0.5rem;
border-radius: 5px; border-radius: 5px;
text-align: center;
position: relative;
cursor: pointer; cursor: pointer;
} }

View File

@ -13,7 +13,7 @@
class="btn-xs ms-1" class="btn-xs ms-1"
size="sm" size="sm"
title="Delete trade" title="Delete trade"
@click="removePairLock(row.item)" @click="removePairLock(row.item as unknown as Lock)"
> >
<i-mdi-delete /> <i-mdi-delete />
</b-button> </b-button>

View File

@ -24,7 +24,7 @@
v-model="pairlistStore.configName" v-model="pairlistStore.configName"
size="sm" size="sm"
:options="pairlistStore.savedConfigs.map((c) => c.name)" :options="pairlistStore.savedConfigs.map((c) => c.name)"
@change="(config) => pairlistStore.selectOrCreateConfig(config)" @change="(config) => pairlistStore.selectOrCreateConfig(config as string)"
/> />
</edit-value> </edit-value>
<b-button <b-button

View File

@ -30,6 +30,6 @@ defineProps<{
param: PairlistParameter; param: PairlistParameter;
}>(); }>();
//TODO: type should really be PairlistParamValue // TODO: type should really be PairlistParamValue
const paramValue = defineModel<any>(); const paramValue = defineModel<any>();
</script> </script>

View File

@ -0,0 +1,110 @@
<template>
<div>
<div class="mb-2">
<label class="me-auto h3">{{ hasWeekly ? 'Period' : 'Daily' }} Breakdown</label>
<b-button class="float-end" size="sm" @click="refreshSummary">
<i-mdi-refresh />
</b-button>
</div>
<b-form-radio-group
v-if="hasWeekly"
id="order-direction"
v-model="periodicBreakdownPeriod"
:options="periodicBreakdownSelections"
name="radios-btn-default"
size="sm"
buttons
style="min-width: 10em"
button-variant="outline-primary"
@change="refreshSummary"
></b-form-radio-group>
<div class="ps-1">
<TimePeriodChart
v-if="selectedStats"
:daily-stats="selectedStatsSorted"
:show-title="false"
/>
</div>
<div>
<b-table class="table-sm" :items="selectedStats.data" :fields="dailyFields"> </b-table>
</div>
</div>
</template>
<script setup lang="ts">
import TimePeriodChart from '@/components/charts/TimePeriodChart.vue';
import { formatPercent, formatPrice } from '@/shared/formatters';
import { useBotStore } from '@/stores/ftbotwrapper';
import { TableField } from 'bootstrap-vue-next';
import { computed, onMounted, ref } from 'vue';
import { TimeSummaryOptions } from '@/types';
const botStore = useBotStore();
const hasWeekly = computed(() => botStore.activeBot.botApiVersion >= 2.33);
const periodicBreakdownSelections = computed(() => {
const vals = [{ value: TimeSummaryOptions.daily, text: 'Days' }];
if (hasWeekly.value) {
vals.push({ value: TimeSummaryOptions.weekly, text: 'Weeks' });
vals.push({ value: TimeSummaryOptions.monthly, text: 'Months' });
}
return vals;
});
const periodicBreakdownPeriod = ref<TimeSummaryOptions>(TimeSummaryOptions.daily);
const selectedStats = computed(() => {
switch (periodicBreakdownPeriod.value) {
case TimeSummaryOptions.weekly:
return botStore.activeBot.weeklyStats;
case TimeSummaryOptions.monthly:
return botStore.activeBot.monthlyStats;
}
return botStore.activeBot.dailyStats;
});
const selectedStatsSorted = computed(() => {
// Sorted version for chart
return {
...selectedStats.value,
data: selectedStats.value.data
? Object.values(selectedStats.value.data).sort((a, b) => (a.date > b.date ? 1 : -1))
: [],
};
});
const dailyFields = computed<TableField[]>(() => {
const res: TableField[] = [
{ key: 'date', label: 'Day' },
{
key: 'abs_profit',
label: 'Profit',
formatter: (value: unknown) =>
formatPrice(value as number, botStore.activeBot.stakeCurrencyDecimals),
},
{
key: 'fiat_value',
label: `In ${botStore.activeBot.dailyStats.fiat_display_currency}`,
formatter: (value: unknown) => formatPrice(value as number, 2),
},
{ key: 'trade_count', label: 'Trades' },
];
if (botStore.activeBot.botApiVersion >= 2.16)
res.push({
key: 'rel_profit',
label: 'Profit%',
formatter: (value: unknown) => formatPercent(value as number, 2),
});
return res;
});
function refreshSummary() {
botStore.activeBot.getTimeSummary(periodicBreakdownPeriod.value);
}
onMounted(() => {
refreshSummary();
});
</script>

View File

@ -61,20 +61,24 @@ const timeRange = computed(() => {
return ''; return '';
}); });
const updateInput = () => { function updateInput() {
const tr = props.modelValue.split('-'); const tr = props.modelValue.split('-');
if (tr[0]) { if (tr[0]) {
dateFrom.value = timestampToDateString(dateFromString(tr[0], 'yyyyMMdd')); dateFrom.value = timestampToDateString(
tr[0].length === 8 ? dateFromString(tr[0], 'yyyyMMdd') : parseInt(tr[0]) * 1000,
);
} else { } else {
dateFrom.value = ''; dateFrom.value = '';
} }
if (tr.length > 1 && tr[1]) { if (tr.length > 1 && tr[1]) {
dateTo.value = timestampToDateString(dateFromString(tr[1], 'yyyyMMdd')); dateTo.value = timestampToDateString(
tr[1].length === 8 ? dateFromString(tr[1], 'yyyyMMdd') : parseInt(tr[1]) * 1000,
);
} else { } else {
dateTo.value = ''; dateTo.value = '';
} }
emit('update:modelValue', timeRange.value); emit('update:modelValue', timeRange.value);
}; }
watch( watch(
() => timeRange.value, () => timeRange.value,

View File

@ -37,7 +37,7 @@
<i-mdi-close-box-multiple class="me-1" />Forceexit partial <i-mdi-close-box-multiple class="me-1" />Forceexit partial
</b-button> </b-button>
<b-button <b-button
v-if="botApiVersion >= 2.24 && trade.open_order_id" v-if="botApiVersion >= 2.24 && (trade.open_order_id || trade.has_open_orders)"
class="btn-xs text-start mt-1" class="btn-xs text-start mt-1"
size="sm" size="sm"
title="Cancel open orders" title="Cancel open orders"

View File

@ -26,12 +26,12 @@
@row-clicked="onRowClicked" @row-clicked="onRowClicked"
@row-selected="onRowSelected" @row-selected="onRowSelected"
> >
<template #cell(actions)="row"> <template #cell(actions)="{ index, item }">
<TradeActionsPopover <TradeActionsPopover
:id="row.index" :id="index"
:trade="row.item" :trade="item as unknown as Trade"
:bot-api-version="botStore.activeBot.botApiVersion" :bot-api-version="botStore.activeBot.botApiVersion"
@delete-trade="removeTradeHandler(row.item)" @delete-trade="removeTradeHandler(item as unknown as Trade)"
@force-exit="forceExitHandler" @force-exit="forceExitHandler"
@force-exit-partial="forceExitPartialHandler" @force-exit-partial="forceExitPartialHandler"
@cancel-open-order="cancelOpenOrderHandler" @cancel-open-order="cancelOpenOrderHandler"
@ -40,11 +40,7 @@
</template> </template>
<template #cell(pair)="row"> <template #cell(pair)="row">
<span> <span>
{{ {{ `${row.item.pair}${row.item.open_order_id || row.item.has_open_orders ? '*' : ''}` }}
`${row.item.pair}${
row.item.open_order_id === undefined || row.item.open_order_id === null ? '' : '*'
}`
}}
</span> </span>
</template> </template>
<template #cell(trade_id)="row"> <template #cell(trade_id)="row">
@ -60,13 +56,13 @@
{{ row.item.trading_mode !== 'spot' ? `(${row.item.leverage}x)` : '' }} {{ row.item.trading_mode !== 'spot' ? `(${row.item.leverage}x)` : '' }}
</template> </template>
<template #cell(profit)="row"> <template #cell(profit)="row">
<trade-profit :trade="row.item" /> <trade-profit :trade="row.item as unknown as Trade" />
</template> </template>
<template #cell(open_timestamp)="row"> <template #cell(open_timestamp)="row">
<DateTimeTZ :date="row.item.open_timestamp" /> <DateTimeTZ :date="(row.item as unknown as Trade).open_timestamp" />
</template> </template>
<template #cell(close_timestamp)="row"> <template #cell(close_timestamp)="row">
<DateTimeTZ :date="row.item.close_timestamp" /> <DateTimeTZ :date="(row.item as unknown as Trade).close_timestamp ?? 0" />
</template> </template>
</b-table> </b-table>
<div class="w-100 d-flex justify-content-between"> <div class="w-100 d-flex justify-content-between">
@ -97,7 +93,7 @@ import TradeProfit from './TradeProfit.vue';
import TradeActionsPopover from './TradeActionsPopover.vue'; import TradeActionsPopover from './TradeActionsPopover.vue';
import ForceExitForm from '@/components/ftbot/ForceExitForm.vue'; import ForceExitForm from '@/components/ftbot/ForceExitForm.vue';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { TableField, TableItem } from 'bootstrap-vue-next'; import { TableField, TableItem } from 'bootstrap-vue-next';
@ -143,43 +139,48 @@ const rows = computed(() => {
return props.trades.length; return props.trades.length;
}); });
const tableFields: TableField[] = [ // This using "TableField[]" below causes
{ key: 'trade_id', label: 'ID' }, // Error: Debug Failure. No error for last overload signature
{ key: 'pair', label: 'Pair' }, const tableFields = ref<any[]>([]);
{ key: 'amount', label: 'Amount' },
{
key: 'stake_amount',
label: 'Stake amount',
},
{
key: 'open_rate',
label: 'Open rate',
formatter: (value: unknown) => formatPrice(value as number),
},
{
key: props.activeTrades ? 'current_rate' : 'close_rate',
label: props.activeTrades ? 'Current rate' : 'Close rate',
formatter: (value: unknown) => formatPrice(value as number),
},
{
key: 'profit',
label: props.activeTrades ? 'Current profit %' : 'Profit %',
formatter: (value: unknown, key?: string, item?: unknown) => { onMounted(() => {
if (!item) { tableFields.value = [
return ''; { key: 'trade_id', label: 'ID' },
} { key: 'pair', label: 'Pair' },
const typedItem = item as Trade; { key: 'amount', label: 'Amount' },
const percent = formatPercent(typedItem.profit_ratio, 2); {
return `${percent} ${`(${formatPriceWithDecimals(typedItem.profit_abs)})`}`; key: 'stake_amount',
label: 'Stake amount',
}, },
}, {
{ key: 'open_timestamp', label: 'Open date' }, key: 'open_rate',
...(props.activeTrades ? openFields : closedFields), label: 'Open rate',
]; formatter: (value: unknown) => formatPrice(value as number),
if (props.multiBotView) { },
tableFields.unshift({ key: 'botName', label: 'Bot' }); {
} key: props.activeTrades ? 'current_rate' : 'close_rate',
label: props.activeTrades ? 'Current rate' : 'Close rate',
formatter: (value: unknown) => formatPrice(value as number),
},
{
key: 'profit',
label: props.activeTrades ? 'Current profit %' : 'Profit %',
formatter: (value: unknown, key?: string, item?: unknown) => {
if (!item) {
return '';
}
const typedItem = item as Trade;
const percent = formatPercent(typedItem.profit_ratio, 2);
return `${percent} ${`(${formatPriceWithDecimals(typedItem.profit_abs)})`}`;
},
},
{ key: 'open_timestamp', label: 'Open date' },
...(props.activeTrades ? openFields : closedFields),
];
if (props.multiBotView) {
tableFields.value.unshift({ key: 'botName', label: 'Bot' });
}
});
const feOrderType = ref<string | undefined>(undefined); const feOrderType = ref<string | undefined>(undefined);
const forceExitHandler = (item: Trade, ordertype: string | undefined = undefined) => { const forceExitHandler = (item: Trade, ordertype: string | undefined = undefined) => {

View File

@ -9,26 +9,48 @@
>Trade Navigation {{ sortNewestFirst ? '&#8595;' : '&#8593;' }} >Trade Navigation {{ sortNewestFirst ? '&#8595;' : '&#8593;' }}
</b-list-group-item> </b-list-group-item>
<b-list-group-item <b-list-group-item
v-for="trade in sortedTrades" v-for="(trade, i) in sortedTrades"
:key="trade.open_timestamp" :key="trade.open_timestamp"
button button
class="d-flex flex-wrap justify-content-between align-items-center py-1" class="d-flex flex-column py-1 pe-1 align-items-stretch"
:title="`${trade.pair}`" :title="`${trade.pair}`"
:active="trade.open_timestamp === selectedTrade.open_timestamp" :active="trade.open_timestamp === selectedTrade.open_timestamp"
@click="onTradeSelect(trade)" @click="onTradeSelect(trade)"
> >
<div> <div class="d-flex">
<span v-if="botStore.activeBot.botState.trading_mode !== 'spot'">{{ <div class="d-flex flex-column">
trade.is_short ? 'S-' : 'L-' <div>
}}</span> <span v-if="botStore.activeBot.botState.trading_mode !== 'spot'">{{
<DateTimeTZ :date="trade.open_timestamp" /> trade.is_short ? 'S-' : 'L-'
}}</span>
<DateTimeTZ :date="trade.open_timestamp" />
</div>
<TradeProfit :trade="trade" class="my-1" />
<ProfitPill
v-if="backtestMode"
:profit-ratio="trade.profit_ratio"
:stake-currency="botStore.activeBot.stakeCurrency"
/>
</div>
<b-button
size="sm"
class="ms-auto"
variant="outline-secondary"
@click="ordersVisible[i] = !ordersVisible[i]"
><i-mdi-chevron-right v-if="!ordersVisible[i]" width="24" height="24" />
<i-mdi-chevron-down v-if="ordersVisible[i]" width="24" height="24" />
</b-button>
</div> </div>
<TradeProfit :trade="trade" /> <b-collapse v-model="ordersVisible[i]">
<ProfitPill <ul class="px-3 m-0">
v-if="backtestMode" <li
:profit-ratio="trade.profit_ratio" v-for="order in trade.orders?.filter((o) => o.order_filled_timestamp !== null)"
:stake-currency="botStore.activeBot.stakeCurrency" :key="order.order_timestamp"
/> >
{{ order.ft_order_side }} {{ order.amount }} at {{ order.safe_price }}
</li>
</ul>
</b-collapse>
</b-list-group-item> </b-list-group-item>
<b-list-group-item v-if="trades.length === 0">No trades to show...</b-list-group-item> <b-list-group-item v-if="trades.length === 0">No trades to show...</b-list-group-item>
</b-list-group> </b-list-group>
@ -39,7 +61,7 @@
import { Trade } from '@/types'; import { Trade } from '@/types';
import TradeProfit from '@/components/ftbot/TradeProfit.vue'; import TradeProfit from '@/components/ftbot/TradeProfit.vue';
import ProfitPill from '@/components/general/ProfitPill.vue'; import ProfitPill from '@/components/general/ProfitPill.vue';
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue'; import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
@ -67,6 +89,15 @@ const sortedTrades = computed(() => {
: a.open_timestamp - b.open_timestamp, : a.open_timestamp - b.open_timestamp,
); );
}); });
const ordersVisible = ref(sortedTrades.value.map(() => false));
watch(
() => botStore.activeBot.selectedPair,
() => {
ordersVisible.value = sortedTrades.value.map(() => false);
},
);
</script> </script>
<style scoped> <style scoped>

View File

@ -3,7 +3,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { timestampms, timestampmsWithTimezone, timestampToDateString } from '@/shared/formatters'; import {
timestampmsOrNa,
timestampmsWithTimezone,
timestampToDateString,
} from '@/shared/formatters';
import { computed } from 'vue'; import { computed } from 'vue';
@ -19,7 +23,7 @@ const formattedDate = computed((): string => {
if (props.showTimezone) { if (props.showTimezone) {
return timestampmsWithTimezone(props.date); return timestampmsWithTimezone(props.date);
} }
return timestampms(props.date); return timestampmsOrNa(props.date);
}); });
const timezoneTooltip = computed((): string => { const timezoneTooltip = computed((): string => {

View File

@ -61,7 +61,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@ -104,8 +104,11 @@ enum EditState {
Duplicating, Duplicating,
} }
const localName = ref<string>(props.modelValue); const localName = ref<string>('');
const mode = ref<EditState>(EditState.None); const mode = ref<EditState>(EditState.None);
onMounted(() => {
localName.value = props.modelValue;
});
function abort() { function abort() {
mode.value = EditState.None; mode.value = EditState.None;

View File

@ -42,17 +42,17 @@ const routes: Array<RouteRecordRaw> = [
}, },
{ {
path: '/open_trades', path: '/open_trades',
component: () => import('@/views/TradesList.vue'), component: () => import('@/views/TradesListView.vue'),
}, },
{ {
path: '/trade_history', path: '/trade_history',
component: () => import('@/views/TradesList.vue'), component: () => import('@/views/TradesListView.vue'),
props: { history: true }, props: { history: true },
}, },
{ {
path: '/pairlist', path: '/pairlist',
component: () => import('@/components/ftbot/FTBotAPIPairList.vue'), component: () => import('@/components/ftbot/PairListLive.vue'),
}, },
{ {
path: '/settings', path: '/settings',

View File

@ -12,18 +12,22 @@ export function calculateDiff(
): number[][] { ): number[][] {
const fromIdx = columns.indexOf(colFrom); const fromIdx = columns.indexOf(colFrom);
const toIdx = columns.indexOf(colTo); const toIdx = columns.indexOf(colTo);
columns.push(`${colFrom}-${colTo}`); const hasBothColumns = fromIdx > 0 && toIdx > 0;
if (hasBothColumns) {
columns.push(`${colFrom}-${colTo}`);
}
return data.map((original) => { return data.map((original) => {
// Prevent mutation of original data // Prevent mutation of original data
const candle = original.slice(); const candle = original.slice();
const diff = if (hasBothColumns) {
candle === null || candle[toIdx] === null || candle[fromIdx] === null const diff =
? null candle === null || candle[toIdx] === null || candle[fromIdx] === null
: candle[toIdx] - candle[fromIdx]; ? null
// eslint-disable-next-line @typescript-eslint/ban-ts-comment : candle[toIdx] - candle[fromIdx];
// @ts-expect-error // eslint-disable-next-line @typescript-eslint/ban-ts-comment
candle.push(diff); // @ts-expect-error
candle.push(diff);
}
return candle; return candle;
}); });
} }

View File

@ -47,11 +47,12 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
// 4: color // 4: color
// 5: label // 5: label
// 6: tooltip // 6: tooltip
const stop_ts_adjusted = dataset.data_stop_ts + dataset.timeframe_ms;
for (let i = 0, len = trades.length; i < len; i += 1) { for (let i = 0, len = trades.length; i < len; i += 1) {
const trade: Trade = trades[i]; const trade: Trade = trades[i];
if ( if (
// Trade is open or closed and within timerange // Trade is open or closed and within timerange
roundTimeframe(dataset.timeframe_ms ?? 0, trade.open_timestamp) <= dataset.data_stop_ts || roundTimeframe(dataset.timeframe_ms ?? 0, trade.open_timestamp) <= stop_ts_adjusted ||
!trade.close_timestamp || !trade.close_timestamp ||
(trade.close_timestamp && trade.close_timestamp >= dataset.data_start_ts) (trade.close_timestamp && trade.close_timestamp >= dataset.data_start_ts)
) { ) {
@ -61,7 +62,7 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
if ( if (
order.order_filled_timestamp && order.order_filled_timestamp &&
roundTimeframe(dataset.timeframe_ms ?? 0, order.order_filled_timestamp) <= roundTimeframe(dataset.timeframe_ms ?? 0, order.order_filled_timestamp) <=
dataset.data_stop_ts && stop_ts_adjusted &&
order.order_filled_timestamp > dataset.data_start_ts order.order_filled_timestamp > dataset.data_start_ts
) { ) {
// Trade entry // Trade entry
@ -79,7 +80,7 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
} else if (i === trade.orders.length - 1 && trade.close_timestamp) { } else if (i === trade.orders.length - 1 && trade.close_timestamp) {
if ( if (
roundTimeframe(dataset.timeframe_ms ?? 0, trade.close_timestamp) <= roundTimeframe(dataset.timeframe_ms ?? 0, trade.close_timestamp) <=
dataset.data_stop_ts && stop_ts_adjusted &&
trade.close_timestamp > dataset.data_start_ts && trade.close_timestamp > dataset.data_start_ts &&
trade.is_open === false trade.is_open === false
) { ) {
@ -127,6 +128,8 @@ export function generateTradeSeries(
): ScatterSeriesOption { ): ScatterSeriesOption {
const { tradeData } = getTradeEntries(dataset, trades); const { tradeData } = getTradeEntries(dataset, trades);
const openTrades = trades.filter((t) => t.is_open);
const tradesSeries: ScatterSeriesOption = { const tradesSeries: ScatterSeriesOption = {
name: nameTrades, name: nameTrades,
type: 'scatter', type: 'scatter',
@ -158,6 +161,41 @@ export function generateTradeSeries(
symbolSize: 13, symbolSize: 13,
data: tradeData, data: tradeData,
}; };
// Show distance to stoploss
if (openTrades.length > 0) {
// Ensure to import and "use" whatever feature in candleChart! (MarkLine, MarkArea, ...)
// Offset to avoid having the line at the very end of the chart
const offset = dataset.timeframe_ms * 10;
tradesSeries.markLine = {
symbol: 'none',
itemStyle: {
color: '#ff0000AA',
},
label: {
show: true,
position: 'middle',
},
lineStyle: {
type: 'solid',
},
data: openTrades.map((t) => {
return [
{
name: 'Stoploss',
yAxis: t.stop_loss_abs,
xAxis:
dataset.data_stop_ts - offset > t.open_timestamp
? t.open_timestamp
: dataset.data_stop_ts - offset,
},
{
yAxis: t.stop_loss_abs,
xAxis: t.close_timestamp ?? dataset.data_stop_ts + dataset.timeframe_ms,
},
];
}),
};
}
return tradesSeries; return tradesSeries;
} }

View File

@ -30,7 +30,7 @@ export function formatPrice(value: number | null, decimals = 15): string {
* @returns * @returns
*/ */
export function formatPriceCurrency(price: number | null, currency: string, decimals = 3) { export function formatPriceCurrency(price: number | null, currency: string, decimals = 3) {
return `${formatPrice(price, decimals)} ${currency}`; return `${formatPrice(price, decimals)} ${currency ?? ''}`;
} }
export default { export default {

View File

@ -38,13 +38,25 @@ export function timestampms(ts: number | Date): string {
return formatDate(toDate(ts), 'yyyy-MM-dd HH:mm:ss'); return formatDate(toDate(ts), 'yyyy-MM-dd HH:mm:ss');
} }
/**
* Convert a timestamp / Date object to String.
* Returns 'N/A' if ts is null
* @param ts Timestamp as number or date (in utc!!)
*/
export function timestampmsOrNa(ts: number | Date | null): string {
return ts ? formatDate(toDate(ts), 'yyyy-MM-dd HH:mm:ss') : 'N/A';
}
/** /**
* Convert a timestamp / Date object to String * Convert a timestamp / Date object to String
* @param ts Timestamp as number or date (in utc!!) * @param ts Timestamp as number or date (in utc!!)
* @param timezone timezone to use * @param timezone timezone to use
* @returns formatted date in desired timezone (or globally configured timezone) * @returns formatted date in desired timezone (or globally configured timezone)
*/ */
export function timestampmsWithTimezone(ts: number | Date, timezone?: string): string { export function timestampmsWithTimezone(ts: number | Date | null, timezone?: string): string {
if (!ts) {
return 'N/A';
}
return formatDate(toDate(ts), 'yyyy-MM-dd HH:mm:ss (z)', timezone); return formatDate(toDate(ts), 'yyyy-MM-dd HH:mm:ss (z)', timezone);
} }

View File

@ -232,7 +232,6 @@ export class UserService {
* Call on startup to migrate old login info to new login * Call on startup to migrate old login info to new login
*/ */
public static migrateLogin() { public static migrateLogin() {
// TODO: this is actually never called!
const AUTH_REFRESH_TOKEN = 'auth_ref_token'; // Legacy key - do not use const AUTH_REFRESH_TOKEN = 'auth_ref_token'; // Legacy key - do not use
const AUTH_ACCESS_TOKEN = 'auth_access_token'; const AUTH_ACCESS_TOKEN = 'auth_access_token';
const AUTH_API_URL = 'auth_api_url'; const AUTH_API_URL = 'auth_api_url';

25
src/stores/btStore.ts Normal file
View File

@ -0,0 +1,25 @@
import { defineStore } from 'pinia';
export const useBtStore = defineStore('btStore', {
state: () => {
return {
strategy: '',
selectedTimeframe: '',
selectedDetailTimeframe: '',
timerange: '',
maxOpenTrades: '',
stakeAmount: '',
startingCapital: '',
allowCache: true,
enableProtections: false,
stakeAmountUnlimited: false,
freqAI: {
enabled: false,
model: '',
identifier: '',
},
};
},
getters: {},
actions: {},
});

View File

@ -6,18 +6,17 @@ import {
PlotConfig, PlotConfig,
StrategyResult, StrategyResult,
BalanceInterface, BalanceInterface,
DailyReturnValue, TimeSummaryReturnValue,
LockResponse, LockResponse,
ProfitInterface, ProfitInterface,
BacktestResult, BacktestResult,
StrategyBacktestResult,
BacktestSteps, BacktestSteps,
LogLine, LogLine,
SysInfoResponse, SysInfoResponse,
LoadingStatus, LoadingStatus,
BacktestHistoryEntry, BacktestHistoryEntry,
RunModes, RunModes,
DailyPayload, TimeSummaryPayload,
BlacklistResponse, BlacklistResponse,
WhitelistResponse, WhitelistResponse,
StrategyListResult, StrategyListResult,
@ -43,6 +42,11 @@ import {
PairlistEvalResponse, PairlistEvalResponse,
PairlistsPayload, PairlistsPayload,
PairlistsResponse, PairlistsResponse,
BacktestResultInMemory,
BacktestMetadataWithStrategyName,
BacktestMetadataPatch,
BacktestResultUpdate,
TimeSummaryOptions,
} from '@/types'; } from '@/types';
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
@ -78,7 +82,9 @@ export function createBotSubStore(botId: string, botName: string) {
profit: {} as ProfitInterface, profit: {} as ProfitInterface,
botState: {} as BotState, botState: {} as BotState,
balance: {} as BalanceInterface, balance: {} as BalanceInterface,
dailyStats: {} as DailyReturnValue, dailyStats: {} as TimeSummaryReturnValue,
weeklyStats: {} as TimeSummaryReturnValue,
monthlyStats: {} as TimeSummaryReturnValue,
pairlistMethods: [] as string[], pairlistMethods: [] as string[],
detailTradeId: null as number | null, detailTradeId: null as number | null,
selectedPair: '', selectedPair: '',
@ -102,7 +108,7 @@ export function createBotSubStore(botId: string, botName: string) {
backtestTradeCount: 0, backtestTradeCount: 0,
backtestResult: undefined as BacktestResult | undefined, backtestResult: undefined as BacktestResult | undefined,
selectedBacktestResultKey: '', selectedBacktestResultKey: '',
backtestHistory: {} as Record<string, StrategyBacktestResult>, backtestHistory: {} as Record<string, BacktestResultInMemory>,
backtestHistoryList: [] as BacktestHistoryEntry[], backtestHistoryList: [] as BacktestHistoryEntry[],
sysInfo: {} as SysInfoResponse, sysInfo: {} as SysInfoResponse,
}; };
@ -114,7 +120,8 @@ export function createBotSubStore(botId: string, botName: string) {
stakeCurrencyDecimals: (state) => state.botState?.stake_currency_decimals || 3, stakeCurrencyDecimals: (state) => state.botState?.stake_currency_decimals || 3,
canRunBacktest: (state) => state.botState?.runmode === RunModes.WEBSERVER, canRunBacktest: (state) => state.botState?.runmode === RunModes.WEBSERVER,
isWebserverMode: (state) => state.botState?.runmode === RunModes.WEBSERVER, isWebserverMode: (state) => state.botState?.runmode === RunModes.WEBSERVER,
selectedBacktestResult: (state) => state.backtestHistory[state.selectedBacktestResultKey], selectedBacktestResult: (state) =>
state.backtestHistory[state.selectedBacktestResultKey]?.strategy || {},
shortAllowed: (state) => state.botState?.short_allowed || false, shortAllowed: (state) => state.botState?.short_allowed || false,
openTradeCount: (state) => state.openTrades.length, openTradeCount: (state) => state.openTrades.length,
isTrading: (state) => isTrading: (state) =>
@ -151,14 +158,6 @@ export function createBotSubStore(botId: string, botName: string) {
botName: (state) => state.botState?.bot_name || 'freqtrade', botName: (state) => state.botState?.bot_name || 'freqtrade',
allTrades: (state) => [...state.openTrades, ...state.trades] as Trade[], allTrades: (state) => [...state.openTrades, ...state.trades] as Trade[],
activeLocks: (state) => state.currentLocks?.locks || [], activeLocks: (state) => state.currentLocks?.locks || [],
dailyStatsSorted: (state): DailyReturnValue => {
return {
...state.dailyStats,
data: state.dailyStats.data
? Object.values(state.dailyStats.data).sort((a, b) => (a.date > b.date ? 1 : -1))
: [],
};
},
}, },
actions: { actions: {
botAdded() { botAdded() {
@ -168,7 +167,6 @@ export function createBotSubStore(botId: string, botName: string) {
try { try {
const result = await api.get('/ping'); const result = await api.get('/ping');
const now = Date.now(); const now = Date.now();
// TODO: Name collision!
this.ping = `${result.data.status} ${now.toString()}`; this.ping = `${result.data.status} ${now.toString()}`;
this.setIsBotOnline(true); this.setIsBotOnline(true);
return Promise.resolve(); return Promise.resolve();
@ -363,29 +361,36 @@ export function createBotSubStore(botId: string, botName: string) {
reject(error); reject(error);
}); });
}, },
getPairHistory(payload: PairHistoryPayload) { async getPairHistory(payload: PairHistoryPayload) {
if (payload.pair && payload.timeframe) { if (payload.pair && payload.timeframe) {
this.historyStatus = LoadingStatus.loading; this.historyStatus = LoadingStatus.loading;
return api try {
.get('/pair_history', { const { data } = await api.get('/pair_history', {
params: { ...payload }, params: { ...payload },
timeout: 50000, timeout: 50000,
})
.then((result) => {
this.history = {
[`${payload.pair}__${payload.timeframe}`]: {
pair: payload.pair,
timeframe: payload.timeframe,
timerange: payload.timerange,
data: result.data,
},
};
this.historyStatus = LoadingStatus.success;
})
.catch((err) => {
console.error(err);
this.historyStatus = LoadingStatus.error;
}); });
this.history = {
[`${payload.pair}__${payload.timeframe}`]: {
pair: payload.pair,
timeframe: payload.timeframe,
timerange: payload.timerange,
data: data,
},
};
this.historyStatus = LoadingStatus.success;
} catch (err) {
console.error(err);
this.historyStatus = LoadingStatus.error;
if (axios.isAxiosError(err)) {
console.error(err.response);
const errMsg = err.response?.data?.detail ?? 'Error fetching history';
showAlert(errMsg, 'danger');
}
return new Promise((resolve, reject) => {
reject(err);
});
}
} }
// Error branchs // Error branchs
const error = 'pair or timeframe or timerange not specified'; const error = 'pair or timeframe or timerange not specified';
@ -436,6 +441,11 @@ export function createBotSubStore(botId: string, botName: string) {
return Promise.resolve(data); return Promise.resolve(data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (axios.isAxiosError(error)) {
console.error(error.response);
const errMsg = error.response?.data?.detail ?? 'Error fetching history';
showAlert(errMsg, 'warning');
}
return Promise.reject(error); return Promise.reject(error);
} }
}, },
@ -520,11 +530,20 @@ export function createBotSubStore(botId: string, botName: string) {
return Promise.reject(error); return Promise.reject(error);
} }
}, },
async getDaily(payload: DailyPayload = {}) { async getTimeSummary(aggregation: TimeSummaryOptions, payload: TimeSummaryPayload = {}) {
const { timescale = 20 } = payload; const { timescale = 20 } = payload;
try { try {
const { data } = await api.get<DailyReturnValue>('/daily', { params: { timescale } }); const { data } = await api.get<TimeSummaryReturnValue>(`/${aggregation}`, {
this.dailyStats = data; params: { timescale },
});
if (aggregation === TimeSummaryOptions.daily) {
this.dailyStats = data;
} else if (aggregation === TimeSummaryOptions.weekly) {
this.weeklyStats = data;
} else if (aggregation === TimeSummaryOptions.monthly) {
this.monthlyStats = data;
}
return Promise.resolve(data); return Promise.resolve(data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -880,18 +899,30 @@ export function createBotSubStore(botId: string, botName: string) {
} }
}, },
async getBacktestHistory() { async getBacktestHistory() {
const result = await api.get<BacktestHistoryEntry[]>('/backtest/history'); const { data } = await api.get<BacktestHistoryEntry[]>('/backtest/history');
this.backtestHistoryList = result.data; this.backtestHistoryList = data;
}, },
updateBacktestResult(backtestResult: BacktestResult) { updateBacktestResult(backtestResult: BacktestResult) {
this.backtestResult = backtestResult; this.backtestResult = backtestResult;
// TODO: Properly identify duplicates to avoid pushing the same multiple times
Object.entries(backtestResult.strategy).forEach(([key, strat]) => { Object.entries(backtestResult.strategy).forEach(([key, strat]) => {
console.log(key, strat); const metadata: BacktestMetadataWithStrategyName = {
...(backtestResult.metadata[key] ?? {}),
strategyName: key,
notes: backtestResult.metadata[key]?.notes ?? ``,
editing: false,
};
// console.log(key, strat, metadata);
const stratKey = `${key}_${strat.total_trades}_${strat.profit_total.toFixed(3)}`; // Never versions will always have run_id
const stratKey =
backtestResult.metadata[key].run_id ??
`${key}_${strat.total_trades}_${strat.profit_total.toFixed(3)}`;
const btResult: BacktestResultInMemory = {
metadata,
strategy: strat,
};
// this.backtestHistory[stratKey] = strat; // this.backtestHistory[stratKey] = strat;
this.backtestHistory = { ...this.backtestHistory, ...{ [stratKey]: strat } }; this.backtestHistory = { ...this.backtestHistory, ...{ [stratKey]: btResult } };
this.selectedBacktestResultKey = stratKey; this.selectedBacktestResultKey = stratKey;
}); });
}, },
@ -903,9 +934,50 @@ export function createBotSubStore(botId: string, botName: string) {
this.updateBacktestResult(result.data.backtest_result); this.updateBacktestResult(result.data.backtest_result);
} }
}, },
async deleteBacktestHistoryResult(btHistoryEntry: BacktestHistoryEntry) {
try {
const { data } = await api.delete<BacktestHistoryEntry[]>(
`/backtest/history/${btHistoryEntry.filename}`,
);
this.backtestHistoryList = data;
} catch (err) {
console.error(err);
return Promise.reject(err);
}
},
async saveBacktestResultMetadata(payload: BacktestResultUpdate) {
try {
const { data } = await api.patch<
BacktestMetadataPatch,
AxiosResponse<BacktestHistoryEntry[]>
>(`/backtest/history/${payload.filename}`, payload);
console.log(data);
data.forEach((entry) => {
if (entry.run_id in this.backtestHistory) {
this.backtestHistory[entry.run_id].metadata.notes = entry.notes;
console.log('updating ...');
}
});
// Update metadata in backtestHistoryList
} catch (err) {
console.error(err);
return Promise.reject(err);
}
},
setBacktestResultKey(key: string) { setBacktestResultKey(key: string) {
this.selectedBacktestResultKey = key; this.selectedBacktestResultKey = key;
}, },
removeBacktestResultFromMemory(key: string) {
if (this.selectedBacktestResultKey === key) {
// Get first key from backtestHistory that is not the key to be deleted
const keys = Object.keys(this.backtestHistory);
const index = keys.findIndex((k) => k !== key);
if (index !== -1) {
this.selectedBacktestResultKey = keys[index];
}
}
delete this.backtestHistory[key];
},
async getSysInfo() { async getSysInfo() {
try { try {
const { data } = await api.get<SysInfoResponse>('/sysinfo'); const { data } = await api.get<SysInfoResponse>('/sysinfo');
@ -936,7 +1008,7 @@ export function createBotSubStore(botId: string, botName: string) {
// TODO: check for active bot ... // TODO: check for active bot ...
if (pair === this.selectedPair) { if (pair === this.selectedPair) {
// Reload pair candles // Reload pair candles
this.getPairCandles({ pair, timeframe, limit: 500 }); this.getPairCandles({ pair, timeframe });
} }
break; break;
} }

View File

@ -5,15 +5,16 @@ import {
BotDescriptors, BotDescriptors,
BotState, BotState,
ClosedTrade, ClosedTrade,
DailyPayload, TimeSummaryPayload,
DailyRecord, TimeSummaryRecord,
DailyReturnValue, TimeSummaryReturnValue,
MultiCancelOpenOrderPayload, MultiCancelOpenOrderPayload,
MultiDeletePayload, MultiDeletePayload,
MultiForcesellPayload, MultiForcesellPayload,
MultiReloadTradePayload, MultiReloadTradePayload,
ProfitInterface, ProfitInterface,
Trade, Trade,
TimeSummaryOptions,
} from '@/types'; } from '@/types';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { createBotSubStore } from './ftbot'; import { createBotSubStore } from './ftbot';
@ -121,9 +122,9 @@ export const useBotStore = defineStore('ftbot-wrapper', {
}); });
return result; return result;
}, },
allDailyStatsSelectedBots: (state): DailyReturnValue => { allDailyStatsSelectedBots: (state): TimeSummaryReturnValue => {
// Return aggregated daily stats for all bots - sorted ascending. // Return aggregated daily stats for all bots - sorted ascending.
const resp: Record<string, DailyRecord> = {}; const resp: Record<string, TimeSummaryRecord> = {};
Object.entries(state.botStores).forEach(([, botStore]) => { Object.entries(state.botStores).forEach(([, botStore]) => {
if (botStore.isSelected) { if (botStore.isSelected) {
botStore.dailyStats?.data?.forEach((d) => { botStore.dailyStats?.data?.forEach((d) => {
@ -138,7 +139,7 @@ export const useBotStore = defineStore('ftbot-wrapper', {
} }
}); });
const dailyReturn: DailyReturnValue = { const dailyReturn: TimeSummaryReturnValue = {
stake_currency: 'USDT', stake_currency: 'USDT',
fiat_display_currency: 'USD', fiat_display_currency: 'USD',
data: Object.values(resp).sort((a, b) => (a.date > b.date ? 1 : -1)), data: Object.values(resp).sort((a, b) => (a.date > b.date ? 1 : -1)),
@ -247,7 +248,7 @@ export const useBotStore = defineStore('ftbot-wrapper', {
const updates: Promise<void>[] = []; const updates: Promise<void>[] = [];
updates.push(this.allRefreshFrequent(false)); updates.push(this.allRefreshFrequent(false));
updates.push(this.allRefreshSlow(true)); updates.push(this.allRefreshSlow(true));
// updates.push(this.getDaily()); // updates.push(this.getTimeSummary());
// updates.push(this.getBalance()); // updates.push(this.getBalance());
await Promise.all(updates); await Promise.all(updates);
console.log('refreshing_end'); console.log('refreshing_end');
@ -303,12 +304,12 @@ export const useBotStore = defineStore('ftbot-wrapper', {
} }
}); });
}, },
async allGetDaily(payload: DailyPayload) { async allGetDaily(payload: TimeSummaryPayload) {
const updates: Promise<DailyReturnValue>[] = []; const updates: Promise<TimeSummaryReturnValue>[] = [];
this.allBotStores.forEach((bot) => { this.allBotStores.forEach((bot) => {
if (bot.isBotOnline) { if (bot.isBotOnline) {
updates.push(bot.getDaily(payload)); updates.push(bot.getTimeSummary(TimeSummaryOptions.daily, payload));
} }
}); });
await Promise.all(updates); await Promise.all(updates);

View File

@ -80,6 +80,12 @@
background: $bg-dark; background: $bg-dark;
} }
.list-group-item.disabled,
.list-group-item:disabled {
color: darken($fg-color, 40%);
background: $bg-dark;
}
// .custom-select { // .custom-select {
// color: $fg-color; // color: $fg-color;
// // background: $bg-dark; // // background: $bg-dark;
@ -214,13 +220,14 @@
} }
.b-toast .toast { .b-toast .toast {
background: $bg-dark; color: black;
// background: $bg-dark;
border-color: lighten($bg-dark, 20%); border-color: lighten($bg-dark, 20%);
} }
.toast-header { .toast-header {
color: $fg-color; color: $fg-color;
background: darken($bg-dark, 10%); // background: darken($bg-dark, 10%);
border-color: lighten($bg-dark, 20%); border-color: lighten($bg-dark, 20%);
} }

View File

@ -32,6 +32,7 @@ export interface PairResult {
profit_total: number; profit_total: number;
trades: number; trades: number;
wins: number; wins: number;
winrate?: number;
} }
export interface ExitReasonResults { export interface ExitReasonResults {
@ -48,6 +49,7 @@ export interface ExitReasonResults {
profit_total_pct: number; profit_total_pct: number;
trades: number; trades: number;
wins: number; wins: number;
winrate?: number;
} }
// Generated by https://quicktype.io // Generated by https://quicktype.io
@ -59,6 +61,7 @@ export interface PeriodicStat {
wins: number; wins: number;
draws: number; draws: number;
loses: number; loses: number;
winrate?: number;
} }
export interface PeriodicBreakdown { export interface PeriodicBreakdown {
@ -137,7 +140,6 @@ export interface StrategyBacktestResult {
canceled_entry_orders?: number; canceled_entry_orders?: number;
replaced_entry_orders?: number; replaced_entry_orders?: number;
// Daily stats ...
draw_days: number; draw_days: number;
drawdown_end: string; drawdown_end: string;
drawdown_end_ts: number; drawdown_end_ts: number;
@ -145,6 +147,8 @@ export interface StrategyBacktestResult {
drawdown_start_ts: number; drawdown_start_ts: number;
loser_holding_avg: string; loser_holding_avg: string;
loser_holding_avg_s: number; loser_holding_avg_s: number;
max_consecutive_wins?: number;
max_consecutive_losses?: number;
losing_days: number; losing_days: number;
max_drawdown: number; max_drawdown: number;
max_drawdown_account: number; max_drawdown_account: number;
@ -159,6 +163,7 @@ export interface StrategyBacktestResult {
sharpe?: number; sharpe?: number;
calmar?: number; calmar?: number;
expectancy?: number; expectancy?: number;
expectancy_ratio?: number;
winner_holding_avg: string; winner_holding_avg: string;
winner_holding_avg_s: number; winner_holding_avg_s: number;
@ -177,9 +182,42 @@ export interface StrategyBacktestResult {
backtest_run_end_ts: number; backtest_run_end_ts: number;
} }
export interface BacktestMetadata {
/** Start time of the backtest run */
backtest_run_start_ts: number;
run_id: string;
filename?: string;
notes?: string;
}
/** Only used in memory */
export interface BacktestMetadataWithStrategyName extends BacktestMetadata {
strategyName: string;
editing: boolean;
}
export interface BacktestMetadataPatch {
notes: string;
strategy: string;
}
export interface BacktestResultUpdate extends BacktestMetadataPatch {
run_id: string;
filename: string;
}
/**
* Represents the in-memory result of a backtest.
*/
export interface BacktestResultInMemory {
strategy: StrategyBacktestResult;
metadata: BacktestMetadataWithStrategyName;
}
export interface BacktestResult { export interface BacktestResult {
strategy: Record<string, StrategyBacktestResult>; strategy: Record<string, StrategyBacktestResult>;
strategy_comparison: Array<Record<string, string | number>>; strategy_comparison: Array<Record<string, string | number>>;
metadata: Record<string, BacktestMetadata>;
} }
export enum BacktestSteps { export enum BacktestSteps {
@ -206,4 +244,5 @@ export interface BacktestHistoryEntry {
strategy: string; strategy: string;
run_id: string; run_id: string;
backtest_start_time: number; backtest_start_time: number;
notes?: string;
} }

View File

@ -11,4 +11,5 @@ export interface ComparisonTableItems {
losses: number; losses: number;
balance?: number; balance?: number;
stakeCurrencyDecimals?: number; stakeCurrencyDecimals?: number;
isDryRun?: boolean;
} }

View File

@ -1,8 +1,13 @@
export interface DailyPayload { export enum TimeSummaryOptions {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface TimeSummaryPayload {
timescale?: number; timescale?: number;
} }
export interface DailyRecord { export interface TimeSummaryRecord {
/** Date in the format yyyy-mm-dd */ /** Date in the format yyyy-mm-dd */
[key: string]: string | number; [key: string]: string | number;
date: string; date: string;
@ -14,8 +19,8 @@ export interface DailyRecord {
trade_count: number; trade_count: number;
} }
export interface DailyReturnValue { export interface TimeSummaryReturnValue {
data: DailyRecord[]; data: TimeSummaryRecord[];
fiat_display_currency: string; fiat_display_currency: string;
stake_currency: string; stake_currency: string;
} }

View File

@ -41,8 +41,15 @@ export interface ProfitInterface {
profit_factor?: number; profit_factor?: number;
max_drawdown?: number; max_drawdown?: number;
max_drawdown_abs?: number; max_drawdown_abs?: number;
max_drawdown_start?: string;
max_drawdown_start_timestamp?: number;
max_drawdown_end?: string;
max_drawdown_end_timestamp?: number;
trading_volume?: number; trading_volume?: number;
/** Initial bot start date*/ /** Initial bot start date*/
bot_start_timestamp?: number; bot_start_timestamp?: number;
bot_start_date?: string; bot_start_date?: string;
winrate?: number;
expectancy?: number;
expectancy_ratio?: number;
} }

View File

@ -92,7 +92,10 @@ interface TradeBase {
initial_stop_loss_ratio?: number; initial_stop_loss_ratio?: number;
initial_stop_loss_pct?: number; initial_stop_loss_pct?: number;
/** deprecated, to be replaced with "has_open_orders" */
open_order_id?: string; open_order_id?: string;
/** Added only recently, replaces open_order_id */
has_open_orders?: boolean;
/** Short properties - only available in API versions 2.x and up */ /** Short properties - only available in API versions 2.x and up */
is_short?: boolean; is_short?: boolean;
leverage?: number; leverage?: number;

View File

@ -1,79 +1,79 @@
<template> <template>
<div class="container-fluid" style="max-height: calc(100vh - 60px)"> <div class="d-flex flex-column pt-1 me-1" style="height: calc(100vh - 60px)">
<div class="container-fluid"> <div>
<div class="row mb-2"></div> <div class="d-flex flex-row">
<p v-if="!botStore.activeBot.canRunBacktest"> <h2 class="ms-5">Backtesting</h2>
Bot must be in webserver mode to enable Backtesting. <p v-if="!botStore.activeBot.canRunBacktest">
</p> Bot must be in webserver mode to enable Backtesting.
<div class="row w-100"> </p>
<h2 class="col-4 col-lg-3">Backtesting</h2> <div class="w-100">
<div <div
class="col-12 col-lg-order-last col-lg-6 mx-md-5 d-flex flex-wrap justify-content-md-center justify-content-between mb-4" class="mx-md-5 d-flex flex-wrap justify-content-md-center justify-content-between mb-4 gap-2"
>
<b-form-radio
v-if="botStore.activeBot.botApiVersion >= 2.15"
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="historicResults"
:disabled="!botStore.activeBot.canRunBacktest"
>Load Results</b-form-radio
> >
<b-form-radio <b-form-radio
v-model="btFormMode" v-if="botStore.activeBot.botApiVersion >= 2.15"
name="bt-form-radios" v-model="btFormMode"
button name="bt-form-radios"
class="mx-1 flex-samesize-items" button
value="run" class="mx-1 flex-samesize-items"
:disabled="!botStore.activeBot.canRunBacktest" value="historicResults"
>Run backtest</b-form-radio :disabled="!botStore.activeBot.canRunBacktest"
> >Load Results</b-form-radio
<b-form-radio >
id="bt-analyze-btn" <b-form-radio
v-model="btFormMode" v-model="btFormMode"
name="bt-form-radios" name="bt-form-radios"
button button
class="mx-1 flex-samesize-items" class="mx-1 flex-samesize-items"
value="results" value="run"
:disabled="!hasBacktestResult" :disabled="!botStore.activeBot.canRunBacktest"
>Analyze result</b-form-radio >Run backtest</b-form-radio
> >
<b-form-radio <b-form-radio
v-model="btFormMode" id="bt-analyze-btn"
name="bt-form-radios" v-model="btFormMode"
button name="bt-form-radios"
class="mx-1 flex-samesize-items" button
value="visualize-summary" class="mx-1 flex-samesize-items"
:disabled="!hasBacktestResult" value="results"
>Visualize summary</b-form-radio :disabled="!hasBacktestResult"
> >Analyze result</b-form-radio
<b-form-radio >
v-model="btFormMode" <b-form-radio
name="bt-form-radios" v-model="btFormMode"
button name="bt-form-radios"
class="mx-1 flex-samesize-items" button
value="visualize" class="mx-1 flex-samesize-items"
:disabled="!hasBacktestResult" value="visualize-summary"
>Visualize result</b-form-radio :disabled="!hasBacktestResult"
>Visualize summary</b-form-radio
>
<b-form-radio
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="visualize"
:disabled="!hasBacktestResult"
>Visualize result</b-form-radio
>
</div>
<small v-show="botStore.activeBot.backtestRunning" class="text-end bt-running-label"
>Backtest running: {{ botStore.activeBot.backtestStep }}
{{ formatPercent(botStore.activeBot.backtestProgress, 2) }}</small
> >
</div> </div>
<small
v-show="botStore.activeBot.backtestRunning"
class="text-end bt-running-label col-8 col-lg-3"
>Backtest running: {{ botStore.activeBot.backtestStep }}
{{ formatPercent(botStore.activeBot.backtestProgress, 2) }}</small
>
</div> </div>
</div> </div>
<div class="d-flex flex-md-row">
<div class="d-md-flex">
<!-- Left bar --> <!-- Left bar -->
<div <div
:class="`${showLeftBar ? 'col-md-3' : ''} sticky-top sticky-offset me-3 d-flex flex-column`" v-if="btFormMode !== 'visualize'"
:class="`${showLeftBar ? 'col-md-3' : ''}`"
class="sticky-top sticky-offset me-3 d-flex flex-column absolute"
style="max-height: calc(100vh - 60px)"
> >
<b-button <b-button
v-if="btFormMode !== 'visualize'"
class="align-self-start" class="align-self-start"
aria-label="Close" aria-label="Close"
size="sm" size="sm"
@ -85,262 +85,71 @@
v-if="btFormMode !== 'visualize' && showLeftBar" v-if="btFormMode !== 'visualize' && showLeftBar"
:backtest-history="botStore.activeBot.backtestHistory" :backtest-history="botStore.activeBot.backtestHistory"
:selected-backtest-result-key="botStore.activeBot.selectedBacktestResultKey" :selected-backtest-result-key="botStore.activeBot.selectedBacktestResultKey"
:can-use-modify="botStore.activeBot.botApiVersion >= 2.32"
@selection-change="botStore.activeBot.setBacktestResultKey" @selection-change="botStore.activeBot.setBacktestResultKey"
@remove-result="botStore.activeBot.removeBacktestResultFromMemory"
@update-result="botStore.activeBot.saveBacktestResultMetadata"
/> />
</transition> </transition>
</div> </div>
<!-- End Left bar --> <!-- End Left bar -->
<div <div class="d-flex flex-column flex-fill mw-100">
v-if="btFormMode == 'historicResults'" <div class="d-md-flex">
class="flex-fill row d-flex flex-column bt-config" <div
> v-if="btFormMode == 'historicResults'"
<backtest-history-load /> class="flex-fill d-flex flex-column bt-config"
</div>
<div v-if="btFormMode == 'run'" class="flex-fill row d-flex flex-column bt-config">
<div class="mb-2">
<span>Strategy</span>
<StrategySelect v-model="strategy"></StrategySelect>
</div>
<b-card :disabled="botStore.activeBot.backtestRunning">
<!-- Backtesting parameters -->
<b-form-group
label-cols-lg="2"
label="Backtest params"
label-size="sm"
label-class="fw-bold pt-0"
class="mb-0"
> >
<b-form-group <BacktestHistoryLoad />
label-cols-sm="5" </div>
label="Timeframe:" <div v-if="btFormMode == 'run'" class="flex-fill d-flex flex-column bt-config">
label-align-sm="right" <BacktestRun />
label-for="timeframe-select" </div>
> <BacktestResultAnalysis
<TimeframeSelect id="timeframe-select" v-model="selectedTimeframe" /> v-if="hasBacktestResult && btFormMode == 'results'"
</b-form-group> :backtest-result="botStore.activeBot.selectedBacktestResult"
<b-form-group class="flex-fill"
label-cols-sm="5" />
label="Detail Timeframe:"
label-align-sm="right"
label-for="timeframe-detail-select"
title="Detail timeframe, to simulate intra-candle results. Not setting this will not use this functionality."
>
<TimeframeSelect
id="timeframe-detail-select"
v-model="selectedDetailTimeframe"
:below-timeframe="selectedTimeframe"
/>
</b-form-group>
<b-form-group <BacktestGraphs
label-cols-sm="5" v-if="hasBacktestResult && btFormMode == 'visualize-summary'"
label="Max open trades:" :trades="botStore.activeBot.selectedBacktestResult.trades"
label-align-sm="right" class="flex-fill"
label-for="max-open-trades" />
> </div>
<b-form-input
id="max-open-trades"
v-model="maxOpenTrades"
placeholder="Use strategy default"
type="number"
></b-form-input>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Starting capital:"
label-align-sm="right"
label-for="starting-capital"
>
<b-form-input
id="starting-capital"
v-model="startingCapital"
type="number"
step="0.001"
></b-form-input>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Stake amount:"
label-align-sm="right"
label-for="stake-amount"
>
<div class="d-flex">
<b-form-checkbox
id="stake-amount-bool"
v-model="stakeAmountUnlimited"
class="col-md-6"
>Unlimited stake</b-form-checkbox
>
<b-form-input
id="stake-amount"
v-model="stakeAmount"
type="number"
placeholder="Use strategy default"
step="0.01"
:disabled="stakeAmountUnlimited"
></b-form-input>
</div>
</b-form-group>
<b-form-group
label-cols-sm="5"
label="Enable Protections:"
label-align-sm="right"
label-for="enable-protections"
>
<b-form-checkbox
id="enable-protections"
v-model="enableProtections"
></b-form-checkbox>
</b-form-group>
<b-form-group
v-if="botStore.activeBot.botApiVersion >= 2.22"
label-cols-sm="5"
label="Cache Backtest results:"
label-align-sm="right"
label-for="enable-cache"
>
<b-form-checkbox id="enable-cache" v-model="allowCache"></b-form-checkbox>
</b-form-group>
<template v-if="botStore.activeBot.botApiVersion >= 2.22">
<b-form-group
label-cols-sm="5"
label="Enable FreqAI:"
label-align-sm="right"
label-for="enable-freqai"
>
<template #label>
<div class="d-flex justify-content-center">
<span class="me-2">Enable FreqAI:</span>
<InfoBox
hint="Assumes freqAI configuration is setup in the configuration, and the strategy is a freqAI strategy. Will fail if that's not the case."
/>
</div>
</template>
<b-form-checkbox id="enable-freqai" v-model="freqAI.enabled"></b-form-checkbox>
</b-form-group>
<b-form-group
v-if="freqAI.enabled"
label-cols-sm="5"
label="FreqAI identifier:"
label-align-sm="right"
label-for="freqai-identifier"
>
<b-form-input
id="freqai-identifier"
v-model="freqAI.identifier"
placeholder="Use config default"
></b-form-input>
</b-form-group>
<b-form-group
v-if="freqAI.enabled"
label-cols-sm="5"
label="FreqAI Model"
label-align-sm="right"
label-for="freqai-model"
>
<FreqaiModelSelect id="freqai-model" v-model="freqAI.model"></FreqaiModelSelect>
</b-form-group>
</template>
<!-- <b-form-group label-cols-sm="5" label="Fee:" label-align-sm="right" label-for="fee">
<b-form-input
id="fee"
type="number"
placeholder="Use exchange default"
step="0.01"
></b-form-input>
</b-form-group> -->
<hr />
<TimeRangeSelect v-model="timerange" class="mt-2"></TimeRangeSelect>
</b-form-group>
</b-card>
<h3 class="mt-3">Backtesting summary</h3>
<div <div
class="d-flex flex-wrap flex-md-nowrap justify-content-between justify-content-md-center" v-if="hasBacktestResult && btFormMode == 'visualize'"
class="container-fluid text-center w-100 mt-2"
> >
<b-button <BacktestResultChart
id="start-backtest" :timeframe="timeframe"
variant="primary" :strategy="btStore.strategy"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest" :timerange="btStore.timerange"
class="mx-1" :pairlist="botStore.activeBot.selectedBacktestResult.pairlist"
@click="clickBacktest" :trades="botStore.activeBot.selectedBacktestResult.trades"
> :freqai-model="btStore.freqAI.enabled ? btStore.freqAI.model : undefined"
Start backtest />
</b-button>
<b-button
variant="primary"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
class="mx-1"
@click="botStore.activeBot.pollBacktest"
>
Load backtest result
</b-button>
<b-button
variant="primary"
class="mx-1"
:disabled="!botStore.activeBot.backtestRunning"
@click="botStore.activeBot.stopBacktest"
>Stop Backtest</b-button
>
<b-button
variant="primary"
class="mx-1"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
@click="botStore.activeBot.removeBacktest"
>Reset Backtest</b-button
>
</div> </div>
</div> </div>
<BacktestResultView
v-if="hasBacktestResult && btFormMode == 'results'"
:backtest-result="botStore.activeBot.selectedBacktestResult"
class="flex-fill"
/>
<BacktestGraphsView
v-if="hasBacktestResult && btFormMode == 'visualize-summary'"
:trades="botStore.activeBot.selectedBacktestResult.trades"
/>
</div>
<div
v-if="hasBacktestResult && btFormMode == 'visualize'"
class="container-fluid text-center w-100 mt-2"
>
<BacktestResultChart
:timeframe="timeframe"
:strategy="strategy"
:timerange="timerange"
:pairlist="botStore.activeBot.selectedBacktestResult.pairlist"
:trades="botStore.activeBot.selectedBacktestResult.trades"
:freqai-model="freqAI.enabled ? freqAI.model : undefined"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import TimeRangeSelect from '@/components/ftbot/TimeRangeSelect.vue'; import BacktestGraphs from '@/components/ftbot/BacktestGraphs.vue';
import BacktestResultView from '@/components/ftbot/BacktestResultView.vue';
import BacktestResultSelect from '@/components/ftbot/BacktestResultSelect.vue';
import StrategySelect from '@/components/ftbot/StrategySelect.vue';
import FreqaiModelSelect from '@/components/ftbot/FreqaiModelSelect.vue';
import TimeframeSelect from '@/components/ftbot/TimeframeSelect.vue';
import BacktestHistoryLoad from '@/components/ftbot/BacktestHistoryLoad.vue'; import BacktestHistoryLoad from '@/components/ftbot/BacktestHistoryLoad.vue';
import BacktestGraphsView from '@/components/ftbot/BacktestGraphsView.vue';
import BacktestResultChart from '@/components/ftbot/BacktestResultChart.vue'; import BacktestResultChart from '@/components/ftbot/BacktestResultChart.vue';
import InfoBox from '@/components/general/InfoBox.vue'; import BacktestResultSelect from '@/components/ftbot/BacktestResultSelect.vue';
import BacktestResultAnalysis from '@/components/ftbot/BacktestResultAnalysis.vue';
import { BacktestPayload } from '@/types'; import BacktestRun from '@/components/ftbot/BacktestRun.vue';
import { formatPercent } from '@/shared/formatters'; import { formatPercent } from '@/shared/formatters';
import { computed, ref, onMounted, watch } from 'vue'; import { useBtStore } from '@/stores/btStore';
import { useBotStore } from '@/stores/ftbotwrapper'; import { useBotStore } from '@/stores/ftbotwrapper';
import { computed, onMounted, ref, watch } from 'vue';
const botStore = useBotStore(); const botStore = useBotStore();
const btStore = useBtStore();
const hasBacktestResult = computed(() => const hasBacktestResult = computed(() =>
botStore.activeBot.backtestHistory botStore.activeBot.backtestHistory
@ -355,33 +164,20 @@ const timeframe = computed((): string => {
} }
}); });
const strategy = ref('');
const selectedTimeframe = ref('');
const selectedDetailTimeframe = ref('');
const timerange = ref('');
const showLeftBar = ref(false); const showLeftBar = ref(false);
const freqAI = ref({
enabled: false,
model: '',
identifier: '',
});
const enableProtections = ref(false);
const stakeAmountUnlimited = ref(false);
const allowCache = ref(true);
const maxOpenTrades = ref('');
const stakeAmount = ref('');
const startingCapital = ref('');
const btFormMode = ref('run'); const btFormMode = ref('run');
const pollInterval = ref<number | null>(null); const pollInterval = ref<number | null>(null);
const selectBacktestResult = () => { const selectBacktestResult = () => {
// Set parameters for this result // Set parameters for this result
strategy.value = botStore.activeBot.selectedBacktestResult.strategy_name; btStore.strategy = botStore.activeBot.selectedBacktestResult.strategy_name;
botStore.activeBot.getStrategy(strategy.value); botStore.activeBot.getStrategy(btStore.strategy);
selectedTimeframe.value = botStore.activeBot.selectedBacktestResult.timeframe; btStore.selectedTimeframe = botStore.activeBot.selectedBacktestResult.timeframe;
selectedDetailTimeframe.value = botStore.activeBot.selectedBacktestResult.timeframe_detail || ''; btStore.selectedDetailTimeframe =
botStore.activeBot.selectedBacktestResult.timeframe_detail || '';
// TODO: maybe this should not use timerange, but the actual backtest start/end results instead? // TODO: maybe this should not use timerange, but the actual backtest start/end results instead?
timerange.value = botStore.activeBot.selectedBacktestResult.timerange; btStore.timerange = botStore.activeBot.selectedBacktestResult.timerange;
}; };
watch( watch(
@ -391,49 +187,6 @@ watch(
}, },
); );
const clickBacktest = () => {
const btPayload: BacktestPayload = {
strategy: strategy.value,
timerange: timerange.value,
enable_protections: enableProtections.value,
};
const openTradesInt = parseInt(maxOpenTrades.value, 10);
if (openTradesInt) {
btPayload.max_open_trades = openTradesInt;
}
if (stakeAmountUnlimited.value) {
btPayload.stake_amount = 'unlimited';
} else {
const stakeAmountLoc = Number(stakeAmount.value);
if (stakeAmountLoc) {
btPayload.stake_amount = stakeAmountLoc.toString();
}
}
const startingCapitalLoc = Number(startingCapital.value);
if (startingCapitalLoc) {
btPayload.dry_run_wallet = startingCapitalLoc;
}
if (selectedTimeframe.value) {
btPayload.timeframe = selectedTimeframe.value;
}
if (selectedDetailTimeframe.value) {
btPayload.timeframe_detail = selectedDetailTimeframe.value;
}
if (!allowCache.value) {
btPayload.backtest_cache = 'none';
}
if (freqAI.value.enabled) {
btPayload.freqaimodel = freqAI.value.model;
if (freqAI.value.identifier !== '') {
btPayload.freqai = { identifier: freqAI.value.identifier };
}
}
botStore.activeBot.startBacktest(btPayload);
};
onMounted(() => botStore.activeBot.getState()); onMounted(() => botStore.activeBot.getState());
watch( watch(
() => botStore.activeBot.backtestRunning, () => botStore.activeBot.backtestRunning,

View File

@ -13,7 +13,7 @@
:cols="{ lg: 12, md: 12, sm: 12, xs: 4, xxs: 2 }" :cols="{ lg: 12, md: 12, sm: 12, xs: 4, xxs: 2 }"
:col-num="12" :col-num="12"
@layout-updated="layoutUpdatedEvent" @layout-updated="layoutUpdatedEvent"
@breakpoint-changed="breakpointChanged" @update:breakpoint="breakpointChanged"
> >
<template #default="{ gridItemProps }"> <template #default="{ gridItemProps }">
<grid-item <grid-item
@ -28,7 +28,7 @@
drag-allow-from=".drag-header" drag-allow-from=".drag-header"
> >
<DraggableContainer :header="`Daily Profit ${botStore.botCount > 1 ? 'combined' : ''}`"> <DraggableContainer :header="`Daily Profit ${botStore.botCount > 1 ? 'combined' : ''}`">
<DailyChart <TimePeriodChart
v-if="botStore.allDailyStatsSelectedBots" v-if="botStore.allDailyStatsSelectedBots"
:daily-stats="botStore.allDailyStatsSelectedBots" :daily-stats="botStore.allDailyStatsSelectedBots"
:show-title="false" :show-title="false"
@ -159,7 +159,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import DailyChart from '@/components/charts/DailyChart.vue'; import TimePeriodChart from '@/components/charts/TimePeriodChart.vue';
import CumProfitChart from '@/components/charts/CumProfitChart.vue'; import CumProfitChart from '@/components/charts/CumProfitChart.vue';
import TradesLogChart from '@/components/charts/TradesLog.vue'; import TradesLogChart from '@/components/charts/TradesLog.vue';
import ProfitDistributionChart from '@/components/charts/ProfitDistributionChart.vue'; import ProfitDistributionChart from '@/components/charts/ProfitDistributionChart.vue';
@ -177,8 +177,8 @@ const botStore = useBotStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const currentBreakpoint = ref(''); const currentBreakpoint = ref('');
const breakpointChanged = (newBreakpoint) => { const breakpointChanged = (newBreakpoint: string) => {
// // console.log('breakpoint:', newBreakpoint); // console.log('breakpoint:', newBreakpoint);
currentBreakpoint.value = newBreakpoint; currentBreakpoint.value = newBreakpoint;
}; };
const isResizableLayout = computed(() => const isResizableLayout = computed(() =>
@ -235,7 +235,7 @@ const responsiveGridLayouts = computed(() => {
}); });
onMounted(async () => { onMounted(async () => {
await botStore.allGetDaily({ timescale: 30 }); botStore.allGetDaily({ timescale: 30 });
// botStore.activeBot.getTrades(); // botStore.activeBot.getTrades();
botStore.activeBot.getOpenTrades(); botStore.activeBot.getOpenTrades();
botStore.activeBot.getProfit(); botStore.activeBot.getProfit();

View File

@ -45,12 +45,12 @@
<b-tab title="Balance" lazy> <b-tab title="Balance" lazy>
<Balance /> <Balance />
</b-tab> </b-tab>
<b-tab title="Daily Stats" lazy> <b-tab title="Time Breakdown" lazy>
<DailyStats /> <PeriodBreakdown />
</b-tab> </b-tab>
<b-tab title="Pairlist" lazy> <b-tab title="Pairlist" lazy>
<FTBotAPIPairList /> <PairListLive />
</b-tab> </b-tab>
<b-tab title="Pair Locks" lazy> <b-tab title="Pair Locks" lazy>
<PairLockList /> <PairLockList />
@ -152,9 +152,9 @@ import Balance from '@/components/ftbot/BotBalance.vue';
import BotControls from '@/components/ftbot/BotControls.vue'; import BotControls from '@/components/ftbot/BotControls.vue';
import BotStatus from '@/components/ftbot/BotStatus.vue'; import BotStatus from '@/components/ftbot/BotStatus.vue';
import CandleChartContainer from '@/components/charts/CandleChartContainer.vue'; import CandleChartContainer from '@/components/charts/CandleChartContainer.vue';
import DailyStats from '@/components/ftbot/DailyStats.vue'; import PeriodBreakdown from '@/components/ftbot/PeriodBreakdown.vue';
import DraggableContainer from '@/components/layout/DraggableContainer.vue'; import DraggableContainer from '@/components/layout/DraggableContainer.vue';
import FTBotAPIPairList from '@/components/ftbot/FTBotAPIPairList.vue'; import PairListLive from '@/components/ftbot/PairListLive.vue';
import PairLockList from '@/components/ftbot/PairLockList.vue'; import PairLockList from '@/components/ftbot/PairLockList.vue';
import PairSummary from '@/components/ftbot/PairSummary.vue'; import PairSummary from '@/components/ftbot/PairSummary.vue';
import BotPerformance from '@/components/ftbot/BotPerformance.vue'; import BotPerformance from '@/components/ftbot/BotPerformance.vue';
@ -169,7 +169,7 @@ const botStore = useBotStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const currentBreakpoint = ref(''); const currentBreakpoint = ref('');
const breakpointChanged = (newBreakpoint) => { const breakpointChanged = (newBreakpoint: string) => {
// console.log('breakpoint:', newBreakpoint); // console.log('breakpoint:', newBreakpoint);
currentBreakpoint.value = newBreakpoint; currentBreakpoint.value = newBreakpoint;
}; };

View File

@ -8,6 +8,7 @@ import {
dateStringToTimeRange, dateStringToTimeRange,
timestampHour, timestampHour,
dateFromString, dateFromString,
timestampmsOrNa,
} from '@/shared/formatters'; } from '@/shared/formatters';
const { getTimeZone } = exportForTesting; const { getTimeZone } = exportForTesting;
@ -31,6 +32,8 @@ describe('timeformatter.ts', () => {
expect(timestampmsWithTimezone(1651057500000)).toEqual('2022-04-27 11:05:00 (UTC)'); expect(timestampmsWithTimezone(1651057500000)).toEqual('2022-04-27 11:05:00 (UTC)');
setTimezone('UTC'); setTimezone('UTC');
expect(timestampmsWithTimezone(1651057500000)).toEqual('2022-04-27 11:05:00 (UTC)'); expect(timestampmsWithTimezone(1651057500000)).toEqual('2022-04-27 11:05:00 (UTC)');
expect(timestampmsWithTimezone(0)).toEqual('N/A');
expect(timestampmsWithTimezone(null)).toEqual('N/A');
}); });
it('timestampms convert correctly', () => { it('timestampms convert correctly', () => {
setTimezone('UTC'); setTimezone('UTC');
@ -38,6 +41,16 @@ describe('timeformatter.ts', () => {
setTimezone('CET'); setTimezone('CET');
expect(timestampms(1651057500000)).toEqual('2022-04-27 13:05:00'); expect(timestampms(1651057500000)).toEqual('2022-04-27 13:05:00');
}); });
it('timestampmsOrNA convert correctly', () => {
setTimezone('UTC');
expect(timestampmsOrNa(1651057500000)).toEqual('2022-04-27 11:05:00');
setTimezone('CET');
expect(timestampmsOrNa(1651057500000)).toEqual('2022-04-27 13:05:00');
expect(timestampmsOrNa(0)).toEqual('N/A');
expect(timestampmsOrNa(null)).toEqual('N/A');
});
it('timestampToDateString converts to date', () => { it('timestampToDateString converts to date', () => {
expect(timestampToDateString(1651057500000)).toEqual('2022-04-27'); expect(timestampToDateString(1651057500000)).toEqual('2022-04-27');

1361
yarn.lock

File diff suppressed because it is too large Load Diff