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
'no-shadow': 'off',
'prettier/prettier': ['error'],
'@typescript-eslint/no-explicit-any': 'warn',
// '@typescript-eslint/naming-convention': [
// '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**
A clear and concise description of what the bug is.

View File

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

View File

@ -21,10 +21,10 @@ jobs:
strategy:
matrix:
os: [ ubuntu-22.04 ]
node: [ "16", "18", "19", "20"]
node: [ "16", "18", "20"]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
@ -42,7 +42,7 @@ jobs:
run: yarn test:unit
- name: Run Component tests
uses: cypress-io/github-action@v5
uses: cypress-io/github-action@v6
with:
# we have already installed everything
install: false
@ -50,7 +50,7 @@ jobs:
command: yarn cypress run --component
- name: Cypress run
uses: cypress-io/github-action@v5
uses: cypress-io/github-action@v6
with:
# build: yarn build
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
@ -19,7 +19,7 @@ COPY . /app
# webpack and node17
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/nginx.conf /etc/nginx/nginx.conf
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/locks', { fixture: 'locks_empty.json' }).as('Locks');
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', () => {
@ -60,5 +63,18 @@ describe('Trade', () => {
cy.get('.drag-header').contains('Closed Trades').scrollIntoView().should('be.visible');
cy.get('span').contains('TRX/USDT').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,
"trade_count": null,
"backtest_result": {
"metadata": {
"SampleStrategy": {
"strategy": "SampleStrategy",
"run_id": "0afc3cf414b6da90210f458078a6b06055b0c4e7",
"filename": "backtest-result-2023-08-03_04-02-20"
}
},
"strategy": {
"SampleStrategy": {
"trades": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,6 +37,9 @@ import {
TooltipComponent,
VisualMapComponent,
VisualMapPiecewiseComponent,
// MarkAreaComponent,
MarkLineComponent,
// MarkPointComponent,
} from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
@ -54,6 +57,9 @@ use([
TooltipComponent,
VisualMapComponent,
VisualMapPiecewiseComponent,
// MarkAreaComponent,
MarkLineComponent,
// MarkPointComponent,
CandlestickChart,
BarChart,
@ -135,7 +141,8 @@ function updateChart(initial = false) {
if (chartOptions.value?.title) {
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 colOpen = columns.findIndex((el) => el === 'open');

View File

@ -18,22 +18,24 @@
<b-button class="ms-2" :disabled="!!!pair" size="sm" @click="refresh">
<i-mdi-refresh />
</b-button>
<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 && 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
>
<div class="d-flex flex-row flex-wrap">
<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 && 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
>
</div>
</div>
<div class="ms-auto d-flex align-items-center w-auto">
<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">
@ -121,7 +123,7 @@ const botStore = useBotStore();
const plotStore = usePlotConfigStore();
const pair = ref('');
const showPlotConfig = ref(props.plotConfigModal);
const showPlotConfig = ref<boolean>();
const dataset = computed((): PairHistory => {
if (props.historicView) {
@ -159,14 +161,16 @@ const noDatasetText = computed((): string => {
}
});
const showPlotConfigModal = ref(false);
const showConfigurator = () => {
function showConfigurator() {
if (props.plotConfigModal) {
showPlotConfigModal.value = !showPlotConfigModal.value;
} else {
showPlotConfig.value = !showPlotConfig.value;
}
};
const refresh = () => {
}
function refresh() {
console.log('refresh', pair.value, props.timeframe);
if (pair.value && props.timeframe) {
if (props.historicView) {
@ -181,11 +185,10 @@ const refresh = () => {
botStore.activeBot.getPairCandles({
pair: pair.value,
timeframe: props.timeframe,
limit: 500,
});
}
}
};
}
watch(
() => props.availablePairs,
@ -206,6 +209,7 @@ watch(
);
onMounted(() => {
showPlotConfig.value = props.plotConfigModal;
if (botStore.activeBot.selectedPair) {
pair.value = botStore.activeBot.selectedPair;
} else if (props.availablePairs.length > 0) {

View File

@ -56,7 +56,6 @@ const props = defineProps({
});
const settingsStore = useSettingsStore();
// const botList = ref<string[]>([]);
// const cumulativeData = ref<{ date: number; profit: any }[]>([]);
const chart = ref<typeof ECharts>();
@ -107,20 +106,26 @@ const cumulativeData = computed<CumProfitChartData[]>(() => {
},
);
if (props.openTrades.length > 0 && valueArray.length > 0) {
const lastPoint = valueArray[valueArray.length - 1];
if (lastPoint) {
const resultWitHOpen = (lastPoint.profit ?? 0) + openProfit.value;
valueArray.push({ date: lastPoint.date, currentProfit: lastPoint.profit });
// 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 });
if (props.openTrades.length > 0) {
let lastProfit = 0;
let lastDate = 0;
if (valueArray.length > 0) {
const lastPoint = valueArray[valueArray.length - 1];
lastProfit = lastPoint.profit ?? 0;
lastDate = lastPoint.date ?? 0;
} 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;
});
function updateChart(initial = false) {
function generateChart(initial = false) {
const { colorProfit, colorLoss } = settingsStore;
const chartOptionsLoc: EChartsOption = {
dataset: {
@ -167,7 +172,6 @@ function updateChart(initial = false) {
},
],
};
// TODO: maybe have profit lines per bot?
// this.botList.forEach((botId: string) => {
// console.log('bot', botId);
@ -184,6 +188,10 @@ function updateChart(initial = false) {
// // symbol: 'none',
// });
// });
return chartOptionsLoc;
}
function updateChart(initial = false) {
const chartOptionsLoc = generateChart(initial);
chart.value?.setOption(chartOptionsLoc, {
replaceMerge: ['series', 'dataset'],
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 });
updateChart(true);
}

View File

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

View File

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

View File

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

View File

@ -12,32 +12,64 @@
Load Historic results from disk. You can click on multiple results to load all of them into
freqUI.
</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
v-for="(res, idx) in botStore.activeBot.backtestHistoryList"
: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
:disabled="res.run_id in botStore.activeBot.backtestHistory"
@click="botStore.activeBot.getBacktestHistoryResult(res)"
>
<strong>{{ res.strategy }}</strong>
backtested on: {{ timestampms(res.backtest_start_time * 1000) }}
<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>
</div>
<MessageBox ref="msgBox" />
</template>
<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 { useBotStore } from '@/stores/ftbotwrapper';
import { BacktestHistoryEntry } from '@/types';
import InfoBox from '../general/InfoBox.vue';
const botStore = useBotStore();
const msgBox = ref<typeof MessageBox>();
onMounted(() => {
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>
<style lang="scss" scoped></style>

View File

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

View File

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

View File

@ -1,16 +1,56 @@
<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>
<b-list-group class="ms-2">
<b-list-group-item
v-for="[key, strat] in Object.entries(backtestHistory)"
v-for="[key, result] in Object.entries(backtestHistory)"
:key="key"
button
: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)"
>
{{ 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>
</div>
@ -18,19 +58,37 @@
<script setup lang="ts">
import { formatPercent } from '@/shared/formatters';
import { StrategyBacktestResult } from '@/types';
import { BacktestResultInMemory, BacktestResultUpdate } from '@/types';
defineProps({
backtestHistory: {
required: true,
type: Object as () => Record<string, StrategyBacktestResult>,
type: Object as () => Record<string, BacktestResultInMemory>,
},
selectedBacktestResultKey: { required: false, default: '', type: String },
canUseModify: { required: false, default: false, type: Boolean },
});
const emit = defineEmits(['selectionChange']);
const setBacktestResult = (key) => {
const emit = defineEmits<{
selectionChange: [value: string];
removeResult: [value: string];
updateResult: [value: BacktestResultUpdate];
}>();
const setBacktestResult = (key: string) => {
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>
<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>
<b-table class="table-sm" :items="balanceCurrencies" :fields="tableFields">
<template #custom-foot>
<td><strong>Total</strong></td>
<td>
<td class="pt-1"><strong>Total</strong></td>
<td class="pt-1">
<span
class="font-italic"
:title="`Increase over initial capital of ${formatCurrency(
botStore.activeBot.balance.starting_capital,
)} ${botStore.activeBot.balance.stake}`"
>{{ formatPercent(botStore.activeBot.balance.starting_capital_ratio) }}</span
>
{{ formatPercent(botStore.activeBot.balance.starting_capital_ratio) }}
</span>
</td>
<!-- this is a computed prop that adds up all the expenses in the visible rows -->
<td>
<strong>{{
showBotOnly && canUseBotBalance
? formatCurrency(botStore.activeBot.balance.total_bot)
: formatCurrency(botStore.activeBot.balance.total)
}}</strong>
<td class="pt-1">
<strong>
{{
showBotOnly && canUseBotBalance
? formatCurrency(botStore.activeBot.balance.total_bot)
: formatCurrency(botStore.activeBot.balance.total)
}}
</strong>
</td>
</template>
</b-table>

View File

@ -12,41 +12,50 @@
<div class="d-flex flex-row">
<b-form-checkbox
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"
/>
<span>{{ row.value }}</span>
</div>
</template>
<template #cell(profitOpen)="row">
<template #cell(profitOpen)="{ item }">
<profit-pill
v-if="row.item.profitOpen && row.item.botId != 'Summary'"
:profit-ratio="row.item.profitOpenRatio"
:profit-abs="row.item.profitOpen"
:stake-currency="row.item.stakeCurrency"
v-if="item.profitOpen && item.botId != 'Summary'"
:profit-ratio="(item as unknown as ComparisonTableItems).profitOpenRatio"
:profit-abs="(item as unknown as ComparisonTableItems).profitOpen"
:stake-currency="(item as unknown as ComparisonTableItems).stakeCurrency"
/>
</template>
<template #cell(profitClosed)="row">
<template #cell(profitClosed)="{ item }">
<profit-pill
v-if="row.item.profitClosed && row.item.botId != 'Summary'"
:profit-ratio="row.item.profitClosedRatio"
:profit-abs="row.item.profitClosed"
:stake-currency="row.item.stakeCurrency"
v-if="item.profitClosed && item.botId != 'Summary'"
:profit-ratio="(item as unknown as ComparisonTableItems).profitClosedRatio"
:profit-abs="(item as unknown as ComparisonTableItems).profitClosed"
:stake-currency="(item as unknown as ComparisonTableItems).stakeCurrency"
/>
</template>
<template #cell(balance)="row">
<div v-if="row.item.balance">
<span :title="row.item.stakeCurrency"
>{{ formatPrice(row.item.balance, row.item.stakeCurrencyDecimals) }}
<template #cell(balance)="{ item }">
<div v-if="item.balance">
<span :title="(item as unknown as ComparisonTableItems).stakeCurrency"
>{{
formatPrice(
(item as unknown as ComparisonTableItems).balance ?? 0,
(item as unknown as ComparisonTableItems).stakeCurrencyDecimals,
)
}}
</span>
<span class="text-small">{{ row.item.stakeCurrency }}</span>
<span class="text-small">{{
` ${item.stakeCurrency}${item.isDryRun ? ' (dry)' : ''}`
}}</span>
</div>
</template>
<template #cell(winVsLoss)="row">
<div v-if="row.item.losses !== undefined">
<span class="text-profit">{{ row.item.wins }}</span> /
<span class="text-loss">{{ row.item.losses }}</span>
<template #cell(winVsLoss)="{ item }">
<div v-if="item.losses !== undefined">
<span class="text-profit">{{ item.wins }}</span> /
<span class="text-loss">{{ item.losses }}</span>
</div>
</template>
</b-table>
@ -113,13 +122,20 @@ const tableItems = computed<TableItem[]>(() => {
losses: v.losing_trades,
balance: botStore.allBalance[k]?.total_bot ?? botStore.allBalance[k]?.total,
stakeCurrencyDecimals: botStore.allBotState[k]?.stake_currency_decimals || 3,
isDryRun: botStore.allBotState[k]?.dry_run,
});
if (v.profit_closed_coin !== undefined) {
summary.profitClosed += v.profit_closed_coin;
summary.profitOpen += profitOpen;
summary.wins += v.winning_trades;
summary.losses += v.losing_trades;
// summary.decimals = this.allBotState[k]?.stake_currency_decimals || summary.decimals;
if (botStore.botStores[k].isSelected) {
// Summary should only include selected bots
summary.profitClosed += v.profit_closed_coin;
summary.profitOpen += profitOpen;
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);

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

View File

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

View File

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

View File

@ -30,6 +30,6 @@ defineProps<{
param: PairlistParameter;
}>();
//TODO: type should really be PairlistParamValue
// TODO: type should really be PairlistParamValue
const paramValue = defineModel<any>();
</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 '';
});
const updateInput = () => {
function updateInput() {
const tr = props.modelValue.split('-');
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 {
dateFrom.value = '';
}
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 {
dateTo.value = '';
}
emit('update:modelValue', timeRange.value);
};
}
watch(
() => timeRange.value,

View File

@ -37,7 +37,7 @@
<i-mdi-close-box-multiple class="me-1" />Forceexit partial
</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"
size="sm"
title="Cancel open orders"

View File

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

View File

@ -9,26 +9,48 @@
>Trade Navigation {{ sortNewestFirst ? '&#8595;' : '&#8593;' }}
</b-list-group-item>
<b-list-group-item
v-for="trade in sortedTrades"
v-for="(trade, i) in sortedTrades"
:key="trade.open_timestamp"
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}`"
:active="trade.open_timestamp === selectedTrade.open_timestamp"
@click="onTradeSelect(trade)"
>
<div>
<span v-if="botStore.activeBot.botState.trading_mode !== 'spot'">{{
trade.is_short ? 'S-' : 'L-'
}}</span>
<DateTimeTZ :date="trade.open_timestamp" />
<div class="d-flex">
<div class="d-flex flex-column">
<div>
<span v-if="botStore.activeBot.botState.trading_mode !== 'spot'">{{
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>
<TradeProfit :trade="trade" />
<ProfitPill
v-if="backtestMode"
:profit-ratio="trade.profit_ratio"
:stake-currency="botStore.activeBot.stakeCurrency"
/>
<b-collapse v-model="ordersVisible[i]">
<ul class="px-3 m-0">
<li
v-for="order in trade.orders?.filter((o) => o.order_filled_timestamp !== null)"
: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 v-if="trades.length === 0">No trades to show...</b-list-group-item>
</b-list-group>
@ -39,7 +61,7 @@
import { Trade } from '@/types';
import TradeProfit from '@/components/ftbot/TradeProfit.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 DateTimeTZ from '@/components/general/DateTimeTZ.vue';
@ -67,6 +89,15 @@ const sortedTrades = computed(() => {
: a.open_timestamp - b.open_timestamp,
);
});
const ordersVisible = ref(sortedTrades.value.map(() => false));
watch(
() => botStore.activeBot.selectedPair,
() => {
ordersVisible.value = sortedTrades.value.map(() => false);
},
);
</script>
<style scoped>

View File

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

View File

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

View File

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

View File

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

View File

@ -47,11 +47,12 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
// 4: color
// 5: label
// 6: tooltip
const stop_ts_adjusted = dataset.data_stop_ts + dataset.timeframe_ms;
for (let i = 0, len = trades.length; i < len; i += 1) {
const trade: Trade = trades[i];
if (
// 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 >= dataset.data_start_ts)
) {
@ -61,7 +62,7 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
if (
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
) {
// Trade entry
@ -79,7 +80,7 @@ export function getTradeEntries(dataset: PairHistory, trades: Trade[]) {
} else if (i === trade.orders.length - 1 && trade.close_timestamp) {
if (
roundTimeframe(dataset.timeframe_ms ?? 0, trade.close_timestamp) <=
dataset.data_stop_ts &&
stop_ts_adjusted &&
trade.close_timestamp > dataset.data_start_ts &&
trade.is_open === false
) {
@ -127,6 +128,8 @@ export function generateTradeSeries(
): ScatterSeriesOption {
const { tradeData } = getTradeEntries(dataset, trades);
const openTrades = trades.filter((t) => t.is_open);
const tradesSeries: ScatterSeriesOption = {
name: nameTrades,
type: 'scatter',
@ -158,6 +161,41 @@ export function generateTradeSeries(
symbolSize: 13,
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;
}

View File

@ -30,7 +30,7 @@ export function formatPrice(value: number | null, decimals = 15): string {
* @returns
*/
export function formatPriceCurrency(price: number | null, currency: string, decimals = 3) {
return `${formatPrice(price, decimals)} ${currency}`;
return `${formatPrice(price, decimals)} ${currency ?? ''}`;
}
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');
}
/**
* 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
* @param ts Timestamp as number or date (in utc!!)
* @param timezone timezone to use
* @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);
}

View File

@ -232,7 +232,6 @@ export class UserService {
* Call on startup to migrate old login info to new login
*/
public static migrateLogin() {
// TODO: this is actually never called!
const AUTH_REFRESH_TOKEN = 'auth_ref_token'; // Legacy key - do not use
const AUTH_ACCESS_TOKEN = 'auth_access_token';
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,
StrategyResult,
BalanceInterface,
DailyReturnValue,
TimeSummaryReturnValue,
LockResponse,
ProfitInterface,
BacktestResult,
StrategyBacktestResult,
BacktestSteps,
LogLine,
SysInfoResponse,
LoadingStatus,
BacktestHistoryEntry,
RunModes,
DailyPayload,
TimeSummaryPayload,
BlacklistResponse,
WhitelistResponse,
StrategyListResult,
@ -43,6 +42,11 @@ import {
PairlistEvalResponse,
PairlistsPayload,
PairlistsResponse,
BacktestResultInMemory,
BacktestMetadataWithStrategyName,
BacktestMetadataPatch,
BacktestResultUpdate,
TimeSummaryOptions,
} from '@/types';
import axios, { AxiosResponse } from 'axios';
import { defineStore } from 'pinia';
@ -78,7 +82,9 @@ export function createBotSubStore(botId: string, botName: string) {
profit: {} as ProfitInterface,
botState: {} as BotState,
balance: {} as BalanceInterface,
dailyStats: {} as DailyReturnValue,
dailyStats: {} as TimeSummaryReturnValue,
weeklyStats: {} as TimeSummaryReturnValue,
monthlyStats: {} as TimeSummaryReturnValue,
pairlistMethods: [] as string[],
detailTradeId: null as number | null,
selectedPair: '',
@ -102,7 +108,7 @@ export function createBotSubStore(botId: string, botName: string) {
backtestTradeCount: 0,
backtestResult: undefined as BacktestResult | undefined,
selectedBacktestResultKey: '',
backtestHistory: {} as Record<string, StrategyBacktestResult>,
backtestHistory: {} as Record<string, BacktestResultInMemory>,
backtestHistoryList: [] as BacktestHistoryEntry[],
sysInfo: {} as SysInfoResponse,
};
@ -114,7 +120,8 @@ export function createBotSubStore(botId: string, botName: string) {
stakeCurrencyDecimals: (state) => state.botState?.stake_currency_decimals || 3,
canRunBacktest: (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,
openTradeCount: (state) => state.openTrades.length,
isTrading: (state) =>
@ -151,14 +158,6 @@ export function createBotSubStore(botId: string, botName: string) {
botName: (state) => state.botState?.bot_name || 'freqtrade',
allTrades: (state) => [...state.openTrades, ...state.trades] as Trade[],
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: {
botAdded() {
@ -168,7 +167,6 @@ export function createBotSubStore(botId: string, botName: string) {
try {
const result = await api.get('/ping');
const now = Date.now();
// TODO: Name collision!
this.ping = `${result.data.status} ${now.toString()}`;
this.setIsBotOnline(true);
return Promise.resolve();
@ -363,29 +361,36 @@ export function createBotSubStore(botId: string, botName: string) {
reject(error);
});
},
getPairHistory(payload: PairHistoryPayload) {
async getPairHistory(payload: PairHistoryPayload) {
if (payload.pair && payload.timeframe) {
this.historyStatus = LoadingStatus.loading;
return api
.get('/pair_history', {
try {
const { data } = await api.get('/pair_history', {
params: { ...payload },
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
const error = 'pair or timeframe or timerange not specified';
@ -436,6 +441,11 @@ export function createBotSubStore(botId: string, botName: string) {
return Promise.resolve(data);
} catch (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);
}
},
@ -520,11 +530,20 @@ export function createBotSubStore(botId: string, botName: string) {
return Promise.reject(error);
}
},
async getDaily(payload: DailyPayload = {}) {
async getTimeSummary(aggregation: TimeSummaryOptions, payload: TimeSummaryPayload = {}) {
const { timescale = 20 } = payload;
try {
const { data } = await api.get<DailyReturnValue>('/daily', { params: { timescale } });
this.dailyStats = data;
const { data } = await api.get<TimeSummaryReturnValue>(`/${aggregation}`, {
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);
} catch (error) {
console.error(error);
@ -880,18 +899,30 @@ export function createBotSubStore(botId: string, botName: string) {
}
},
async getBacktestHistory() {
const result = await api.get<BacktestHistoryEntry[]>('/backtest/history');
this.backtestHistoryList = result.data;
const { data } = await api.get<BacktestHistoryEntry[]>('/backtest/history');
this.backtestHistoryList = data;
},
updateBacktestResult(backtestResult: BacktestResult) {
this.backtestResult = backtestResult;
// TODO: Properly identify duplicates to avoid pushing the same multiple times
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 = { ...this.backtestHistory, ...{ [stratKey]: strat } };
this.backtestHistory = { ...this.backtestHistory, ...{ [stratKey]: btResult } };
this.selectedBacktestResultKey = stratKey;
});
},
@ -903,9 +934,50 @@ export function createBotSubStore(botId: string, botName: string) {
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) {
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() {
try {
const { data } = await api.get<SysInfoResponse>('/sysinfo');
@ -936,7 +1008,7 @@ export function createBotSubStore(botId: string, botName: string) {
// TODO: check for active bot ...
if (pair === this.selectedPair) {
// Reload pair candles
this.getPairCandles({ pair, timeframe, limit: 500 });
this.getPairCandles({ pair, timeframe });
}
break;
}

View File

@ -5,15 +5,16 @@ import {
BotDescriptors,
BotState,
ClosedTrade,
DailyPayload,
DailyRecord,
DailyReturnValue,
TimeSummaryPayload,
TimeSummaryRecord,
TimeSummaryReturnValue,
MultiCancelOpenOrderPayload,
MultiDeletePayload,
MultiForcesellPayload,
MultiReloadTradePayload,
ProfitInterface,
Trade,
TimeSummaryOptions,
} from '@/types';
import { defineStore } from 'pinia';
import { createBotSubStore } from './ftbot';
@ -121,9 +122,9 @@ export const useBotStore = defineStore('ftbot-wrapper', {
});
return result;
},
allDailyStatsSelectedBots: (state): DailyReturnValue => {
allDailyStatsSelectedBots: (state): TimeSummaryReturnValue => {
// 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]) => {
if (botStore.isSelected) {
botStore.dailyStats?.data?.forEach((d) => {
@ -138,7 +139,7 @@ export const useBotStore = defineStore('ftbot-wrapper', {
}
});
const dailyReturn: DailyReturnValue = {
const dailyReturn: TimeSummaryReturnValue = {
stake_currency: 'USDT',
fiat_display_currency: 'USD',
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>[] = [];
updates.push(this.allRefreshFrequent(false));
updates.push(this.allRefreshSlow(true));
// updates.push(this.getDaily());
// updates.push(this.getTimeSummary());
// updates.push(this.getBalance());
await Promise.all(updates);
console.log('refreshing_end');
@ -303,12 +304,12 @@ export const useBotStore = defineStore('ftbot-wrapper', {
}
});
},
async allGetDaily(payload: DailyPayload) {
const updates: Promise<DailyReturnValue>[] = [];
async allGetDaily(payload: TimeSummaryPayload) {
const updates: Promise<TimeSummaryReturnValue>[] = [];
this.allBotStores.forEach((bot) => {
if (bot.isBotOnline) {
updates.push(bot.getDaily(payload));
updates.push(bot.getTimeSummary(TimeSummaryOptions.daily, payload));
}
});
await Promise.all(updates);

View File

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

View File

@ -32,6 +32,7 @@ export interface PairResult {
profit_total: number;
trades: number;
wins: number;
winrate?: number;
}
export interface ExitReasonResults {
@ -48,6 +49,7 @@ export interface ExitReasonResults {
profit_total_pct: number;
trades: number;
wins: number;
winrate?: number;
}
// Generated by https://quicktype.io
@ -59,6 +61,7 @@ export interface PeriodicStat {
wins: number;
draws: number;
loses: number;
winrate?: number;
}
export interface PeriodicBreakdown {
@ -137,7 +140,6 @@ export interface StrategyBacktestResult {
canceled_entry_orders?: number;
replaced_entry_orders?: number;
// Daily stats ...
draw_days: number;
drawdown_end: string;
drawdown_end_ts: number;
@ -145,6 +147,8 @@ export interface StrategyBacktestResult {
drawdown_start_ts: number;
loser_holding_avg: string;
loser_holding_avg_s: number;
max_consecutive_wins?: number;
max_consecutive_losses?: number;
losing_days: number;
max_drawdown: number;
max_drawdown_account: number;
@ -159,6 +163,7 @@ export interface StrategyBacktestResult {
sharpe?: number;
calmar?: number;
expectancy?: number;
expectancy_ratio?: number;
winner_holding_avg: string;
winner_holding_avg_s: number;
@ -177,9 +182,42 @@ export interface StrategyBacktestResult {
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 {
strategy: Record<string, StrategyBacktestResult>;
strategy_comparison: Array<Record<string, string | number>>;
metadata: Record<string, BacktestMetadata>;
}
export enum BacktestSteps {
@ -206,4 +244,5 @@ export interface BacktestHistoryEntry {
strategy: string;
run_id: string;
backtest_start_time: number;
notes?: string;
}

View File

@ -11,4 +11,5 @@ export interface ComparisonTableItems {
losses: number;
balance?: 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;
}
export interface DailyRecord {
export interface TimeSummaryRecord {
/** Date in the format yyyy-mm-dd */
[key: string]: string | number;
date: string;
@ -14,8 +19,8 @@ export interface DailyRecord {
trade_count: number;
}
export interface DailyReturnValue {
data: DailyRecord[];
export interface TimeSummaryReturnValue {
data: TimeSummaryRecord[];
fiat_display_currency: string;
stake_currency: string;
}

View File

@ -41,8 +41,15 @@ export interface ProfitInterface {
profit_factor?: number;
max_drawdown?: 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;
/** Initial bot start date*/
bot_start_timestamp?: number;
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_pct?: number;
/** deprecated, to be replaced with "has_open_orders" */
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 */
is_short?: boolean;
leverage?: number;

View File

@ -1,79 +1,79 @@
<template>
<div class="container-fluid" style="max-height: calc(100vh - 60px)">
<div class="container-fluid">
<div class="row mb-2"></div>
<p v-if="!botStore.activeBot.canRunBacktest">
Bot must be in webserver mode to enable Backtesting.
</p>
<div class="row w-100">
<h2 class="col-4 col-lg-3">Backtesting</h2>
<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"
>
<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
<div class="d-flex flex-column pt-1 me-1" style="height: calc(100vh - 60px)">
<div>
<div class="d-flex flex-row">
<h2 class="ms-5">Backtesting</h2>
<p v-if="!botStore.activeBot.canRunBacktest">
Bot must be in webserver mode to enable Backtesting.
</p>
<div class="w-100">
<div
class="mx-md-5 d-flex flex-wrap justify-content-md-center justify-content-between mb-4 gap-2"
>
<b-form-radio
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="run"
:disabled="!botStore.activeBot.canRunBacktest"
>Run backtest</b-form-radio
>
<b-form-radio
id="bt-analyze-btn"
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="results"
:disabled="!hasBacktestResult"
>Analyze result</b-form-radio
>
<b-form-radio
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="visualize-summary"
: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
<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
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="run"
:disabled="!botStore.activeBot.canRunBacktest"
>Run backtest</b-form-radio
>
<b-form-radio
id="bt-analyze-btn"
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="results"
:disabled="!hasBacktestResult"
>Analyze result</b-form-radio
>
<b-form-radio
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="visualize-summary"
: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>
<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 class="d-md-flex">
<div class="d-flex flex-md-row">
<!-- Left bar -->
<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
v-if="btFormMode !== 'visualize'"
class="align-self-start"
aria-label="Close"
size="sm"
@ -85,262 +85,71 @@
v-if="btFormMode !== 'visualize' && showLeftBar"
:backtest-history="botStore.activeBot.backtestHistory"
:selected-backtest-result-key="botStore.activeBot.selectedBacktestResultKey"
:can-use-modify="botStore.activeBot.botApiVersion >= 2.32"
@selection-change="botStore.activeBot.setBacktestResultKey"
@remove-result="botStore.activeBot.removeBacktestResultFromMemory"
@update-result="botStore.activeBot.saveBacktestResultMetadata"
/>
</transition>
</div>
<!-- End Left bar -->
<div
v-if="btFormMode == 'historicResults'"
class="flex-fill row d-flex flex-column bt-config"
>
<backtest-history-load />
</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"
<div class="d-flex flex-column flex-fill mw-100">
<div class="d-md-flex">
<div
v-if="btFormMode == 'historicResults'"
class="flex-fill d-flex flex-column bt-config"
>
<b-form-group
label-cols-sm="5"
label="Timeframe:"
label-align-sm="right"
label-for="timeframe-select"
>
<TimeframeSelect id="timeframe-select" v-model="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="selectedDetailTimeframe"
:below-timeframe="selectedTimeframe"
/>
</b-form-group>
<BacktestHistoryLoad />
</div>
<div v-if="btFormMode == 'run'" class="flex-fill d-flex flex-column bt-config">
<BacktestRun />
</div>
<BacktestResultAnalysis
v-if="hasBacktestResult && btFormMode == 'results'"
:backtest-result="botStore.activeBot.selectedBacktestResult"
class="flex-fill"
/>
<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="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
>
<BacktestGraphs
v-if="hasBacktestResult && btFormMode == 'visualize-summary'"
:trades="botStore.activeBot.selectedBacktestResult.trades"
class="flex-fill"
/>
</div>
<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
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
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
>
<BacktestResultChart
:timeframe="timeframe"
:strategy="btStore.strategy"
:timerange="btStore.timerange"
:pairlist="botStore.activeBot.selectedBacktestResult.pairlist"
:trades="botStore.activeBot.selectedBacktestResult.trades"
:freqai-model="btStore.freqAI.enabled ? btStore.freqAI.model : undefined"
/>
</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>
</template>
<script setup lang="ts">
import TimeRangeSelect from '@/components/ftbot/TimeRangeSelect.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 BacktestGraphs from '@/components/ftbot/BacktestGraphs.vue';
import BacktestHistoryLoad from '@/components/ftbot/BacktestHistoryLoad.vue';
import BacktestGraphsView from '@/components/ftbot/BacktestGraphsView.vue';
import BacktestResultChart from '@/components/ftbot/BacktestResultChart.vue';
import InfoBox from '@/components/general/InfoBox.vue';
import { BacktestPayload } from '@/types';
import BacktestResultSelect from '@/components/ftbot/BacktestResultSelect.vue';
import BacktestResultAnalysis from '@/components/ftbot/BacktestResultAnalysis.vue';
import BacktestRun from '@/components/ftbot/BacktestRun.vue';
import { formatPercent } from '@/shared/formatters';
import { computed, ref, onMounted, watch } from 'vue';
import { useBtStore } from '@/stores/btStore';
import { useBotStore } from '@/stores/ftbotwrapper';
import { computed, onMounted, ref, watch } from 'vue';
const botStore = useBotStore();
const btStore = useBtStore();
const hasBacktestResult = computed(() =>
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 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 pollInterval = ref<number | null>(null);
const selectBacktestResult = () => {
// Set parameters for this result
strategy.value = botStore.activeBot.selectedBacktestResult.strategy_name;
botStore.activeBot.getStrategy(strategy.value);
selectedTimeframe.value = botStore.activeBot.selectedBacktestResult.timeframe;
selectedDetailTimeframe.value = botStore.activeBot.selectedBacktestResult.timeframe_detail || '';
btStore.strategy = botStore.activeBot.selectedBacktestResult.strategy_name;
botStore.activeBot.getStrategy(btStore.strategy);
btStore.selectedTimeframe = botStore.activeBot.selectedBacktestResult.timeframe;
btStore.selectedDetailTimeframe =
botStore.activeBot.selectedBacktestResult.timeframe_detail || '';
// 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(
@ -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());
watch(
() => botStore.activeBot.backtestRunning,

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
dateStringToTimeRange,
timestampHour,
dateFromString,
timestampmsOrNa,
} from '@/shared/formatters';
const { getTimeZone } = exportForTesting;
@ -31,6 +32,8 @@ describe('timeformatter.ts', () => {
expect(timestampmsWithTimezone(1651057500000)).toEqual('2022-04-27 11:05:00 (UTC)');
setTimezone('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', () => {
setTimezone('UTC');
@ -38,6 +41,16 @@ describe('timeformatter.ts', () => {
setTimezone('CET');
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', () => {
expect(timestampToDateString(1651057500000)).toEqual('2022-04-27');

1361
yarn.lock

File diff suppressed because it is too large Load Diff