Merge pull request #737 from freqtrade/pinia

Vuex to Pinia and composition API
This commit is contained in:
Matthias 2022-04-23 14:33:08 +02:00 committed by GitHub
commit 30317bf2b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 5303 additions and 6211 deletions

2
.gitignore vendored
View File

@ -3,6 +3,8 @@ node_modules
package-lock.json
/dist
cypress/screenshots/*
# local env files
.env.local
.env.*.local

View File

@ -81,6 +81,14 @@ describe('Login', () => {
cy.get('span').should('contain', 'TestBot');
// Check API calls have been made.
cy.wait('@RandomAPICall');
// login button gone
cy.get('button').should('not.contain', 'Login');
// Test logout
cy.get('[id=avatar-drop]').parent().click();
cy.get('.dropdown-menu > a:last').click();
cy.get('button').should('contain', 'Login');
// login button there again
});
it('Test Login failed - wrong api url', () => {

View File

@ -27,21 +27,21 @@
"echarts": "^5.3.2",
"favico.js": "^0.3.10",
"humanize-duration": "^3.27.1",
"pinia": "^2.0.13",
"pinia-plugin-persistedstate": "^1.5.1",
"vue": "^2.6.14",
"vue-class-component": "^7.2.5",
"vue-demi": "0.12.5",
"vue-echarts": "^6.0.2",
"vue-grid-layout": "^2.3.12",
"vue-material-design-icons": "^5.0.0",
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.5.3",
"vue-select": "^3.18.3",
"vuex": "^3.6.2",
"vuex-composition-helpers": "^1.1.0"
"vue2-helpers": "^1.1.7"
},
"devDependencies": {
"@cypress/vue": "^2.2.3",
"@cypress/vite-dev-server": "^2.2.2",
"@cypress/vue": "^2.2.3",
"@types/echarts": "^4.9.14",
"@types/jest": "^27.4.1",
"@typescript-eslint/eslint-plugin": "^2.33.0",
@ -65,7 +65,6 @@
"vite": "^2.9.5",
"vite-jest": "^0.1.4",
"vite-plugin-vue2": "^1.5.1",
"vue-template-compiler": "^2.6.14",
"vuex-class": "^0.3.2"
"vue-template-compiler": "^2.6.14"
}
}

View File

@ -7,27 +7,24 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import NavBar from '@/components/layout/NavBar.vue';
import NavFooter from '@/components/layout/NavFooter.vue';
import Body from '@/components/layout/Body.vue';
import { namespace } from 'vuex-class';
import { SettingsGetters } from './store/modules/settings';
import { setTimezone } from './shared/formatters';
import StoreModules from './store/storeSubModules';
import { defineComponent, onMounted } from '@vue/composition-api';
import { useSettingsStore } from './stores/settings';
const uiSettingsNs = namespace(StoreModules.uiSettings);
@Component({
export default defineComponent({
name: 'App',
components: { NavBar, Body, NavFooter },
})
export default class App extends Vue {
@uiSettingsNs.Getter [SettingsGetters.timezone]: string;
mounted() {
setTimezone(this.timezone);
}
}
setup() {
const settingsStore = useSettingsStore();
onMounted(() => {
setTimezone(settingsStore.timezone);
});
return {};
},
});
</script>
<style scoped>

View File

@ -31,14 +31,13 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { defineComponent, ref, onMounted } from '@vue/composition-api';
import axios from 'axios';
import ThemeLightDark from 'vue-material-design-icons/Brightness6.vue';
import { themeList } from '@/shared/themes';
import { mapActions } from 'vuex';
import { FTHTMLStyleElement } from '@/types/styleElement';
import { useSettingsStore } from '@/stores/settings';
export default Vue.extend({
export default defineComponent({
name: 'BootswatchThemeSelect',
components: { ThemeLightDark },
props: {
@ -47,27 +46,14 @@ export default Vue.extend({
default: true,
},
},
data() {
return {
activeTheme: '',
themeList,
};
},
mounted() {
// If a theme has been stored in localstorage, the theme will be set.
if (window.localStorage.theme) this.setTheme(window.localStorage.theme);
},
methods: {
...mapActions(['setCurrentTheme']),
handleClick(e) {
this.setTheme(e.target.name.trim());
},
toggleNight() {
this.setTheme(this.activeTheme === 'bootstrap' ? 'bootstrap_dark' : 'bootstrap');
},
setTheme(themeName) {
setup(props) {
const activeTheme = ref('');
const themeList = ref([]);
const settingsStore = useSettingsStore();
const setTheme = (themeName) => {
// If theme is already active, do nothing.
if (this.activeTheme === themeName) {
if (activeTheme.value === themeName) {
return;
}
if (themeName.toLowerCase() === 'bootstrap' || themeName.toLowerCase() === 'bootstrap_dark') {
@ -81,7 +67,7 @@ export default Vue.extend({
bw.forEach((style, index) => {
(bw[index] as FTHTMLStyleElement).disabled = true;
});
if (this.simple && this.activeTheme) {
if (props.simple && activeTheme.value) {
// Only transition if simple mode is active
document.documentElement.classList.add('ft-theme-transition');
window.setTimeout(() => {
@ -110,10 +96,21 @@ export default Vue.extend({
});
}
// Save the theme as localstorage
this.setCurrentTheme(themeName);
this.activeTheme = themeName;
},
fetchApi() {
settingsStore.currentTheme = themeName;
activeTheme.value = themeName;
};
onMounted(() => {
if (window.localStorage.theme) setTheme(window.localStorage.theme);
});
const handleClick = (e) => {
setTheme(e.target.name.trim());
};
const toggleNight = () => {
setTheme(activeTheme.value === 'bootstrap' ? 'bootstrap_dark' : 'bootstrap');
};
const fetchApi = () => {
// Fetches boostswatch api and dynamically sets themes.
// Not used, but useful for updating the static array of themes if bootswatch dependency is outdated.
axios
@ -132,7 +129,15 @@ export default Vue.extend({
.catch((error) => {
console.error(error);
});
},
};
return {
activeTheme,
themeList,
setTheme,
handleClick,
toggleNight,
fetchApi,
};
},
});
</script>

View File

@ -4,7 +4,7 @@
<div class="align-items-center d-flex">
<span class="ml-2 mr-1 align-middle">{{
allIsBotOnline[bot.botId] ? '&#128994;' : '&#128308;'
botStore.botStores[bot.botId].isBotOnline ? '&#128994;' : '&#128308;'
}}</span>
<b-form-checkbox
v-model="autoRefreshLoc"
@ -29,58 +29,55 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import EditIcon from 'vue-material-design-icons/Pencil.vue';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import { BotDescriptor, BotDescriptors } from '@/types';
import StoreModules from '@/store/storeSubModules';
import { BotDescriptor } from '@/types';
import { defineComponent, computed } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'BotEntry',
components: {
DeleteIcon,
EditIcon,
},
})
export default class BotList extends Vue {
@Prop({ default: false, type: Object }) bot!: BotDescriptor;
props: {
bot: { required: true, type: Object },
noButtons: { default: false, type: Boolean },
},
emits: ['edit'],
setup(props, { root }) {
const botStore = useBotStore();
@Prop({ default: false, type: Boolean }) noButtons!: boolean;
const changeEvent = (v) => {
botStore.botStores[props.bot.botId].setAutoRefresh(v);
};
@ftbot.Getter [MultiBotStoreGetters.allIsBotOnline];
const clickRemoveBot = (bot: BotDescriptor) => {
//
root.$bvModal
.msgBoxConfirm(`Really remove (logout) from '${bot.botName}' (${bot.botId})?`)
.then((value: boolean) => {
if (value) {
botStore.removeBot(bot.botId);
}
});
};
const autoRefreshLoc = computed({
get() {
return botStore.botStores[props.bot.botId].autoRefresh;
},
set() {
// pass
},
});
@ftbot.Getter [MultiBotStoreGetters.allAutoRefresh];
@ftbot.Getter [MultiBotStoreGetters.allAvailableBots]: BotDescriptors;
@ftbot.Action removeBot;
@ftbot.Action selectBot;
get autoRefreshLoc() {
return this.allAutoRefresh[this.bot.botId];
}
set autoRefreshLoc(v) {
// Dummy setter - Set via change event to avoid bouncing
}
changeEvent(v) {
this.$store.dispatch(`ftbot/${this.bot.botId}/setAutoRefresh`, v);
}
clickRemoveBot(bot: BotDescriptor) {
//
this.$bvModal
.msgBoxConfirm(`Really remove (logout) from '${bot.botName}' (${bot.botId})?`)
.then((value: boolean) => {
if (value) {
this.removeBot(bot.botId);
}
});
}
}
return {
botStore,
changeEvent,
clickRemoveBot,
autoRefreshLoc,
};
},
});
</script>

View File

@ -1,14 +1,14 @@
<template>
<div v-if="botCount > 0">
<div v-if="botStore.botCount > 0">
<h3 v-if="!small">Available bots</h3>
<b-list-group>
<b-list-group-item
v-for="bot in allAvailableBots"
v-for="bot in botStore.availableBots"
:key="bot.botId"
:active="bot.botId === selectedBot"
:active="bot.botId === botStore.selectedBot"
button
:title="`${bot.botId} - ${bot.botName} - ${bot.botUrl}`"
@click="selectBot(bot.botId)"
@click="botStore.selectBot(bot.botId)"
>
<bot-rename
v-if="editingBots.includes(bot.botId)"
@ -25,51 +25,44 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import LoginModal from '@/views/LoginModal.vue';
import BotEntry from '@/components/BotEntry.vue';
import BotRename from '@/components/BotRename.vue';
import { BotDescriptors } from '@/types';
import StoreModules from '@/store/storeSubModules';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
@Component({
components: {
LoginModal,
BotEntry,
BotRename,
export default defineComponent({
name: 'BotList',
components: { LoginModal, BotEntry, BotRename },
props: {
small: { default: false, type: Boolean },
},
})
export default class BotList extends Vue {
@Prop({ default: false, type: Boolean }) small!: boolean;
setup() {
const botStore = useBotStore();
@ftbot.Getter [MultiBotStoreGetters.botCount]: number;
const editingBots = ref<string[]>([]);
@ftbot.Getter [MultiBotStoreGetters.selectedBot]: string;
const editBot = (botId: string) => {
if (!editingBots.value.includes(botId)) {
editingBots.value.push(botId);
}
};
@ftbot.Getter [MultiBotStoreGetters.allIsBotOnline]: Record<string, boolean>;
const stopEditBot = (botId: string) => {
if (!editingBots.value.includes(botId)) {
return;
}
@ftbot.Getter [MultiBotStoreGetters.allAvailableBots]: BotDescriptors;
editingBots.value.splice(editingBots.value.indexOf(botId), 1);
};
@ftbot.Action selectBot;
editingBots: string[] = [];
editBot(botId: string) {
if (!this.editingBots.includes(botId)) {
this.editingBots.push(botId);
}
}
stopEditBot(botId: string) {
if (!this.editingBots.includes(botId)) {
return;
}
this.editingBots.splice(this.editingBots.indexOf(botId), 1);
}
}
return {
botStore,
editingBots,
editBot,
stopEditBot,
};
},
});
</script>

View File

@ -22,36 +22,39 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import CheckIcon from 'vue-material-design-icons/Check.vue';
import CloseIcon from 'vue-material-design-icons/Close.vue';
import { BotDescriptor, RenameBotPayload } from '@/types';
import StoreModules from '@/store/storeSubModules';
import { BotDescriptor } from '@/types';
import { defineComponent, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'BotRename',
components: {
CheckIcon,
CloseIcon,
},
})
export default class BotList extends Vue {
@Prop({ required: true, type: Object }) bot!: BotDescriptor;
props: {
bot: { type: Object as () => BotDescriptor, required: true },
},
emits: ['saved'],
setup(props, { emit }) {
const botStore = useBotStore();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action renameBot!: (payload: RenameBotPayload) => Promise<void>;
const newName = ref<string>(props.bot.botName);
newName: string = this.bot.botName;
const save = () => {
botStore.renameBot({
botId: props.bot.botId,
botName: newName.value,
});
save() {
this.renameBot({
botId: this.bot.botId,
botName: this.newName,
});
this.$emit('saved');
}
}
emit('saved');
};
return {
newName,
save,
};
},
});
</script>

View File

@ -1,6 +1,6 @@
<template>
<div>
<form ref="form" novalidate @submit.stop.prevent="handleSubmit" @reset="handleReset">
<form ref="formRef" novalidate @submit.stop.prevent="handleSubmit" @reset="handleReset">
<b-form-group label="Bot Name" label-for="name-input">
<b-form-input
id="name-input"
@ -72,146 +72,146 @@
</template>
<script lang="ts">
import { Component, Vue, Emit, Prop } from 'vue-property-decorator';
import { Action, namespace } from 'vuex-class';
import { useUserService } from '@/shared/userService';
import { AuthPayload, BotDescriptor } from '@/types';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import StoreModules from '@/store/storeSubModules';
import { AuthPayload } from '@/types';
import { defineComponent, ref } from '@vue/composition-api';
import { useRouter, useRoute } from 'vue2-helpers/vue-router';
import { useBotStore } from '@/stores/ftbotwrapper';
const defaultURL = window.location.origin || 'http://localhost:3000';
const ftbot = namespace(StoreModules.ftbot);
@Component({})
export default class Login extends Vue {
@Action setLoggedIn;
export default defineComponent({
name: 'Login',
props: {
inModal: { default: false, type: Boolean },
},
emits: ['loginResult'],
setup(props, { emit }) {
const router = useRouter();
const route = useRoute();
const botStore = useBotStore();
@ftbot.Getter [MultiBotStoreGetters.nextBotId]: string;
const nameState = ref<boolean | null>();
const pwdState = ref<boolean | null>();
const urlState = ref<boolean | null>();
const errorMessage = ref<string>('');
const errorMessageCORS = ref<boolean>(false);
const formRef = ref<HTMLFormElement>();
const auth = ref<AuthPayload>({
botName: '',
url: defaultURL,
username: '',
password: '',
});
@ftbot.Getter [MultiBotStoreGetters.selectedBot]: string;
const emitLoginResult = (value: boolean) => {
emit('loginResult', value);
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action addBot!: (payload: BotDescriptor) => void;
const checkFormValidity = () => {
const valid = formRef.value?.checkValidity();
nameState.value = valid || auth.value.username !== '';
pwdState.value = valid || auth.value.password !== '';
return valid;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action selectBot!: (botId: string) => void;
const resetLogin = () => {
auth.value.url = defaultURL;
auth.value.username = '';
auth.value.password = '';
nameState.value = null;
pwdState.value = null;
errorMessage.value = '';
};
@ftbot.Action allRefreshFull;
const handleReset = (evt) => {
evt.preventDefault();
resetLogin();
};
@Prop({ default: false }) inModal!: boolean;
const handleSubmit = () => {
// Exit when the form isn't valid
if (!checkFormValidity()) {
return;
}
errorMessage.value = '';
const userService = useUserService(botStore.nextBotId);
// Push the name to submitted names
userService
.login(auth.value)
.then(() => {
const botId = botStore.nextBotId;
botStore.addBot({
botName: auth.value.botName,
botId,
botUrl: auth.value.url,
});
if (botStore.selectedBot === '') {
console.log(`selecting bot ${botId}`);
botStore.selectBot(botId);
}
$refs!: {
form: HTMLFormElement;
};
auth: AuthPayload = {
botName: '',
url: defaultURL,
username: '',
password: '',
};
@Emit('loginResult')
emitLoginResult(value: boolean) {
return value;
}
nameState: boolean | null = null;
pwdState: boolean | null = null;
urlState: boolean | null = null;
errorMessage = '';
errorMessageCORS = false;
checkFormValidity() {
const valid = this.$refs.form.checkValidity();
this.nameState = valid || this.auth.username !== '';
this.pwdState = valid || this.auth.password !== '';
return valid;
}
resetLogin() {
this.auth.url = defaultURL;
this.auth.username = '';
this.auth.password = '';
this.nameState = null;
this.pwdState = null;
this.errorMessage = '';
}
handleReset(evt) {
evt.preventDefault();
this.resetLogin();
}
handleOk(evt) {
evt.preventDefault();
this.handleSubmit();
}
handleSubmit() {
// Exit when the form isn't valid
if (!this.checkFormValidity()) {
return;
}
this.errorMessage = '';
const userService = useUserService(this.nextBotId);
// Push the name to submitted names
userService
.login(this.auth)
.then(() => {
const botId = this.nextBotId;
this.addBot({
botName: this.auth.botName,
botId,
botUrl: this.auth.url,
});
if (this.selectedBot === '') {
console.log(`selecting bot ${botId}`);
this.selectBot(botId);
}
this.emitLoginResult(true);
this.allRefreshFull();
if (this.inModal === false) {
if (typeof this.$route.query.redirect === 'string') {
const resolved = this.$router.resolve({ path: this.$route.query.redirect });
if (resolved.route.name !== '404') {
this.$router.push(resolved.route.path);
emitLoginResult(true);
botStore.allRefreshFull();
if (props.inModal === false) {
if (typeof route?.query.redirect === 'string') {
const resolved = router.resolve({ path: route.query.redirect });
if (resolved.route.name !== '404') {
router.push(resolved.route.path);
} else {
router.push('/');
}
} else {
this.$router.push('/');
router.push('/');
}
}
})
.catch((error) => {
errorMessageCORS.value = false;
// this.nameState = false;
console.error(error);
if (error.response && error.response.status === 401) {
nameState.value = false;
pwdState.value = false;
errorMessage.value =
'Connected to bot, however Login failed, Username or Password wrong.';
} else {
this.$router.push('/');
}
}
})
.catch((error) => {
this.errorMessageCORS = false;
// this.nameState = false;
console.error(error.response);
if (error.response && error.response.status === 401) {
this.nameState = false;
this.pwdState = false;
this.errorMessage = 'Connected to bot, however Login failed, Username or Password wrong.';
} else {
this.urlState = false;
this.errorMessage = `Login failed.
urlState.value = false;
errorMessage.value = `Login failed.
Please verify that the bot is running, the Bot API is enabled and the URL is reachable.
You can verify this by navigating to ${this.auth.url}/api/v1/ping to make sure the bot API is reachable`;
if (this.auth.url !== window.location.origin) {
this.errorMessageCORS = true;
You can verify this by navigating to ${auth.value.url}/api/v1/ping to make sure the bot API is reachable`;
if (auth.value.url !== window.location.origin) {
errorMessageCORS.value = true;
}
}
}
console.error(this.errorMessage);
this.emitLoginResult(false);
});
}
}
console.error(errorMessage.value);
emitLoginResult(false);
});
};
const handleOk = (evt) => {
evt.preventDefault();
handleSubmit();
};
return {
nameState,
pwdState,
urlState,
errorMessage,
auth,
checkFormValidity,
resetLogin,
handleReset,
handleOk,
handleSubmit,
formRef,
errorMessageCORS,
};
},
});
</script>
<style scoped lang="scss">

View File

@ -1,10 +1,13 @@
<template>
<v-chart v-if="currencies" :option="balanceChartOptions" :theme="getChartTheme" autoresize />
<v-chart
v-if="currencies"
:option="balanceChartOptions"
:theme="settingsStore.chartTheme"
autoresize
/>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Getter } from 'vuex-class';
import ECharts from 'vue-echarts';
import { EChartsOption } from 'echarts';
@ -21,6 +24,8 @@ import {
import { BalanceRecords } from '@/types';
import { formatPriceCurrency } from '@/shared/formatters';
import { defineComponent, computed } from '@vue/composition-api';
import { useSettingsStore } from '@/stores/settings';
use([
PieChart,
@ -32,65 +37,68 @@ use([
LabelLayout,
]);
@Component({
export default defineComponent({
name: 'BalanceChart',
components: {
'v-chart': ECharts,
},
})
export default class BalanceChart extends Vue {
@Prop({ required: true }) currencies!: BalanceRecords[];
props: {
currencies: { required: true, type: Array as () => BalanceRecords[] },
showTitle: { required: false, type: Boolean },
},
setup(props) {
const settingsStore = useSettingsStore();
@Prop({ default: false, type: Boolean }) showTitle!: boolean;
@Getter getChartTheme!: string;
get balanceChartOptions(): EChartsOption {
return {
title: {
text: 'Balance',
show: this.showTitle,
},
center: ['50%', '50%'],
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['balance', 'currency', 'est_stake', 'free', 'used', 'stake'],
source: this.currencies,
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${formatPriceCurrency(params.value.balance, params.value.currency, 8)}<br />${
params.percent
}% (${formatPriceCurrency(params.value.est_stake, params.value.stake)})`;
const balanceChartOptions = computed((): EChartsOption => {
return {
title: {
text: 'Balance',
show: props.showTitle,
},
},
// legend: {
// orient: 'vertical',
// right: 10,
// top: 20,
// bottom: 20,
// },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
encode: {
value: 'est_stake',
itemName: 'currency',
tooltip: ['balance', 'currency'],
},
label: {
formatter: '{b} - {d}%',
},
tooltip: {
show: true,
center: ['50%', '50%'],
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['balance', 'currency', 'est_stake', 'free', 'used', 'stake'],
source: props.currencies,
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${formatPriceCurrency(params.value.balance, params.value.currency, 8)}<br />${
params.percent
}% (${formatPriceCurrency(params.value.est_stake, params.value.stake)})`;
},
},
],
};
}
}
// legend: {
// orient: 'vertical',
// right: 10,
// top: 20,
// bottom: 20,
// },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
encode: {
value: 'est_stake',
itemName: 'currency',
tooltip: ['balance', 'currency'],
},
label: {
formatter: '{b} - {d}%',
},
tooltip: {
show: true,
},
},
],
};
});
return { balanceChartOptions, settingsStore };
},
});
</script>
<style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,7 @@
<div class="ml-2">
<b-select
v-model="plotConfigName"
:options="availablePlotConfigNames"
:options="botStore.activeBot.availablePlotConfigNames"
size="sm"
@change="plotConfigChanged"
>
@ -67,8 +67,8 @@
:trades="trades"
:plot-config="plotConfig"
:heikin-ashi="heikinAshi"
:use-u-t-c="timezone === 'UTC'"
:theme="getChartTheme"
:use-u-t-c="settingsStore.timezone === 'UTC'"
:theme="settingsStore.chartTheme"
>
</CandleChart>
<div v-else class="m-auto">
@ -89,184 +89,161 @@
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator';
import { Getter, namespace } from 'vuex-class';
import {
Trade,
PairHistory,
EMPTY_PLOTCONFIG,
PlotConfig,
PairCandlePayload,
PairHistoryPayload,
LoadingStatus,
} from '@/types';
import { Trade, PairHistory, EMPTY_PLOTCONFIG, PlotConfig, LoadingStatus } from '@/types';
import CandleChart from '@/components/charts/CandleChart.vue';
import PlotConfigurator from '@/components/charts/PlotConfigurator.vue';
import { getCustomPlotConfig, getPlotConfigName } from '@/shared/storage';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { SettingsGetters } from '@/store/modules/settings';
import vSelect from 'vue-select';
import StoreModules from '@/store/storeSubModules';
import { useSettingsStore } from '@/stores/settings';
const ftbot = namespace(StoreModules.ftbot);
const uiSettingsNs = namespace(StoreModules.uiSettings);
import { defineComponent, ref, computed, onMounted, watch } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
@Component({ components: { CandleChart, PlotConfigurator, vSelect } })
export default class CandleChartContainer extends Vue {
@Prop({ required: true }) readonly availablePairs!: string[];
export default defineComponent({
name: 'CandleChartContainer',
components: { CandleChart, PlotConfigurator, vSelect },
props: {
trades: { required: false, default: () => [], type: Array as () => Trade[] },
availablePairs: { required: true, type: Array as () => string[] },
timeframe: { required: true, type: String },
historicView: { required: false, default: false, type: Boolean },
plotConfigModal: { required: false, default: true, type: Boolean },
/** Only required if historicView is true */
timerange: { required: false, default: '', type: String },
/** Only required if historicView is true */
strategy: { required: false, default: '', type: String },
},
setup(props, { root }) {
const settingsStore = useSettingsStore();
const botStore = useBotStore();
@Prop({ required: true }) readonly timeframe!: string;
const pair = ref('');
const plotConfig = ref<PlotConfig>({ ...EMPTY_PLOTCONFIG });
const plotConfigName = ref('');
const heikinAshi = ref(false);
const showPlotConfig = ref(props.plotConfigModal);
@Prop({ required: false, default: () => [] }) readonly trades!: Array<Trade>;
@Prop({ required: false, default: false }) historicView!: boolean;
@Prop({ required: false, default: true }) plotConfigModal!: boolean;
/** Only required if historicView is true */
@Prop({ required: false, default: false }) timerange!: string;
/**
* Only required if historicView is true
*/
@Prop({ required: false, default: false }) strategy!: string;
pair = '';
plotConfig: PlotConfig = { ...EMPTY_PLOTCONFIG };
plotConfigName = '';
showPlotConfig = this.plotConfigModal;
heikinAshi: boolean = false;
@Getter getChartTheme!: string;
@ftbot.Getter [BotStoreGetters.availablePlotConfigNames]!: string[];
@ftbot.Action setPlotConfigName;
@ftbot.Getter [BotStoreGetters.candleDataStatus]!: LoadingStatus;
@ftbot.Getter [BotStoreGetters.candleData]!: PairHistory;
@ftbot.Getter [BotStoreGetters.historyStatus]!: LoadingStatus;
@ftbot.Getter [BotStoreGetters.history]!: PairHistory;
@ftbot.Getter [BotStoreGetters.selectedPair]!: string;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action public getPairCandles!: (payload: PairCandlePayload) => void;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action public getPairHistory!: (payload: PairHistoryPayload) => void;
@uiSettingsNs.Getter [SettingsGetters.timezone]: string;
get dataset(): PairHistory {
if (this.historicView) {
return this.history[`${this.pair}__${this.timeframe}`];
}
return this.candleData[`${this.pair}__${this.timeframe}`];
}
get strategyName() {
return this.strategy || this.dataset?.strategy || '';
}
get datasetColumns() {
return this.dataset ? this.dataset.columns : [];
}
get isLoadingDataset(): boolean {
if (this.historicView) {
return this.historyStatus === 'loading';
}
return this.candleDataStatus === 'loading';
}
get noDatasetText(): string {
const status = this.historicView ? this.historyStatus : this.candleDataStatus;
switch (status) {
case 'loading':
return 'Loading...';
case 'success':
return 'No data available';
case 'error':
return 'Failed to load data';
default:
return 'Unknown';
}
}
get hasDataset(): boolean {
return !!this.dataset;
}
mounted() {
if (this.selectedPair) {
this.pair = this.selectedPair;
} else if (this.availablePairs.length > 0) {
[this.pair] = this.availablePairs;
}
this.plotConfigName = getPlotConfigName();
this.plotConfig = getCustomPlotConfig(this.plotConfigName);
if (!this.hasDataset) {
this.refresh();
}
}
plotConfigChanged() {
console.log('plotConfigChanged');
this.plotConfig = getCustomPlotConfig(this.plotConfigName);
this.setPlotConfigName(this.plotConfigName);
}
showConfigurator() {
if (this.plotConfigModal) {
this.$bvModal.show('plotConfiguratorModal');
} else {
this.showPlotConfig = !this.showPlotConfig;
}
}
refresh() {
if (this.pair && this.timeframe) {
if (this.historicView) {
this.getPairHistory({
pair: this.pair,
timeframe: this.timeframe,
timerange: this.timerange,
strategy: this.strategy,
});
} else {
this.getPairCandles({ pair: this.pair, timeframe: this.timeframe, limit: 500 });
const dataset = computed((): PairHistory => {
if (props.historicView) {
return botStore.activeBot.history[`${pair.value}__${props.timeframe}`]?.data;
}
return botStore.activeBot.candleData[`${pair.value}__${props.timeframe}`]?.data;
});
const strategyName = computed(() => props.strategy || dataset.value?.strategy || '');
const datasetColumns = computed(() => (dataset.value ? dataset.value.columns : []));
const hasDataset = computed(() => !!dataset.value);
const isLoadingDataset = computed((): boolean => {
if (props.historicView) {
return botStore.activeBot.historyStatus === LoadingStatus.loading;
}
}
}
@Watch('availablePairs')
watchAvailablePairs() {
if (!this.availablePairs.find((pair) => pair === this.pair)) {
[this.pair] = this.availablePairs;
this.refresh();
}
}
return botStore.activeBot.candleDataStatus === LoadingStatus.loading;
});
const noDatasetText = computed((): string => {
const status = props.historicView
? botStore.activeBot.historyStatus
: botStore.activeBot.candleDataStatus;
@Watch(BotStoreGetters.selectedPair)
watchSelectedPair() {
this.pair = this.selectedPair;
this.refresh();
}
}
switch (status) {
case LoadingStatus.loading:
return 'Loading...';
case LoadingStatus.success:
return 'No data available';
case LoadingStatus.error:
return 'Failed to load data';
default:
return 'Unknown';
}
});
const plotConfigChanged = () => {
console.log('plotConfigChanged');
plotConfig.value = getCustomPlotConfig(plotConfigName.value);
botStore.activeBot.setPlotConfigName(plotConfigName.value);
};
const showConfigurator = () => {
if (props.plotConfigModal) {
root.$bvModal.show('plotConfiguratorModal');
} else {
showPlotConfig.value = !showPlotConfig.value;
}
};
const refresh = () => {
if (pair.value && props.timeframe) {
if (props.historicView) {
botStore.activeBot.getPairHistory({
pair: pair.value,
timeframe: props.timeframe,
timerange: props.timerange,
strategy: props.strategy,
});
} else {
botStore.activeBot.getPairCandles({
pair: pair.value,
timeframe: props.timeframe,
limit: 500,
});
}
}
};
watch(
() => props.availablePairs,
() => {
if (!props.availablePairs.find((p) => p === pair.value)) {
[pair.value] = props.availablePairs;
refresh();
}
},
);
watch(
() => botStore.activeBot.selectedPair,
() => {
pair.value = botStore.activeBot.selectedPair;
refresh();
},
);
onMounted(() => {
if (botStore.activeBot.selectedPair) {
pair.value = botStore.activeBot.selectedPair;
} else if (props.availablePairs.length > 0) {
[pair.value] = props.availablePairs;
}
plotConfigName.value = getPlotConfigName();
plotConfig.value = getCustomPlotConfig(plotConfigName.value);
if (!hasDataset) {
refresh();
}
});
return {
botStore,
settingsStore,
history,
dataset,
strategyName,
datasetColumns,
isLoadingDataset,
noDatasetText,
hasDataset,
heikinAshi,
plotConfigChanged,
showPlotConfig,
showConfigurator,
refresh,
plotConfigName,
pair,
plotConfig,
};
},
});
</script>
<style scoped lang="scss">

View File

@ -1,11 +1,8 @@
<template>
<v-chart v-if="trades" :option="chartOptions" autoresize :theme="getChartTheme" />
<v-chart v-if="trades" :option="chartOptions" autoresize :theme="settingsStore.chartTheme" />
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Getter } from 'vuex-class';
import ECharts from 'vue-echarts';
import { EChartsOption } from 'echarts';
@ -21,6 +18,8 @@ import {
} from 'echarts/components';
import { ClosedTrade, CumProfitData, CumProfitDataPerDate } from '@/types';
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import { useSettingsStore } from '@/stores/settings';
use([
BarChart,
@ -38,162 +37,166 @@ use([
// Define Column labels here to avoid typos
const CHART_PROFIT = 'Profit';
@Component({
export default defineComponent({
name: 'CumProfitChart',
components: {
'v-chart': ECharts,
},
})
export default class CumProfitChart extends Vue {
@Prop({ required: true }) trades!: ClosedTrade[];
props: {
trades: { required: true, type: Array as () => ClosedTrade[] },
showTitle: { default: true, type: Boolean },
profitColumn: { default: 'close_profit_abs', type: String },
},
setup(props) {
const settingsStore = useSettingsStore();
const botList = ref<string[]>([]);
const cumulativeData = ref<{ date: number; profit: any }[]>([]);
@Prop({ default: true, type: Boolean }) showTitle!: boolean;
watch(
() => props.trades,
() => {
botList.value = [];
const res: CumProfitData[] = [];
const resD: CumProfitDataPerDate = {};
const closedTrades = props.trades
.slice()
.sort((a, b) => (a.close_timestamp > b.close_timestamp ? 1 : -1));
let profit = 0.0;
@Prop({ default: 'close_profit_abs' }) profitColumn!: string;
for (let i = 0, len = closedTrades.length; i < len; i += 1) {
const trade = closedTrades[i];
@Getter getChartTheme!: string;
botList: string[] = [];
get cumulativeData() {
this.botList = [];
const res: CumProfitData[] = [];
const resD: CumProfitDataPerDate = {};
const closedTrades = this.trades
.slice()
.sort((a, b) => (a.close_timestamp > b.close_timestamp ? 1 : -1));
let profit = 0.0;
for (let i = 0, len = closedTrades.length; i < len; i += 1) {
const trade = closedTrades[i];
if (trade.close_timestamp && trade[this.profitColumn]) {
profit += trade[this.profitColumn];
if (!resD[trade.close_timestamp]) {
// New timestamp
resD[trade.close_timestamp] = { profit, [trade.botId]: profit };
} else {
// Add to existing profit
resD[trade.close_timestamp].profit += trade[this.profitColumn];
if (resD[trade.close_timestamp][trade.botId]) {
resD[trade.close_timestamp][trade.botId] += trade[this.profitColumn];
} else {
resD[trade.close_timestamp][trade.botId] = profit;
if (trade.close_timestamp && trade[props.profitColumn]) {
profit += trade[props.profitColumn];
if (!resD[trade.close_timestamp]) {
// New timestamp
resD[trade.close_timestamp] = { profit, [trade.botId]: profit };
} else {
// Add to existing profit
resD[trade.close_timestamp].profit += trade[props.profitColumn];
if (resD[trade.close_timestamp][trade.botId]) {
resD[trade.close_timestamp][trade.botId] += trade[props.profitColumn];
} else {
resD[trade.close_timestamp][trade.botId] = profit;
}
}
// console.log(trade.close_date, profit);
res.push({ date: trade.close_timestamp, profit, [trade.botId]: profit });
if (!botList.value.includes(trade.botId)) {
botList.value.push(trade.botId);
}
}
}
// console.log(trade.close_date, profit);
res.push({ date: trade.close_timestamp, profit, [trade.botId]: profit });
if (!this.botList.includes(trade.botId)) {
this.botList.push(trade.botId);
}
}
}
// console.log(resD);
// console.log(resD);
return Object.entries(resD).map(([k, v]) => {
const obj = { date: parseInt(k, 10), profit: v.profit };
// TODO: The below could allow "lines" per bot"
// this.botList.forEach((botId) => {
// obj[botId] = v[botId];
cumulativeData.value = Object.entries(resD).map(([k, v]) => {
const obj = { date: parseInt(k, 10), profit: v.profit };
// TODO: The below could allow "lines" per bot"
// this.botList.forEach((botId) => {
// obj[botId] = v[botId];
// });
return obj;
});
},
);
const chartOptions = computed((): EChartsOption => {
const chartOptionsLoc: EChartsOption = {
title: {
text: 'Cumulative Profit',
show: props.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'profit'],
source: cumulativeData.value,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: [CHART_PROFIT],
right: '5%',
},
useUTC: false,
xAxis: {
type: 'time',
},
yAxis: [
{
type: 'value',
name: CHART_PROFIT,
splitLine: {
show: false,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 40,
},
],
grid: {
bottom: 80,
},
dataZoom: [
{
type: 'inside',
// xAxisIndex: [0],
start: 0,
end: 100,
},
{
show: true,
// xAxisIndex: [0],
type: 'slider',
bottom: 10,
start: 0,
end: 100,
},
],
series: [
{
type: 'line',
name: CHART_PROFIT,
animation: true,
step: 'end',
lineStyle: {
color: settingsStore.chartTheme === 'dark' ? '#c2c2c2' : 'black',
},
itemStyle: {
color: settingsStore.chartTheme === 'dark' ? '#c2c2c2' : 'black',
},
// symbol: 'none',
},
],
};
// TODO: maybe have profit lines per bot?
// this.botList.forEach((botId: string) => {
// console.log('bot', botId);
// chartOptionsLoc.series.push({
// type: 'line',
// name: botId,
// animation: true,
// step: 'end',
// lineStyle: {
// color: settingsStore.chartTheme === 'dark' ? '#c2c2c2' : 'black',
// },
// itemStylesettingsStore.chartTheme === 'dark' ? '#c2c2c2' : 'black',
// },
// // symbol: 'none',
// });
// });
return obj;
return chartOptionsLoc;
});
}
get chartOptions(): EChartsOption {
const chartOptionsLoc: EChartsOption = {
title: {
text: 'Cumulative Profit',
show: this.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'profit'],
source: this.cumulativeData,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: [CHART_PROFIT],
right: '5%',
},
useUTC: false,
xAxis: {
type: 'time',
},
yAxis: [
{
type: 'value',
name: CHART_PROFIT,
splitLine: {
show: false,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 40,
},
],
grid: {
bottom: 80,
},
dataZoom: [
{
type: 'inside',
// xAxisIndex: [0],
start: 0,
end: 100,
},
{
show: true,
// xAxisIndex: [0],
type: 'slider',
bottom: 10,
start: 0,
end: 100,
},
],
series: [
{
type: 'line',
name: CHART_PROFIT,
animation: true,
step: 'end',
lineStyle: {
color: this.getChartTheme === 'dark' ? '#c2c2c2' : 'black',
},
itemStyle: {
color: this.getChartTheme === 'dark' ? '#c2c2c2' : 'black',
},
// symbol: 'none',
},
],
};
// TODO: maybe have profit lines per bot?
// this.botList.forEach((botId: string) => {
// console.log('bot', botId);
// chartOptionsLoc.series.push({
// type: 'line',
// name: botId,
// animation: true,
// step: 'end',
// lineStyle: {
// color: this.getChartTheme === 'dark' ? '#c2c2c2' : 'black',
// },
// itemStyle: {
// color: this.getChartTheme === 'dark' ? '#c2c2c2' : 'black',
// },
// // symbol: 'none',
// });
// });
return chartOptionsLoc;
}
}
return { settingsStore, cumulativeData, chartOptions };
},
});
</script>
<style scoped>

View File

@ -1,10 +1,14 @@
<template>
<v-chart v-if="dailyStats.data" :option="dailyChartOptions" :theme="getChartTheme" autoresize />
<v-chart
v-if="dailyStats.data"
:option="dailyChartOptions"
:theme="settingsStore.chartTheme"
autoresize
/>
</template>
<script lang="ts">
import { useGetters } from 'vuex-composition-helpers';
import { ref, defineComponent, computed, ComputedRef } from '@vue/composition-api';
import { defineComponent, computed, ComputedRef } from '@vue/composition-api';
import ECharts from 'vue-echarts';
// import { EChartsOption } from 'echarts';
@ -21,6 +25,8 @@ import {
} from 'echarts/components';
import { DailyReturnValue } from '@/types';
import { useSettingsStore } from '@/stores/settings';
import { EChartsOption } from 'echarts';
use([
BarChart,
@ -54,108 +60,107 @@ export default defineComponent({
},
setup(props) {
const { getChartTheme } = useGetters(['getChartTheme']);
const absoluteMin: ComputedRef<number> = computed(() =>
const settingsStore = useSettingsStore();
const absoluteMin = computed(() =>
props.dailyStats.data.reduce(
(min, p) => (p.abs_profit < min ? p.abs_profit : min),
props.dailyStats.data[0]?.abs_profit,
),
);
const absoluteMax: ComputedRef<number> = computed(() =>
const absoluteMax = computed(() =>
props.dailyStats.data.reduce(
(max, p) => (p.abs_profit > max ? p.abs_profit : max),
props.dailyStats.data[0]?.abs_profit,
),
);
// : Ref<EChartsOption>
const dailyChartOptions = ref({
title: {
text: 'Daily profit',
show: props.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'abs_profit', 'trade_count'],
source: props.dailyStats.data,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
const dailyChartOptions: ComputedRef<EChartsOption> = computed(() => {
return {
title: {
text: 'Daily profit',
show: props.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'abs_profit', 'trade_count'],
source: props.dailyStats.data,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
},
},
},
},
legend: {
data: [CHART_ABS_PROFIT, CHART_TRADE_COUNT],
right: '5%',
},
xAxis: [
{
type: 'category',
inverse: true,
legend: {
data: [CHART_ABS_PROFIT, CHART_TRADE_COUNT],
right: '5%',
},
],
visualMap: [
{
dimension: 1,
seriesIndex: 0,
show: false,
pieces: [
{
max: 0.0,
min: absoluteMin.value,
color: 'red',
},
{
min: 0.0,
max: absoluteMax.value,
color: 'green',
},
],
},
],
yAxis: [
{
type: 'value',
name: CHART_ABS_PROFIT,
splitLine: {
xAxis: [
{
type: 'category',
},
],
visualMap: [
{
dimension: 1,
seriesIndex: 0,
show: false,
pieces: [
{
max: 0.0,
min: absoluteMin.value,
color: 'red',
},
{
min: 0.0,
max: absoluteMax.value,
color: 'green',
},
],
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 40,
},
{
type: 'value',
name: CHART_TRADE_COUNT,
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
],
series: [
{
type: 'line',
name: CHART_ABS_PROFIT,
// Color is induced by visualMap
},
{
type: 'bar',
name: CHART_TRADE_COUNT,
itemStyle: {
color: 'rgba(150,150,150,0.3)',
],
yAxis: [
{
type: 'value',
name: CHART_ABS_PROFIT,
splitLine: {
show: false,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 40,
},
yAxisIndex: 1,
},
],
{
type: 'value',
name: CHART_TRADE_COUNT,
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
],
series: [
{
type: 'line',
name: CHART_ABS_PROFIT,
// Color is induced by visualMap
},
{
type: 'bar',
name: CHART_TRADE_COUNT,
itemStyle: {
color: 'rgba(150,150,150,0.3)',
},
yAxisIndex: 1,
},
],
};
});
return {
dailyChartOptions,
getChartTheme,
settingsStore,
};
},
});

View File

@ -3,15 +3,14 @@
v-if="trades.length > 0"
:option="hourlyChartOptions"
autoresize
:theme="getChartTheme"
:theme="settingsStore.chartTheme"
/>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Getter } from 'vuex-class';
import ECharts from 'vue-echarts';
import { useSettingsStore } from '@/stores/settings';
import { defineComponent, computed } from '@vue/composition-api';
import { Trade } from '@/types';
import { timestampHour } from '@/shared/formatters';
@ -46,122 +45,122 @@ use([
const CHART_PROFIT = 'Profit %';
const CHART_TRADE_COUNT = 'Trade Count';
@Component({
export default defineComponent({
name: 'HourlyChart',
components: {
'v-chart': ECharts,
},
})
export default class HourlyChart extends Vue {
// TODO: This chart is not used at the moment!
@Prop({ required: true }) trades!: Trade[];
props: {
trades: { required: true, type: Array as () => Trade[] },
showTitle: { default: true, type: Boolean },
},
setup(props) {
const settingsStore = useSettingsStore();
@Prop({ default: true, type: Boolean }) showTitle!: boolean;
@Getter getChartTheme!: string;
get hourlyData() {
const res = new Array(24);
for (let i = 0; i < 24; i += 1) {
res[i] = { hour: i, hourDesc: `${i}h`, profit: 0.0, count: 0.0 };
}
for (let i = 0, len = this.trades.length; i < len; i += 1) {
const trade = this.trades[i];
if (trade.close_timestamp) {
const hour = timestampHour(trade.close_timestamp);
res[hour].profit += trade.profit_ratio;
res[hour].count += 1;
const hourlyData = computed(() => {
const res = new Array(24);
for (let i = 0; i < 24; i += 1) {
res[i] = { hour: i, hourDesc: `${i}h`, profit: 0.0, count: 0.0 };
}
}
return res;
}
get hourlyChartOptions(): EChartsOption {
return {
title: {
text: 'Hourly Profit',
show: this.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['hourDesc', 'profit', 'count'],
source: this.hourlyData,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
for (let i = 0, len = props.trades.length; i < len; i += 1) {
const trade = props.trades[i];
if (trade.close_timestamp) {
const hour = timestampHour(trade.close_timestamp);
res[hour].profit += trade.profit_ratio;
res[hour].count += 1;
}
}
return res;
});
const hourlyChartOptions = computed((): EChartsOption => {
return {
title: {
text: 'Hourly Profit',
show: props.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['hourDesc', 'profit', 'count'],
source: hourlyData.value,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
},
},
},
},
legend: {
data: [CHART_PROFIT, CHART_TRADE_COUNT],
right: '5%',
},
xAxis: {
type: 'category',
},
yAxis: [
{
type: 'value',
name: CHART_PROFIT,
splitLine: {
legend: {
data: [CHART_PROFIT, CHART_TRADE_COUNT],
right: '5%',
},
xAxis: {
type: 'category',
},
yAxis: [
{
type: 'value',
name: CHART_PROFIT,
splitLine: {
show: false,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
{
type: 'value',
name: CHART_TRADE_COUNT,
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
],
visualMap: [
{
dimension: 1,
seriesIndex: 0,
show: false,
pieces: [
{
max: 0.0,
min: -2,
color: 'red',
},
{
min: 0.0,
max: 2,
color: 'green',
},
],
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
{
type: 'value',
name: CHART_TRADE_COUNT,
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
],
visualMap: [
{
dimension: 1,
seriesIndex: 0,
show: false,
pieces: [
{
max: 0.0,
min: -2,
color: 'red',
},
{
min: 0.0,
max: 2,
color: 'green',
},
],
},
],
series: [
{
type: 'line',
name: CHART_PROFIT,
animation: false,
// symbol: 'none',
},
{
type: 'bar',
name: CHART_TRADE_COUNT,
animation: false,
itemStyle: {
color: 'rgba(150,150,150,0.3)',
],
series: [
{
type: 'line',
name: CHART_PROFIT,
animation: false,
// symbol: 'none',
},
yAxisIndex: 1,
},
],
};
}
}
{
type: 'bar',
name: CHART_TRADE_COUNT,
animation: false,
itemStyle: {
color: 'rgba(150,150,150,0.3)',
},
yAxisIndex: 1,
},
],
};
});
return { settingsStore, hourlyChartOptions };
},
});
</script>
<style scoped>

View File

@ -120,238 +120,225 @@
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit, Watch } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { PlotConfig, EMPTY_PLOTCONFIG, IndicatorConfig } from '@/types';
import { getCustomPlotConfig } from '@/shared/storage';
import PlotIndicator from '@/components/charts/PlotIndicator.vue';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { AlertActions } from '@/store/modules/alerts';
import StoreModules from '@/store/storeSubModules';
import { showAlert } from '@/stores/alerts';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, computed, ref, watch, onMounted } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const alerts = namespace(StoreModules.alerts);
@Component({
export default defineComponent({
name: 'PlotConfigurator',
components: { PlotIndicator },
})
export default class PlotConfigurator extends Vue {
@Prop({ required: true }) value!: PlotConfig;
props: {
value: { type: Object as () => PlotConfig, required: true },
columns: { required: true, type: Array as () => string[] },
asModal: { required: false, default: true, type: Boolean },
},
emits: ['input'],
setup(props, { emit }) {
const botStore = useBotStore();
@Prop({ required: true }) columns!: Array<string>;
const plotConfig = ref<PlotConfig>(EMPTY_PLOTCONFIG);
@Prop({ required: false, default: true }) asModal!: boolean;
const plotConfigNameLoc = ref('default');
const newSubplotName = ref('');
const selIndicatorName = ref('');
const addNewIndicator = ref(false);
const showConfig = ref(false);
const selSubPlot = ref('main_plot');
const tempPlotConfig = ref<PlotConfig>();
const tempPlotConfigValid = ref(true);
@Emit('input')
emitPlotConfig() {
return this.plotConfig;
}
const isMainPlot = computed(() => {
return selSubPlot.value === 'main_plot';
});
@ftbot.Action getStrategyPlotConfig;
const currentPlotConfig = computed(() => {
if (isMainPlot.value) {
return plotConfig.value.main_plot;
}
@ftbot.Getter [BotStoreGetters.strategyPlotConfig];
return plotConfig.value.subplots[selSubPlot.value];
});
const subplots = computed((): string[] => {
// Subplot keys (for selection window)
return ['main_plot', ...Object.keys(plotConfig.value.subplots)];
});
const usedColumns = computed((): string[] => {
if (isMainPlot.value) {
return Object.keys(plotConfig.value.main_plot);
}
if (selSubPlot.value in plotConfig.value.subplots) {
return Object.keys(plotConfig.value.subplots[selSubPlot.value]);
}
return [];
});
get selIndicator(): Record<string, IndicatorConfig> {
if (this.addNewIndicator) {
return {};
}
if (this.selIndicatorName) {
return {
[this.selIndicatorName]: this.currentPlotConfig[this.selIndicatorName],
};
}
return {};
}
const addIndicator = (newIndicator: Record<string, IndicatorConfig>) => {
console.log(plotConfig.value);
set selIndicator(newValue: Record<string, IndicatorConfig>) {
// console.log('newValue', newValue);
const name = Object.keys(newValue)[0];
// this.currentPlotConfig[this.selIndicatorName] = { ...newValue[name] };
// this.emitPlotConfig();
if (name && newValue) {
this.addIndicator(newValue);
} else {
this.addNewIndicator = false;
}
}
// const { plotConfig.value } = this;
const name = Object.keys(newIndicator)[0];
const indicator = newIndicator[name];
if (isMainPlot.value) {
console.log(`Adding ${name} to MainPlot`);
plotConfig.value.main_plot[name] = { ...indicator };
} else {
console.log(`Adding ${name} to ${selSubPlot.value}`);
plotConfig.value.subplots[selSubPlot.value][name] = { ...indicator };
}
plotConfig: PlotConfig = EMPTY_PLOTCONFIG;
plotOptions = [
{ text: 'Main Plot', value: 'main_plot' },
{ text: 'Subplots', value: 'subplots' },
];
plotConfigNameLoc = 'default';
newSubplotName = '';
selAvailableIndicator = '';
selIndicatorName = '';
addNewIndicator = false;
showConfig = false;
selSubPlot = 'main_plot';
tempPlotConfig?: PlotConfig = undefined;
tempPlotConfigValid = true;
@ftbot.Action saveCustomPlotConfig;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action updatePlotConfigName!: (plotConfigName: string) => void;
@ftbot.Getter [BotStoreGetters.plotConfigName]!: string;
@alerts.Action [AlertActions.addAlert];
get plotConfigJson() {
return JSON.stringify(this.plotConfig, null, 2);
}
set plotConfigJson(newValue: string) {
try {
this.tempPlotConfig = JSON.parse(newValue);
// TODO: Should Validate schema validity (should be PlotConfig type...)
this.tempPlotConfigValid = true;
} catch (err) {
this.tempPlotConfigValid = false;
}
}
get subplots(): string[] {
// Subplot keys (for selection window)
return ['main_plot', ...Object.keys(this.plotConfig.subplots)];
}
get usedColumns(): string[] {
if (this.isMainPlot) {
return Object.keys(this.plotConfig.main_plot);
}
if (this.selSubPlot in this.plotConfig.subplots) {
return Object.keys(this.plotConfig.subplots[this.selSubPlot]);
}
return [];
}
get isMainPlot() {
return this.selSubPlot === 'main_plot';
}
get currentPlotConfig() {
if (this.isMainPlot) {
return this.plotConfig.main_plot;
}
return this.plotConfig.subplots[this.selSubPlot];
}
mounted() {
console.log('Config Mounted', this.value);
this.plotConfig = this.value;
this.plotConfigNameLoc = this.plotConfigName;
}
@Watch('value')
watchValue() {
this.plotConfig = this.value;
this.plotConfigNameLoc = this.plotConfigName;
}
addIndicator(newIndicator: Record<string, IndicatorConfig>) {
console.log(this.plotConfig);
const { plotConfig } = this;
const name = Object.keys(newIndicator)[0];
const indicator = newIndicator[name];
if (this.isMainPlot) {
console.log(`Adding ${name} to MainPlot`);
plotConfig.main_plot[name] = { ...indicator };
} else {
console.log(`Adding ${name} to ${this.selSubPlot}`);
plotConfig.subplots[this.selSubPlot][name] = { ...indicator };
}
this.plotConfig = { ...plotConfig };
// Reset random color
this.addNewIndicator = false;
this.emitPlotConfig();
}
removeIndicator() {
console.log(this.plotConfig);
const { plotConfig } = this;
if (this.isMainPlot) {
console.log(`Removing ${this.selIndicatorName} from MainPlot`);
delete plotConfig.main_plot[this.selIndicatorName];
} else {
console.log(`Removing ${this.selIndicatorName} from ${this.selSubPlot}`);
delete plotConfig.subplots[this.selSubPlot][this.selIndicatorName];
}
this.plotConfig = { ...plotConfig };
console.log(this.plotConfig);
this.selIndicatorName = '';
this.emitPlotConfig();
}
addSubplot() {
this.plotConfig.subplots = {
...this.plotConfig.subplots,
[this.newSubplotName]: {},
plotConfig.value = { ...plotConfig.value };
// Reset random color
addNewIndicator.value = false;
emit('input', plotConfig.value);
};
this.selSubPlot = this.newSubplotName;
this.newSubplotName = '';
console.log(this.plotConfig);
this.emitPlotConfig();
}
const selIndicator = computed({
get() {
if (addNewIndicator.value) {
return {};
}
if (selIndicatorName.value) {
return {
[selIndicatorName.value]: currentPlotConfig.value[selIndicatorName.value],
};
}
return {};
},
set(newValue: Record<string, IndicatorConfig>) {
// console.log('newValue', newValue);
const name = Object.keys(newValue)[0];
// this.currentPlotConfig[this.selIndicatorName] = { ...newValue[name] };
// this.emitPlotConfig();
if (name && newValue) {
addIndicator(newValue);
} else {
addNewIndicator.value = false;
}
},
});
delSubplot() {
delete this.plotConfig.subplots[this.selSubPlot];
this.plotConfig.subplots = { ...this.plotConfig.subplots };
this.selSubPlot = '';
}
const plotConfigJson = computed({
get() {
return JSON.stringify(plotConfig.value, null, 2);
},
set(newValue: string) {
try {
tempPlotConfig.value = JSON.parse(newValue);
// TODO: Should Validate schema validity (should be PlotConfig type...)
tempPlotConfigValid.value = true;
} catch (err) {
tempPlotConfigValid.value = false;
}
},
});
savePlotConfig() {
this.saveCustomPlotConfig({ [this.plotConfigNameLoc]: this.plotConfig });
}
const removeIndicator = () => {
console.log(plotConfig.value);
// const { plotConfig } = this;
if (isMainPlot.value) {
console.log(`Removing ${selIndicatorName.value} from MainPlot`);
delete plotConfig.value.main_plot[selIndicatorName.value];
} else {
console.log(`Removing ${selIndicatorName.value} from ${selSubPlot.value}`);
delete plotConfig.value.subplots[selSubPlot.value][selIndicatorName.value];
}
loadPlotConfig() {
this.plotConfig = getCustomPlotConfig(this.plotConfigNameLoc);
console.log(this.plotConfig);
console.log('loading config');
this.emitPlotConfig();
}
plotConfig.value = { ...plotConfig.value };
console.log(plotConfig.value);
selIndicatorName.value = '';
emit('input', plotConfig.value);
};
const addSubplot = () => {
plotConfig.value.subplots = {
...plotConfig.value.subplots,
[newSubplotName.value]: {},
};
selSubPlot.value = newSubplotName.value;
newSubplotName.value = '';
loadConfigFromString() {
// this.plotConfig = JSON.parse();
if (this.tempPlotConfig !== undefined && this.tempPlotConfigValid) {
this.plotConfig = this.tempPlotConfig;
this.emitPlotConfig();
}
}
console.log(plotConfig.value);
emit('input', plotConfig.value);
};
resetConfig() {
this.plotConfig = { ...EMPTY_PLOTCONFIG };
}
const delSubplot = () => {
delete plotConfig.value.subplots[selSubPlot.value];
plotConfig.value.subplots = { ...plotConfig.value.subplots };
selSubPlot.value = '';
};
const loadPlotConfig = () => {
plotConfig.value = getCustomPlotConfig(plotConfigNameLoc.value);
console.log(plotConfig.value);
console.log('loading config');
emit('input', plotConfig.value);
};
async loadPlotConfigFromStrategy() {
try {
await this.getStrategyPlotConfig();
this.plotConfig = this.strategyPlotConfig;
this.emitPlotConfig();
} catch (data) {
//
this.addAlert({ message: 'Failed to load Plot configuration from Strategy.' });
}
}
}
const loadConfigFromString = () => {
// this.plotConfig = JSON.parse();
if (tempPlotConfig.value !== undefined && tempPlotConfigValid.value) {
plotConfig.value = tempPlotConfig.value;
emit('input', plotConfig.value);
}
};
const resetConfig = () => {
plotConfig.value = { ...EMPTY_PLOTCONFIG };
};
const loadPlotConfigFromStrategy = async () => {
try {
await botStore.activeBot.getStrategyPlotConfig();
if (botStore.activeBot.strategyPlotConfig) {
plotConfig.value = botStore.activeBot.strategyPlotConfig;
emit('input', plotConfig.value);
}
} catch (data) {
//
showAlert('Failed to load Plot configuration from Strategy.');
}
};
const savePlotConfig = () => {
botStore.activeBot.saveCustomPlotConfig({ [plotConfigNameLoc.value]: plotConfig.value });
};
watch(props.value, () => {
console.log('config value');
plotConfig.value = props.value;
plotConfigNameLoc.value = botStore.activeBot.plotConfigName;
});
onMounted(() => {
console.log('Config Mounted', props.value);
plotConfig.value = props.value;
plotConfigNameLoc.value = botStore.activeBot.plotConfigName;
});
return {
botStore,
removeIndicator,
addSubplot,
delSubplot,
loadPlotConfig,
loadConfigFromString,
resetConfig,
loadPlotConfigFromStrategy,
savePlotConfig,
showConfig,
addNewIndicator,
selIndicatorName,
usedColumns,
selSubPlot,
newSubplotName,
subplots,
plotConfigNameLoc,
selIndicator,
plotConfigJson,
tempPlotConfigValid,
};
},
});
</script>
<style scoped>
@ -362,6 +349,7 @@ export default class PlotConfigurator extends Vue {
.form-group {
margin-bottom: 0.5rem;
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;

View File

@ -71,70 +71,80 @@
<script lang="ts">
import { ChartType, IndicatorConfig } from '@/types';
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import randomColor from '@/shared/randomColor';
@Component({})
export default class PlotIndicator extends Vue {
@Prop({ required: true }) value!: Record<string, IndicatorConfig>;
import { defineComponent, computed, ref, watch } from '@vue/composition-api';
@Prop({ required: true }) columns!: string[];
export default defineComponent({
name: 'PlotIndicator',
props: {
value: { required: true, type: Object as () => Record<string, IndicatorConfig> },
columns: { required: true, type: Array as () => string[] },
addNew: { required: true, type: Boolean },
},
emits: ['input'],
setup(props, { emit }) {
const selColor = ref(randomColor());
const graphType = ref<ChartType>(ChartType.line);
const availableGraphTypes = ref(Object.keys(ChartType));
const selAvailableIndicator = ref('');
const cancelled = ref(false);
@Prop({ required: true }) addNew!: boolean;
@Emit('input')
emitIndicator() {
return this.combinedIndicator;
}
selColor = randomColor();
graphType: ChartType = ChartType.line;
availableGraphTypes = Object.keys(ChartType);
selAvailableIndicator = '';
cancelled = false;
@Watch('value')
watchValue() {
[this.selAvailableIndicator] = Object.keys(this.value);
this.cancelled = false;
if (this.selAvailableIndicator && this.value) {
this.selColor = this.value[this.selAvailableIndicator].color || randomColor();
this.graphType = this.value[this.selAvailableIndicator].type || ChartType.line;
}
}
@Watch('selColor')
watchColor() {
if (!this.addNew) {
this.emitIndicator();
}
}
clickCancel() {
this.cancelled = true;
this.emitIndicator();
}
get combinedIndicator() {
if (this.cancelled || !this.selAvailableIndicator) {
return {};
}
return {
[this.selAvailableIndicator]: {
color: this.selColor,
type: this.graphType,
},
const newColor = () => {
selColor.value = randomColor();
};
}
newColor() {
this.selColor = randomColor();
}
}
const combinedIndicator = computed(() => {
if (cancelled.value || !selAvailableIndicator.value) {
return {};
}
return {
[selAvailableIndicator.value]: {
color: selColor.value,
type: graphType.value,
},
};
});
const emitIndicator = () => {
emit('input', combinedIndicator.value);
};
const clickCancel = () => {
cancelled.value = true;
emitIndicator();
};
watch(
() => props.value,
() => {
[selAvailableIndicator.value] = Object.keys(props.value);
cancelled.value = false;
if (selAvailableIndicator.value && props.value) {
selColor.value = props.value[selAvailableIndicator.value].color || randomColor();
graphType.value = props.value[selAvailableIndicator.value].type || ChartType.line;
}
},
);
watch(selColor, () => {
if (!props.addNew) {
emitIndicator();
}
});
return {
selColor,
graphType,
availableGraphTypes,
selAvailableIndicator,
cancelled,
combinedIndicator,
newColor,
emitIndicator,
clickCancel,
};
},
});
</script>
<style scoped>

View File

@ -1,11 +1,13 @@
<template>
<v-chart v-if="trades.length > 0" :option="chartOptions" autoresize :theme="getChartTheme" />
<v-chart
v-if="trades.length > 0"
:option="chartOptions"
autoresize
:theme="settingsStore.chartTheme"
/>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Getter } from 'vuex-class';
import ECharts from 'vue-echarts';
import { EChartsOption } from 'echarts';
@ -23,7 +25,8 @@ import {
} from 'echarts/components';
import { ClosedTrade } from '@/types';
import { useSettingsStore } from '@/stores/settings';
import { defineComponent, computed } from '@vue/composition-api';
import { timestampms } from '@/shared/formatters';
use([
@ -45,142 +48,145 @@ use([
const CHART_PROFIT = 'Profit %';
const CHART_COLOR = '#9be0a8';
@Component({
export default defineComponent({
name: 'TradesLogChart',
components: {
'v-chart': ECharts,
},
})
export default class TradesLogChart extends Vue {
@Prop({ required: true }) trades!: ClosedTrade[];
props: {
trades: { required: true, type: Array as () => ClosedTrade[] },
showTitle: { default: true, type: Boolean },
},
setup(props) {
const settingsStore = useSettingsStore();
const chartData = computed(() => {
const res: (number | string)[][] = [];
const sortedTrades = props.trades
.slice(0)
.sort((a, b) => (a.close_timestamp > b.close_timestamp ? 1 : -1));
for (let i = 0, len = sortedTrades.length; i < len; i += 1) {
const trade = sortedTrades[i];
const entry = [
i,
(trade.profit_ratio * 100).toFixed(2),
trade.pair,
trade.botName,
timestampms(trade.close_timestamp),
trade.is_short === undefined || !trade.is_short ? 'Long' : 'Short',
];
res.push(entry);
}
return res;
});
@Prop({ default: true, type: Boolean }) showTitle!: boolean;
@Getter getChartTheme!: string;
get chartData() {
const res: (number | string)[][] = [];
const sortedTrades = this.trades
.slice(0)
.sort((a, b) => (a.close_timestamp > b.close_timestamp ? 1 : -1));
for (let i = 0, len = sortedTrades.length; i < len; i += 1) {
const trade = sortedTrades[i];
const entry = [
i,
(trade.profit_ratio * 100).toFixed(2),
trade.pair,
trade.botName,
timestampms(trade.close_timestamp),
trade.is_short === undefined || !trade.is_short ? 'Long' : 'Short',
];
res.push(entry);
}
return res;
}
get chartOptions(): EChartsOption {
const { chartData } = this;
// Show a maximum of 50 trades by default - allowing to zoom out further.
const datazoomStart = chartData.length > 0 ? (1 - 50 / chartData.length) * 100 : 100;
return {
title: {
text: 'Trades log',
show: this.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'profit'],
source: chartData,
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
const botName = params[0].data[3] ? ` | ${params[0].data[3]}` : '';
return `${params[0].data[2]} | ${params[0].data[5]} ${botName}<br>${params[0].data[4]}<br>Profit ${params[0].data[1]} %`;
const chartOptions = computed((): EChartsOption => {
// const { chartData } = this;
// Show a maximum of 50 trades by default - allowing to zoom out further.
const datazoomStart =
chartData.value.length > 0 ? (1 - 50 / chartData.value.length) * 100 : 100;
return {
title: {
text: 'Trades log',
show: props.showTitle,
},
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'profit'],
source: chartData.value,
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
const botName = params[0].data[3] ? ` | ${params[0].data[3]}` : '';
return `${params[0].data[2]} | ${params[0].data[5]} ${botName}<br>${params[0].data[4]}<br>Profit ${params[0].data[1]} %`;
},
axisPointer: {
type: 'line',
label: {
backgroundColor: '#6a7985',
},
},
},
},
xAxis: {
type: 'value',
show: false,
},
yAxis: [
{
xAxis: {
type: 'value',
name: CHART_PROFIT,
splitLine: {
show: false,
show: false,
},
yAxis: [
{
type: 'value',
name: CHART_PROFIT,
splitLine: {
show: false,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
},
nameRotate: 90,
nameLocation: 'middle',
nameGap: 30,
],
grid: {
bottom: 80,
},
],
grid: {
bottom: 80,
},
dataZoom: [
{
type: 'inside',
start: datazoomStart,
end: 100,
},
{
show: true,
type: 'slider',
bottom: 10,
start: datazoomStart,
end: 100,
},
],
visualMap: [
{
show: true,
seriesIndex: 0,
pieces: [
{
max: 0.0,
color: '#f84960',
},
{
min: 0.0,
color: '#2ed191',
},
],
},
],
series: [
{
type: 'bar',
name: CHART_PROFIT,
barGap: '0%',
barCategoryGap: '0%',
animation: false,
label: {
dataZoom: [
{
type: 'inside',
start: datazoomStart,
end: 100,
},
{
show: true,
position: 'top',
rotate: 90,
offset: [7.5, 7.5],
formatter: '{@[1]} %',
color: this.getChartTheme === 'dark' ? '#c2c2c2' : '#3c3c3c',
type: 'slider',
bottom: 10,
start: datazoomStart,
end: 100,
},
encode: {
x: 0,
y: 1,
],
visualMap: [
{
show: true,
seriesIndex: 0,
pieces: [
{
max: 0.0,
color: '#f84960',
},
{
min: 0.0,
color: '#2ed191',
},
],
},
],
series: [
{
type: 'bar',
name: CHART_PROFIT,
barGap: '0%',
barCategoryGap: '0%',
animation: false,
label: {
show: true,
position: 'top',
rotate: 90,
offset: [7.5, 7.5],
formatter: '{@[1]} %',
color: settingsStore.chartTheme === 'dark' ? '#c2c2c2' : '#3c3c3c',
},
encode: {
x: 0,
y: 1,
},
itemStyle: {
color: CHART_COLOR,
itemStyle: {
color: CHART_COLOR,
},
},
},
],
};
}
}
],
};
});
return { settingsStore, chartData, chartOptions };
},
});
</script>
<style scoped>

View File

@ -4,7 +4,7 @@
class="btn btn-secondary float-right"
title="Refresh"
aria-label="Refresh"
@click="getBacktestHistory"
@click="botStore.activeBot.getBacktestHistory"
>
&#x21bb;
</button>
@ -12,13 +12,13 @@
Load Historic results from disk. You can click on multiple results to load all of them into
freqUI.
</p>
<b-list-group v-if="backtestHistoryList" class="ml-2">
<b-list-group v-if="botStore.activeBot.backtestHistoryList" class="ml-2">
<b-list-group-item
v-for="(res, idx) in backtestHistoryList"
v-for="(res, idx) in botStore.activeBot.backtestHistoryList"
:key="idx"
class="d-flex justify-content-between align-items-center py-1 mb-1"
button
@click="getBacktestHistoryResult(res)"
@click="botStore.activeBot.getBacktestHistoryResult(res)"
>
<strong>{{ res.strategy }}</strong>
backtested on: {{ timestampms(res.backtest_start_time * 1000) }}
@ -30,29 +30,20 @@
<script>
import { defineComponent, onMounted } from '@vue/composition-api';
import StoreModules from '@/store/storeSubModules';
import { useNamespacedActions, useNamespacedGetters } from 'vuex-composition-helpers';
import { timestampms } from '@/shared/formatters';
import { useBotStore } from '@/stores/ftbotwrapper';
export default defineComponent({
setup() {
const { getBacktestHistory, getBacktestHistoryResult } = useNamespacedActions(
StoreModules.ftbot,
['getBacktestHistory', 'getBacktestHistoryResult'],
);
const { backtestHistoryList } = useNamespacedGetters(StoreModules.ftbot, [
'backtestHistoryList',
]);
const botStore = useBotStore();
onMounted(() => {
getBacktestHistory();
botStore.activeBot.getBacktestHistory();
});
return {
getBacktestHistory,
getBacktestHistoryResult,
backtestHistoryList,
timestampms,
botStore,
};
},
});

View File

@ -19,21 +19,29 @@
<script lang="ts">
import { formatPercent } from '@/shared/formatters';
import { StrategyBacktestResult } from '@/types';
import { Component, Emit, Prop, Vue } from 'vue-property-decorator';
@Component({})
export default class BacktestResultSelect extends Vue {
@Prop({ required: true }) backtestHistory!: StrategyBacktestResult[];
import { defineComponent } from '@vue/composition-api';
@Prop({ required: false, default: '' }) selectedBacktestResultKey!: string;
@Emit('selectionChange')
setBacktestResult(key) {
return key;
}
formatPercent = formatPercent;
}
export default defineComponent({
name: 'BacktestResultSelect',
props: {
backtestHistory: {
required: true,
type: Object as () => Record<string, StrategyBacktestResult>,
},
selectedBacktestResultKey: { required: false, default: '', type: String },
},
emits: ['selectionChange'],
setup(_, { emit }) {
const setBacktestResult = (key) => {
emit('selectionChange', key);
};
return {
formatPercent,
setBacktestResult,
};
},
});
</script>
<style scoped></style>

View File

@ -59,11 +59,9 @@
<script lang="ts">
import TradeList from '@/components/ftbot/TradeList.vue';
import { Component, Vue, Prop } from 'vue-property-decorator';
import { StrategyBacktestResult, Trade } from '@/types';
import ValuePair from '@/components/general/ValuePair.vue';
import { defineComponent, computed } from '@vue/composition-api';
import {
timestampms,
formatPercent,
@ -71,298 +69,310 @@ import {
humanizeDurationFromSeconds,
} from '@/shared/formatters';
@Component({
export default defineComponent({
name: 'LoginModal',
components: {
TradeList,
ValuePair,
},
})
export default class BacktestResultView extends Vue {
@Prop({ required: true }) readonly backtestResult!: StrategyBacktestResult;
props: {
backtestResult: { required: true, type: Object as () => StrategyBacktestResult },
},
setup(props) {
const hasBacktestResult = computed(() => {
return !!props.backtestResult;
});
get hasBacktestResult() {
return !!this.backtestResult;
}
const formatPriceStake = (price) => {
return `${formatPrice(price, props.backtestResult.stake_currency_decimals)} ${
props.backtestResult.stake_currency
}`;
};
const getSortedTrades = (backtestResult: StrategyBacktestResult): Trade[] => {
const sortedTrades = backtestResult.trades
.slice()
.sort((a, b) => a.profit_ratio - b.profit_ratio);
return sortedTrades;
};
getSortedTrades(backtestResult: StrategyBacktestResult): Trade[] {
const sortedTrades = backtestResult.trades
.slice()
.sort((a, b) => a.profit_ratio - b.profit_ratio);
return sortedTrades;
}
const bestPair = computed((): string => {
const trades = getSortedTrades(props.backtestResult);
const value = trades[trades.length - 1];
return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`;
});
const worstPair = computed((): string => {
const trades = getSortedTrades(props.backtestResult);
const value = trades[0];
return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`;
});
const backtestResultStats = computed(() => {
// Transpose Result into readable format
const shortMetrics =
props.backtestResult?.trade_count_short && props.backtestResult?.trade_count_short > 0
? [
{ metric: '___', value: '___' },
{
metric: 'Long / Short',
value: `${props.backtestResult.trade_count_long} / ${props.backtestResult.trade_count_short}`,
},
{
metric: 'Total profit Long',
value: `${formatPercent(
props.backtestResult.profit_total_long || 0,
)} | ${formatPriceStake(props.backtestResult.profit_total_long_abs)}`,
},
{
metric: 'Total profit Short',
value: `${formatPercent(
props.backtestResult.profit_total_short || 0,
)} | ${formatPriceStake(props.backtestResult.profit_total_short_abs)}`,
},
]
: [];
formatPriceStake(price) {
return `${formatPrice(price, this.backtestResult.stake_currency_decimals)} ${
this.backtestResult.stake_currency
}`;
}
return [
{
metric: 'Total Profit',
value: `${formatPercent(props.backtestResult.profit_total)} | ${formatPriceStake(
props.backtestResult.profit_total_abs,
)}`,
},
{
metric: 'Total trades / Daily Avg Trades',
value: `${props.backtestResult.total_trades} / ${props.backtestResult.trades_per_day}`,
},
// { metric: 'First trade', value: props.backtestResult.backtest_fi },
// { metric: 'First trade Pair', value: props.backtestResult.backtest_best_day },
{
metric: 'Best day',
value: `${formatPercent(props.backtestResult.backtest_best_day, 2)} | ${formatPriceStake(
props.backtestResult.backtest_best_day_abs,
)}`,
},
{
metric: 'Worst day',
value: `${formatPercent(props.backtestResult.backtest_worst_day, 2)} | ${formatPriceStake(
props.backtestResult.backtest_worst_day_abs,
)}`,
},
get bestPair(): string {
const trades = this.getSortedTrades(this.backtestResult);
const value = trades[trades.length - 1];
return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`;
}
{
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
}`,
},
{
metric: 'Days win/draw/loss',
value: `${props.backtestResult.winning_days} / ${props.backtestResult.draw_days} / ${props.backtestResult.losing_days}`,
},
get worstPair(): string {
const trades = this.getSortedTrades(this.backtestResult);
const value = trades[0];
return `${value.pair} ${formatPercent(value.profit_ratio, 2)}`;
}
{
metric: 'Avg. Duration winners',
value: humanizeDurationFromSeconds(props.backtestResult.winner_holding_avg),
},
{
metric: 'Avg. Duration Losers',
value: humanizeDurationFromSeconds(props.backtestResult.loser_holding_avg),
},
{ metric: 'Rejected entry signals', value: props.backtestResult.rejected_signals },
{
metric: 'Entry/Exit timeouts',
value: `${props.backtestResult.timedout_entry_orders} / ${props.backtestResult.timedout_exit_orders}`,
},
get backtestResultStats() {
// Transpose Result into readable format
const shortMetrics =
this.backtestResult?.trade_count_short && this.backtestResult?.trade_count_short > 0
? [
{ metric: '___', value: '___' },
{
metric: 'Long / Short',
value: `${this.backtestResult.trade_count_long} / ${this.backtestResult.trade_count_short}`,
},
{
metric: 'Total profit Long',
value: `${formatPercent(
this.backtestResult.profit_total_long || 0,
)} | ${this.formatPriceStake(this.backtestResult.profit_total_long_abs)}`,
},
{
metric: 'Total profit Short',
value: `${formatPercent(
this.backtestResult.profit_total_short || 0,
)} | ${this.formatPriceStake(this.backtestResult.profit_total_short_abs)}`,
},
]
: [];
...shortMetrics,
return [
{
metric: 'Total Profit',
value: `${formatPercent(this.backtestResult.profit_total)} | ${this.formatPriceStake(
this.backtestResult.profit_total_abs,
)}`,
},
{
metric: 'Total trades / Daily Avg Trades',
value: `${this.backtestResult.total_trades} / ${this.backtestResult.trades_per_day}`,
},
// { metric: 'First trade', value: this.backtestResult.backtest_fi },
// { metric: 'First trade Pair', value: this.backtestResult.backtest_best_day },
{
metric: 'Best day',
value: `${formatPercent(
this.backtestResult.backtest_best_day,
2,
)} | ${this.formatPriceStake(this.backtestResult.backtest_best_day_abs)}`,
},
{
metric: 'Worst day',
value: `${formatPercent(
this.backtestResult.backtest_worst_day,
2,
)} | ${this.formatPriceStake(this.backtestResult.backtest_worst_day_abs)}`,
},
{ metric: '___', value: '___' },
{ metric: 'Min balance', value: formatPriceStake(props.backtestResult.csum_min) },
{ metric: 'Max balance', value: formatPriceStake(props.backtestResult.csum_max) },
{ metric: 'Market change', value: formatPercent(props.backtestResult.market_change) },
{ metric: '___', value: '___' },
{
metric: 'Max Drawdown (Account)',
value: formatPercent(props.backtestResult.max_drawdown_account),
},
{
metric: 'Max Drawdown ABS',
value: formatPriceStake(props.backtestResult.max_drawdown_abs),
},
{
metric: 'Drawdown high | low',
value: `${formatPriceStake(props.backtestResult.max_drawdown_high)} | ${formatPriceStake(
props.backtestResult.max_drawdown_low,
)}`,
},
{ metric: 'Drawdown start', value: timestampms(props.backtestResult.drawdown_start_ts) },
{ metric: 'Drawdown end', value: timestampms(props.backtestResult.drawdown_end_ts) },
{ metric: '___', value: '___' },
{
metric: 'Win/Draw/Loss',
value: `${
this.backtestResult.results_per_pair[this.backtestResult.results_per_pair.length - 1].wins
} / ${
this.backtestResult.results_per_pair[this.backtestResult.results_per_pair.length - 1]
.draws
} / ${
this.backtestResult.results_per_pair[this.backtestResult.results_per_pair.length - 1]
.losses
}`,
},
{
metric: 'Days win/draw/loss',
value: `${this.backtestResult.winning_days} / ${this.backtestResult.draw_days} / ${this.backtestResult.losing_days}`,
},
{
metric: 'Best Pair',
value: `${props.backtestResult.best_pair.key} ${formatPercent(
props.backtestResult.best_pair.profit_sum,
)}`,
},
{
metric: 'Worst Pair',
value: `${props.backtestResult.worst_pair.key} ${formatPercent(
props.backtestResult.worst_pair.profit_sum,
)}`,
},
{ metric: 'Best single Trade', value: bestPair },
{ metric: 'Worst single Trade', value: worstPair },
];
});
{
metric: 'Avg. Duration winners',
value: humanizeDurationFromSeconds(this.backtestResult.winner_holding_avg),
},
{
metric: 'Avg. Duration Losers',
value: humanizeDurationFromSeconds(this.backtestResult.loser_holding_avg),
},
{ metric: 'Rejected entry signals', value: this.backtestResult.rejected_signals },
{
metric: 'Entry/Exit timeouts',
value: `${this.backtestResult.timedout_entry_orders} / ${this.backtestResult.timedout_exit_orders}`,
},
const backtestResultSettings = computed(() => {
// Transpose Result into readable format
return [
{ setting: 'Backtesting from', value: timestampms(props.backtestResult.backtest_start_ts) },
{ setting: 'Backtesting to', value: timestampms(props.backtestResult.backtest_end_ts) },
{
setting: 'BT execution time',
value: humanizeDurationFromSeconds(
props.backtestResult.backtest_run_end_ts - props.backtestResult.backtest_run_start_ts,
),
},
{ setting: 'Max open trades', value: props.backtestResult.max_open_trades },
{ setting: 'Timeframe', value: props.backtestResult.timeframe },
{ setting: 'Timerange', value: props.backtestResult.timerange },
{ setting: 'Stoploss', value: formatPercent(props.backtestResult.stoploss, 2) },
{ setting: 'Trailing Stoploss', value: props.backtestResult.trailing_stop },
{
setting: 'Trail only when offset is reached',
value: props.backtestResult.trailing_only_offset_is_reached,
},
{ setting: 'Trailing Stop positive', value: props.backtestResult.trailing_stop_positive },
{
setting: 'Trailing stop positive offset',
value: props.backtestResult.trailing_stop_positive_offset,
},
{ setting: 'Custom Stoploss', value: props.backtestResult.use_custom_stoploss },
{ setting: 'ROI', value: props.backtestResult.minimal_roi },
{
setting: 'Use Exit Signal',
value:
props.backtestResult.use_exit_signal !== undefined
? props.backtestResult.use_exit_signal
: props.backtestResult.use_sell_signal,
},
{
setting: 'Exit profit only',
value:
props.backtestResult.exit_profit_only !== undefined
? props.backtestResult.exit_profit_only
: props.backtestResult.sell_profit_only,
},
{
setting: 'Exit profit offset',
value:
props.backtestResult.exit_profit_offset !== undefined
? props.backtestResult.exit_profit_offset
: props.backtestResult.sell_profit_offset,
},
{ setting: 'Enable protections', value: props.backtestResult.enable_protections },
{
setting: 'Starting balance',
value: formatPriceStake(props.backtestResult.starting_balance),
},
{
setting: 'Final balance',
value: formatPriceStake(props.backtestResult.final_balance),
},
{
setting: 'Avg. stake amount',
value: formatPriceStake(props.backtestResult.avg_stake_amount),
},
{
setting: 'Total trade volume',
value: formatPriceStake(props.backtestResult.total_volume),
},
];
});
const perPairFields = computed(() => {
return [
{ key: 'key', label: 'Pair' },
{ key: 'trades', label: 'Buys' },
{
key: 'profit_mean',
label: 'Avg Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'profit_sum', label: 'Cum Profit %', formatter: (value) => formatPercent(value, 2) },
{
key: 'profit_total_abs',
label: `Tot Profit ${props.backtestResult.stake_currency}`,
formatter: (value) => formatPrice(value, props.backtestResult.stake_currency_decimals),
},
{
key: 'profit_total',
label: 'Tot Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'duration_avg', label: 'Avg Duration' },
{ key: 'wins', label: 'Wins' },
{ key: 'draws', label: 'Draws' },
{ key: 'losses', label: 'Losses' },
];
});
...shortMetrics,
const perExitReason = computed(() => {
return [
{ key: 'exit_reason', label: 'Exit Reason' },
{ key: 'trades', label: 'Buys' },
{
key: 'profit_mean',
label: 'Avg Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'profit_sum', label: 'Cum Profit %', formatter: (value) => formatPercent(value, 2) },
{
key: 'profit_total_abs',
label: `Tot Profit ${props.backtestResult.stake_currency}`,
{ metric: '___', value: '___' },
{ metric: 'Min balance', value: this.formatPriceStake(this.backtestResult.csum_min) },
{ metric: 'Max balance', value: this.formatPriceStake(this.backtestResult.csum_max) },
{ metric: 'Market change', value: formatPercent(this.backtestResult.market_change) },
{ metric: '___', value: '___' },
{
metric: 'Max Drawdown (Account)',
value: formatPercent(this.backtestResult.max_drawdown_account),
},
{
metric: 'Max Drawdown ABS',
value: this.formatPriceStake(this.backtestResult.max_drawdown_abs),
},
{
metric: 'Drawdown high | low',
value: `${this.formatPriceStake(
this.backtestResult.max_drawdown_high,
)} | ${this.formatPriceStake(this.backtestResult.max_drawdown_low)}`,
},
{ metric: 'Drawdown start', value: timestampms(this.backtestResult.drawdown_start_ts) },
{ metric: 'Drawdown end', value: timestampms(this.backtestResult.drawdown_end_ts) },
{ metric: '___', value: '___' },
{
metric: 'Best Pair',
value: `${this.backtestResult.best_pair.key} ${formatPercent(
this.backtestResult.best_pair.profit_sum,
)}`,
},
{
metric: 'Worst Pair',
value: `${this.backtestResult.worst_pair.key} ${formatPercent(
this.backtestResult.worst_pair.profit_sum,
)}`,
},
{ metric: 'Best single Trade', value: this.bestPair },
{ metric: 'Worst single Trade', value: this.worstPair },
formatter: (value) => formatPrice(value, props.backtestResult.stake_currency_decimals),
},
{
key: 'profit_total',
label: 'Tot Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'wins', label: 'Wins' },
{ key: 'draws', label: 'Draws' },
{ key: 'losses', label: 'Losses' },
];
});
const backtestResultFields: Array<Record<string, string>> = [
{ key: 'metric', label: 'Metric' },
{ key: 'value', label: 'Value' },
];
}
timestampms = timestampms;
formatPercent = formatPercent;
get backtestResultSettings() {
// Transpose Result into readable format
return [
{ setting: 'Backtesting from', value: timestampms(this.backtestResult.backtest_start_ts) },
{ setting: 'Backtesting to', value: timestampms(this.backtestResult.backtest_end_ts) },
{
setting: 'BT execution time',
value: humanizeDurationFromSeconds(
this.backtestResult.backtest_run_end_ts - this.backtestResult.backtest_run_start_ts,
),
},
{ setting: 'Max open trades', value: this.backtestResult.max_open_trades },
{ setting: 'Timeframe', value: this.backtestResult.timeframe },
{ setting: 'Timerange', value: this.backtestResult.timerange },
{ setting: 'Stoploss', value: formatPercent(this.backtestResult.stoploss, 2) },
{ setting: 'Trailing Stoploss', value: this.backtestResult.trailing_stop },
{
setting: 'Trail only when offset is reached',
value: this.backtestResult.trailing_only_offset_is_reached,
},
{ setting: 'Trailing Stop positive', value: this.backtestResult.trailing_stop_positive },
{
setting: 'Trailing stop positive offset',
value: this.backtestResult.trailing_stop_positive_offset,
},
{ setting: 'Custom Stoploss', value: this.backtestResult.use_custom_stoploss },
{ setting: 'ROI', value: this.backtestResult.minimal_roi },
{
setting: 'Use Exit Signal',
value:
this.backtestResult.use_exit_signal !== undefined
? this.backtestResult.use_exit_signal
: this.backtestResult.use_sell_signal,
},
{
setting: 'Exit profit only',
value:
this.backtestResult.exit_profit_only !== undefined
? this.backtestResult.exit_profit_only
: this.backtestResult.sell_profit_only,
},
{
setting: 'Exit profit offset',
value:
this.backtestResult.exit_profit_offset !== undefined
? this.backtestResult.exit_profit_offset
: this.backtestResult.sell_profit_offset,
},
{ setting: 'Enable protections', value: this.backtestResult.enable_protections },
{
setting: 'Starting balance',
value: this.formatPriceStake(this.backtestResult.starting_balance),
},
{
setting: 'Final balance',
value: this.formatPriceStake(this.backtestResult.final_balance),
},
{
setting: 'Avg. stake amount',
value: this.formatPriceStake(this.backtestResult.avg_stake_amount),
},
{
setting: 'Total trade volume',
value: this.formatPriceStake(this.backtestResult.total_volume),
},
const backtestsettingFields: Array<Record<string, string>> = [
{ key: 'setting', label: 'Setting' },
{ key: 'value', label: 'Value' },
];
}
get perPairFields() {
return [
{ key: 'key', label: 'Pair' },
{ key: 'trades', label: 'Buys' },
{ key: 'profit_mean', label: 'Avg Profit %', formatter: (value) => formatPercent(value, 2) },
{ key: 'profit_sum', label: 'Cum Profit %', formatter: (value) => formatPercent(value, 2) },
{
key: 'profit_total_abs',
label: `Tot Profit ${this.backtestResult.stake_currency}`,
formatter: (value) => formatPrice(value, this.backtestResult.stake_currency_decimals),
},
{
key: 'profit_total',
label: 'Tot Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'duration_avg', label: 'Avg Duration' },
{ key: 'wins', label: 'Wins' },
{ key: 'draws', label: 'Draws' },
{ key: 'losses', label: 'Losses' },
];
}
get perExitReason() {
return [
{ key: 'exit_reason', label: 'Exit Reason' },
{ key: 'trades', label: 'Buys' },
{ key: 'profit_mean', label: 'Avg Profit %', formatter: (value) => formatPercent(value, 2) },
{ key: 'profit_sum', label: 'Cum Profit %', formatter: (value) => formatPercent(value, 2) },
{
key: 'profit_total_abs',
label: `Tot Profit ${this.backtestResult.stake_currency}`,
formatter: (value) => formatPrice(value, this.backtestResult.stake_currency_decimals),
},
{
key: 'profit_total',
label: 'Tot Profit %',
formatter: (value) => formatPercent(value, 2),
},
{ key: 'wins', label: 'Wins' },
{ key: 'draws', label: 'Draws' },
{ key: 'losses', label: 'Losses' },
];
}
backtestResultFields: Array<Record<string, string>> = [
{ key: 'metric', label: 'Metric' },
{ key: 'value', label: 'Value' },
];
backtestsettingFields: Array<Record<string, string>> = [
{ key: 'setting', label: 'Setting' },
{ key: 'value', label: 'Value' },
];
}
return {
hasBacktestResult,
formatPriceStake,
bestPair,
worstPair,
backtestResultStats,
backtestResultSettings,
perPairFields,
perExitReason,
backtestResultFields,
backtestsettingFields,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -2,7 +2,9 @@
<div>
<div class="mb-2">
<label class="mr-auto h3">Balance</label>
<b-button class="float-right" size="sm" @click="getBalance">&#x21bb;</b-button>
<b-button class="float-right" size="sm" @click="botStore.activeBot.getBalance"
>&#x21bb;</b-button
>
<b-form-checkbox
v-model="hideSmallBalances"
class="float-right"
@ -16,20 +18,20 @@
</div>
<BalanceChart v-if="balanceCurrencies" :currencies="balanceCurrencies" />
<div>
<p v-if="balance.note">
<strong>{{ balance.note }}</strong>
<p v-if="botStore.activeBot.balance.note">
<strong>{{ botStore.activeBot.balance.note }}</strong>
</p>
<b-table class="table-sm" :items="balanceCurrencies" :fields="tableFields">
<template slot="bottom-row">
<td><strong>Total</strong></td>
<td>
<span class="font-italic" title="Increase over initial capital">{{
formatPercent(balance.starting_capital_ratio)
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>{{ formatCurrency(balance.total) }}</strong>
<strong>{{ formatCurrency(botStore.activeBot.balance.total) }}</strong>
</td>
</template>
</b-table>
@ -38,56 +40,59 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BalanceInterface } from '@/types';
import HideIcon from 'vue-material-design-icons/EyeOff.vue';
import ShowIcon from 'vue-material-design-icons/Eye.vue';
import BalanceChart from '@/components/charts/BalanceChart.vue';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { formatPercent } from '@/shared/formatters';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, computed, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
@Component({
export default defineComponent({
name: 'Balance',
components: { HideIcon, ShowIcon, BalanceChart },
})
export default class Balance extends Vue {
@ftbot.Action getBalance;
setup() {
const botStore = useBotStore();
const hideSmallBalances = ref(true);
@ftbot.Getter [BotStoreGetters.balance]!: BalanceInterface;
const smallBalance = computed((): number => {
return Number((0.1 ** botStore.activeBot.stakeCurrencyDecimals).toFixed(8));
});
@ftbot.Getter [BotStoreGetters.stakeCurrencyDecimals]!: number;
const balanceCurrencies = computed(() => {
if (!hideSmallBalances.value) {
return botStore.activeBot.balance.currencies;
}
hideSmallBalances = true;
formatPercent = formatPercent;
return botStore.activeBot.balance.currencies?.filter(
(v) => v.est_stake >= smallBalance.value,
);
});
get smallBalance(): number {
return Number((0.1 ** this.stakeCurrencyDecimals).toFixed(8));
}
const tableFields = computed(() => {
return [
{ key: 'currency', label: 'Currency' },
{ key: 'free', label: 'Available', formatter: 'formatCurrency' },
{
key: 'est_stake',
label: `in ${botStore.activeBot.balance.stake}`,
formatter: 'formatCurrency',
},
];
});
get balanceCurrencies() {
if (!this.hideSmallBalances) {
return this.balance.currencies;
}
const formatCurrency = (value) => {
return value ? value.toFixed(5) : '';
};
return this.balance?.currencies?.filter((v) => v.est_stake >= this.smallBalance);
}
get tableFields() {
return [
{ key: 'currency', label: 'Currency' },
{ key: 'free', label: 'Available', formatter: 'formatCurrency' },
{ key: 'est_stake', label: `in ${this.balance.stake}`, formatter: 'formatCurrency' },
];
}
mounted() {
this.getBalance();
}
formatCurrency(value) {
return value ? value.toFixed(5) : '';
}
}
return {
botStore,
hideSmallBalances,
formatPercent,
smallBalance,
balanceCurrencies,
tableFields,
formatCurrency,
};
},
});
</script>

View File

@ -1,13 +1,13 @@
<template>
<div class="bot-alerts">
<b-alert
v-for="(alert, index) in activeMessages"
v-for="(alert, index) in alertStore.activeMessages"
:key="index"
variant="warning"
dismissible
:show="5"
:value="!!alert.message"
@dismissed="closeAlert"
@dismissed="alertStore.removeAlert"
>
{{ alert.message }}
</b-alert>
@ -15,22 +15,16 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { AlertActions } from '@/store/modules/alerts';
import StoreModules from '@/store/storeSubModules';
import { AlertType } from '@/types/alertTypes';
import { defineComponent } from '@vue/composition-api';
import { useAlertsStore } from '@/stores/alerts';
const alerts = namespace(StoreModules.alerts);
@Component({})
export default class BotAlerts extends Vue {
@alerts.State activeMessages!: AlertType[];
@alerts.Action [AlertActions.removeAlert];
closeAlert() {
this[AlertActions.removeAlert]();
}
}
export default defineComponent({
name: 'BotAlerts',
setup() {
const alertStore = useAlertsStore();
return {
alertStore,
};
},
});
</script>

View File

@ -43,87 +43,82 @@
</template>
<script lang="ts">
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import { BalanceInterface, BotDescriptors, BotState, ProfitInterface, Trade } from '@/types';
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import ProfitPill from '@/components/general/ProfitPill.vue';
import { formatPrice } from '@/shared/formatters';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, computed } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
export default defineComponent({
name: 'BotComparisonList',
components: { ProfitPill },
setup() {
const botStore = useBotStore();
@Component({ components: { ProfitPill } })
export default class BotComparisonList extends Vue {
@ftbot.Getter [MultiBotStoreGetters.allProfit]!: Record<string, ProfitInterface>;
const tableFields: Record<string, string | Function>[] = [
{ key: 'botId', label: 'Bot' },
{ key: 'trades', label: 'Trades' },
{ key: 'profitOpen', label: 'Open Profit' },
{ key: 'profitClosed', label: 'Closed Profit' },
{ key: 'balance', label: 'Balance' },
{ key: 'winVsLoss', label: 'W/L' },
];
@ftbot.Getter [MultiBotStoreGetters.allOpenTradeCount]!: Record<string, number>;
const tableItems = computed(() => {
console.log('tableItems called');
const val: any[] = [];
const summary = {
botId: 'Summary',
profitClosed: 0,
profitClosedRatio: 0,
profitOpen: 0,
profitOpenRatio: 0,
stakeCurrency: 'USDT',
wins: 0,
losses: 0,
};
@ftbot.Getter [MultiBotStoreGetters.allOpenTrades]!: Record<string, Trade[]>;
Object.entries(botStore.allProfit).forEach(([k, v]: [k: string, v: any]) => {
const allStakes = botStore.allOpenTrades[k].reduce((a, b) => a + b.stake_amount, 0);
const profitOpenRatio =
botStore.allOpenTrades[k].reduce((a, b) => a + b.profit_ratio * b.stake_amount, 0) /
allStakes;
const profitOpen = botStore.allOpenTrades[k].reduce((a, b) => a + b.profit_abs, 0);
@ftbot.Getter [MultiBotStoreGetters.allBotState]!: Record<string, BotState>;
@ftbot.Getter [MultiBotStoreGetters.allBalance]!: Record<string, BalanceInterface>;
@ftbot.Getter [MultiBotStoreGetters.allAvailableBots]!: BotDescriptors;
formatPrice = formatPrice;
get tableItems() {
console.log('tableItems called');
const val: any[] = [];
const summary = {
botId: 'Summary',
profitClosed: 0,
profitClosedRatio: 0,
profitOpen: 0,
profitOpenRatio: 0,
stakeCurrency: 'USDT',
wins: 0,
losses: 0,
};
Object.entries(this.allProfit).forEach(([k, v]) => {
const allStakes = this.allOpenTrades[k].reduce((a, b) => a + b.stake_amount, 0);
const profitOpenRatio =
this.allOpenTrades[k].reduce((a, b) => a + b.profit_ratio * b.stake_amount, 0) / allStakes;
const profitOpen = this.allOpenTrades[k].reduce((a, b) => a + b.profit_abs, 0);
// TODO: handle one inactive bot ...
val.push({
botId: this.allAvailableBots[k].botName,
trades: `${this.allOpenTradeCount[k]} / ${this.allBotState[k]?.max_open_trades || 'N/A'}`,
profitClosed: v.profit_closed_coin,
profitClosedRatio: v.profit_closed_ratio || 0,
stakeCurrency: this.allBotState[k]?.stake_currency || '',
profitOpenRatio,
profitOpen,
wins: v.winning_trades,
losses: v.losing_trades,
balance: this.allBalance[k]?.total,
stakeCurrencyDecimals: this.allBotState[k]?.stake_currency_decimals || 3,
// TODO: handle one inactive bot ...
val.push({
botId: botStore.availableBots[k].botName,
trades: `${botStore.allOpenTradeCount[k]} / ${
botStore.allBotState[k]?.max_open_trades || 'N/A'
}`,
profitClosed: v.profit_closed_coin,
profitClosedRatio: v.profit_closed_ratio || 0,
stakeCurrency: botStore.allBotState[k]?.stake_currency || '',
profitOpenRatio,
profitOpen,
wins: v.winning_trades,
losses: v.losing_trades,
balance: botStore.allBalance[k]?.total,
stakeCurrencyDecimals: botStore.allBotState[k]?.stake_currency_decimals || 3,
});
if (v.profit_closed_coin !== undefined) {
summary.profitClosed += v.profit_closed_coin;
summary.profitOpen += v.profit_all_coin;
summary.wins += v.winning_trades;
summary.losses += v.losing_trades;
// summary.decimals = this.allBotState[k]?.stake_currency_decimals || summary.decimals;
}
});
if (v.profit_closed_coin !== undefined) {
summary.profitClosed += v.profit_closed_coin;
summary.profitOpen += v.profit_all_coin;
summary.wins += v.winning_trades;
summary.losses += v.losing_trades;
// summary.decimals = this.allBotState[k]?.stake_currency_decimals || summary.decimals;
}
val.push(summary);
return val;
});
val.push(summary);
return val;
}
tableFields: Record<string, string | Function>[] = [
{ key: 'botId', label: 'Bot' },
{ key: 'trades', label: 'Trades' },
{ key: 'profitOpen', label: 'Open Profit' },
{ key: 'profitClosed', label: 'Closed Profit' },
{ key: 'balance', label: 'Balance' },
{ key: 'winVsLoss', label: 'W/L' },
];
}
return {
formatPrice,
tableFields,
tableItems,
};
},
});
</script>
<style scoped></style>

View File

@ -1,8 +1,9 @@
forceexit
<template>
<div>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || isRunning"
:disabled="!botStore.activeBot.isTrading || isRunning"
title="Start Trading"
@click="startBot()"
>
@ -10,7 +11,7 @@
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
:disabled="!botStore.activeBot.isTrading || !isRunning"
title="Stop Trading - Also stops handling open trades."
@click="handleStopBot()"
>
@ -18,7 +19,7 @@
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
:disabled="!botStore.activeBot.isTrading || !isRunning"
title="StopBuy - Stops buying, but still handles open trades"
@click="handleStopBuy()"
>
@ -26,7 +27,7 @@
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading"
:disabled="!botStore.activeBot.isTrading"
title="Reload Config - reloads configuration including strategy, resetting all settings changed on the fly."
@click="handleReloadConfig()"
>
@ -34,23 +35,27 @@
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading"
title="Forcesell all"
@click="handleForceSell()"
:disabled="!botStore.activeBot.isTrading"
title="Force exit all"
@click="handleForceExit()"
>
<ForceSellIcon />
<ForceExitIcon />
</button>
<button
v-if="botState && (botState.force_entry_enable || botState.forcebuy_enabled)"
v-if="
botStore.activeBot.botState &&
(botStore.activeBot.botState.force_entry_enable ||
botStore.activeBot.botState.forcebuy_enabled)
"
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="Force enter - Immediately buy an asset at an optional price. Sells are then handled according to strategy rules."
:disabled="!botStore.activeBot.isTrading || !isRunning"
title="Force enter - Immediately enter a trade at an optional price. Exits are then handled according to strategy rules."
@click="initiateForceenter"
>
<ForceBuyIcon />
<ForceEntryIcon />
</button>
<button
v-if="isWebserverMode && false"
v-if="botStore.activeBot.isWebserverMode && false"
:disabled="isTrading"
class="btn btn-secondary btn-sm ml-1"
title="Start Trading mode"
@ -63,98 +68,87 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BotState, ForceSellPayload } from '@/types';
import { BotStoreActions, BotStoreGetters } from '@/store/modules/ftbot';
import { ForceSellPayload } from '@/types';
import PlayIcon from 'vue-material-design-icons/Play.vue';
import StopIcon from 'vue-material-design-icons/Stop.vue';
import PauseIcon from 'vue-material-design-icons/Pause.vue';
import ReloadIcon from 'vue-material-design-icons/Reload.vue';
import ForceSellIcon from 'vue-material-design-icons/CloseBoxMultiple.vue';
import ForceBuyIcon from 'vue-material-design-icons/PlusBoxMultipleOutline.vue';
import StoreModules from '@/store/storeSubModules';
import ForceExitIcon from 'vue-material-design-icons/CloseBoxMultiple.vue';
import ForceEntryIcon from 'vue-material-design-icons/PlusBoxMultipleOutline.vue';
import ForceBuyForm from './ForceBuyForm.vue';
import { defineComponent, computed, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'BotControls',
components: {
ForceBuyForm,
PlayIcon,
StopIcon,
PauseIcon,
ReloadIcon,
ForceSellIcon,
ForceBuyIcon,
ForceExitIcon,
ForceEntryIcon,
},
})
export default class BotControls extends Vue {
forcebuyShow = false;
setup(_, { root }) {
const botStore = useBotStore();
const forcebuyShow = ref(false);
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
@ftbot.Action startBot;
@ftbot.Action stopBot;
@ftbot.Action stopBuy;
@ftbot.Action reloadConfig;
@ftbot.Action startTrade;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action [BotStoreActions.forcesell]!: (payload: ForceSellPayload) => void;
@ftbot.Getter [BotStoreGetters.isTrading]!: boolean;
@ftbot.Getter [BotStoreGetters.isWebserverMode]!: boolean;
get isRunning(): boolean {
return this.botState?.state === 'running';
}
initiateForceenter() {
this.$bvModal.show('forcebuy-modal');
}
handleStopBot() {
this.$bvModal.msgBoxConfirm('Stop Bot?').then((value: boolean) => {
if (value) {
this.stopBot();
}
const isRunning = computed((): boolean => {
return botStore.activeBot.botState?.state === 'running';
});
}
handleStopBuy() {
this.$bvModal
.msgBoxConfirm('Stop buying? Freqtrade will continue to handle open trades.')
.then((value: boolean) => {
const initiateForceenter = () => {
root.$bvModal.show('forcebuy-modal');
};
const handleStopBot = () => {
root.$bvModal.msgBoxConfirm('Stop Bot?').then((value: boolean) => {
if (value) {
this.stopBuy();
botStore.activeBot.stopBot();
}
});
}
};
handleReloadConfig() {
this.$bvModal.msgBoxConfirm('Reload configuration?').then((value: boolean) => {
if (value) {
this.reloadConfig();
}
});
}
const handleStopBuy = () => {
root.$bvModal
.msgBoxConfirm('Stop buying? Freqtrade will continue to handle open trades.')
.then((value: boolean) => {
if (value) {
botStore.activeBot.stopBuy();
}
});
};
handleForceSell() {
this.$bvModal.msgBoxConfirm(`Really forcesell ALL trades?`).then((value: boolean) => {
if (value) {
const payload: ForceSellPayload = {
tradeid: 'all',
// TODO: support ordertype (?)
};
this.forcesell(payload);
}
});
}
}
const handleReloadConfig = () => {
root.$bvModal.msgBoxConfirm('Reload configuration?').then((value: boolean) => {
if (value) {
botStore.activeBot.reloadConfig();
}
});
};
const handleForceExit = () => {
root.$bvModal.msgBoxConfirm(`Really forcesell ALL trades?`).then((value: boolean) => {
if (value) {
const payload: ForceSellPayload = {
tradeid: 'all',
// TODO: support ordertype (?)
};
botStore.activeBot.forceexit(payload);
}
});
};
return {
initiateForceenter,
handleStopBot,
handleStopBuy,
handleReloadConfig,
handleForceExit,
forcebuyShow,
botStore,
isRunning,
};
},
});
</script>

View File

@ -1,63 +1,72 @@
<template>
<div v-if="botState">
<div v-if="botStore.activeBot.botState">
<p>
Running Freqtrade <strong>{{ version }}</strong>
Running Freqtrade <strong>{{ botStore.activeBot.version }}</strong>
</p>
<p>
Running with
<strong>
{{ botState.max_open_trades }}x{{ botState.stake_amount }} {{ botState.stake_currency }}
{{ botStore.activeBot.botState.max_open_trades }}x{{
botStore.activeBot.botState.stake_amount
}}
{{ botStore.activeBot.botState.stake_currency }}
</strong>
on
<strong>{{ botState.exchange }}</strong> in
<strong>{{ botState.trading_mode || 'spot' }}</strong> markets, with Strategy
<strong>{{ botState.strategy }}</strong>
<strong>{{ botStore.activeBot.botState.exchange }}</strong> in
<strong>{{ botStore.activeBot.botState.trading_mode || 'spot' }}</strong> markets, with
Strategy
<strong>{{ botStore.activeBot.botState.strategy }}</strong>
</p>
<p>
Currently <strong>{{ botState.state }}</strong
Currently <strong>{{ botStore.activeBot.botState.state }}</strong
>,
<strong>force entry: {{ botState.force_entry_enable || botState.forcebuy_enabled }}</strong>
<strong
>force entry:
{{ botStore.activeBot.botState.force_entry_enable || botState.forcebuy_enabled }}</strong
>
</p>
<p>
<strong>{{ botState.dry_run ? 'Dry-Run' : 'Live' }}</strong>
<strong>{{ botStore.activeBot.botState.dry_run ? 'Dry-Run' : 'Live' }}</strong>
</p>
<hr />
<p>
Avg Profit {{ formatPercent(profit.profit_all_ratio_mean) }} (&sum;
{{ formatPercent(profit.profit_all_ratio_sum) }}) in {{ profit.trade_count }} Trades, with an
average duration of {{ profit.avg_duration }}. Best pair: {{ profit.best_pair }}.
Avg Profit {{ formatPercent(botStore.activeBot.profit.profit_all_ratio_mean) }} (&sum;
{{ formatPercent(botStore.activeBot.profit.profit_all_ratio_sum) }}) in
{{ botStore.activeBot.profit.trade_count }} Trades, with an average duration of
{{ botStore.activeBot.profit.avg_duration }}. Best pair:
{{ botStore.activeBot.profit.best_pair }}.
</p>
<p v-if="profit.first_trade_timestamp">
<p v-if="botStore.activeBot.profit.first_trade_timestamp">
First trade opened:
<strong><DateTimeTZ :date="profit.first_trade_timestamp" show-timezone /></strong>
<strong
><DateTimeTZ :date="botStore.activeBot.profit.first_trade_timestamp" show-timezone
/></strong>
<br />
Last trade opened:
<strong><DateTimeTZ :date="profit.latest_trade_timestamp" show-timezone /></strong>
<strong
><DateTimeTZ :date="botStore.activeBot.profit.latest_trade_timestamp" show-timezone
/></strong>
</p>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BotState, ProfitInterface } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { formatPercent } from '@/shared/formatters';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import StoreModules from '@/store/storeSubModules';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
@Component({ components: { DateTimeTZ } })
export default class BotStatus extends Vue {
@ftbot.Getter [BotStoreGetters.version]: string;
@ftbot.Getter [BotStoreGetters.profit]: ProfitInterface | {};
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
formatPercent = formatPercent;
}
export default defineComponent({
name: 'BotStatus',
components: { DateTimeTZ },
setup() {
const botStore = useBotStore();
return {
formatPercent,
botStore,
};
},
});
</script>

View File

@ -34,136 +34,104 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { formatPercent, formatPrice } from '@/shared/formatters';
import { formatPrice } from '@/shared/formatters';
import { MultiDeletePayload, MultiForcesellPayload, Trade } from '@/types';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import ForceSellIcon from 'vue-material-design-icons/CloseBoxMultiple.vue';
import ActionIcon from 'vue-material-design-icons/GestureTap.vue';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import StoreModules from '@/store/storeSubModules';
import CustomTradeListEntry from '@/components/ftbot/CustomTradeListEntry.vue';
import TradeProfit from './TradeProfit.vue';
import { defineComponent, computed, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'CustomTradeList',
components: {
DeleteIcon,
ForceSellIcon,
ActionIcon,
DateTimeTZ,
TradeProfit,
CustomTradeListEntry,
},
})
export default class CustomTradeList extends Vue {
$refs!: {
tradesTable: HTMLFormElement;
};
props: {
trades: { required: true, type: Array as () => Trade[] },
title: { default: 'Trades', type: String },
stakeCurrency: { required: false, default: '', type: String },
activeTrades: { default: false, type: Boolean },
showFilter: { default: false, type: Boolean },
multiBotView: { default: false, type: Boolean },
emptyText: { default: 'No Trades to show.', type: String },
stakeCurrencyDecimals: { default: 3, type: Number },
},
setup(props, { root }) {
const botStore = useBotStore();
const currentPage = ref(1);
const filterText = ref('');
const perPage = props.activeTrades ? 200 : 25;
formatPercent = formatPercent;
const rows = computed(() => props.trades.length);
formatPrice = formatPrice;
@Prop({ required: true }) trades!: Array<Trade>;
@Prop({ default: 'Trades' }) title!: string;
@Prop({ required: false, default: '' }) stakeCurrency!: string;
@Prop({ default: false }) activeTrades!: boolean;
@Prop({ default: false }) showFilter!: boolean;
@Prop({ default: false, type: Boolean }) multiBotView!: boolean;
@Prop({ default: 'No Trades to show.' }) emptyText!: string;
@Prop({ default: 3, type: Number }) stakeCurrencyDecimals!: number;
@ftbot.Action setDetailTrade;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action forceSellMulti!: (payload: MultiForcesellPayload) => Promise<string>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action deleteTradeMulti!: (payload: MultiDeletePayload) => Promise<string>;
currentPage = 1;
selectedItemIndex? = undefined;
filterText = '';
get rows(): number {
return this.trades.length;
}
perPage = this.activeTrades ? 200 : 25;
get filteredTrades() {
return this.trades.slice(
(this.currentPage - 1) * this.perPage,
this.currentPage * this.perPage,
);
}
formatPriceWithDecimals(price) {
return formatPrice(price, this.stakeCurrencyDecimals);
}
forcesellHandler(item: Trade, ordertype: string | undefined = undefined) {
this.$bvModal
.msgBoxConfirm(`Really forcesell trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiForcesellPayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
if (ordertype) {
payload.ordertype = ordertype;
const filteredTrades = computed(() => {
return props.trades.slice((currentPage.value - 1) * perPage, currentPage.value * perPage);
});
const formatPriceWithDecimals = (price) => {
return formatPrice(price, props.stakeCurrencyDecimals);
};
const forcesellHandler = (item: Trade, ordertype: string | undefined = undefined) => {
root.$bvModal
.msgBoxConfirm(`Really forcesell trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiForcesellPayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
if (ordertype) {
payload.ordertype = ordertype;
}
botStore
.forceSellMulti(payload)
.then((xxx) => console.log(xxx))
.catch((error) => console.log(error.response));
}
this.forceSellMulti(payload)
.then((xxx) => console.log(xxx))
.catch((error) => console.log(error.response));
}
});
}
});
};
handleContextMenuEvent(item, index, event) {
// stop browser context menu from appearing
if (!this.activeTrades) {
return;
}
event.preventDefault();
// log the selected item to the console
console.log(item);
}
const handleContextMenuEvent = (item, index, event) => {
// stop browser context menu from appearing
if (!props.activeTrades) {
return;
}
event.preventDefault();
// log the selected item to the console
console.log(item);
};
removeTradeHandler(item) {
console.log(item);
this.$bvModal
.msgBoxConfirm(`Really delete trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiDeletePayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
this.deleteTradeMulti(payload).catch((error) => console.log(error.response));
}
});
}
const removeTradeHandler = (item) => {
console.log(item);
root.$bvModal
.msgBoxConfirm(`Really delete trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiDeletePayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
botStore.deleteTradeMulti(payload).catch((error) => console.log(error.response));
}
});
};
const tradeClick = (trade) => {
botStore.activeBot.setDetailTrade(trade);
};
tradeClick(trade) {
this.setDetailTrade(trade);
}
}
return {
currentPage,
filterText,
perPage,
filteredTrades,
formatPriceWithDecimals,
forcesellHandler,
handleContextMenuEvent,
removeTradeHandler,
tradeClick,
botStore,
rows,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -2,50 +2,56 @@
<div>
<div class="mb-2">
<label class="mr-auto h3">Daily Stats</label>
<b-button class="float-right" size="sm" @click="getDaily">&#x21bb;</b-button>
<b-button class="float-right" size="sm" @click="botStore.activeBot.getDaily"
>&#x21bb;</b-button
>
</div>
<div>
<DailyChart v-if="dailyStats.data" :daily-stats="dailyStats" />
<DailyChart
v-if="botStore.activeBot.dailyStats.data"
:daily-stats="botStore.activeBot.dailyStats"
/>
</div>
<div>
<b-table class="table-sm" :items="dailyStats.data" :fields="dailyFields"> </b-table>
<b-table class="table-sm" :items="botStore.activeBot.dailyStats.data" :fields="dailyFields">
</b-table>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapActions, mapGetters } from 'vuex';
import { defineComponent, computed, onMounted } from '@vue/composition-api';
import DailyChart from '@/components/charts/DailyChart.vue';
import { formatPrice } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { useBotStore } from '@/stores/ftbotwrapper';
export default Vue.extend({
export default defineComponent({
name: 'DailyStats',
components: {
DailyChart,
},
computed: {
...mapGetters(StoreModules.ftbot, [BotStoreGetters.dailyStats]),
dailyFields() {
setup() {
const botStore = useBotStore();
const dailyFields = computed(() => {
return [
{ key: 'date', label: 'Day' },
{ key: 'abs_profit', label: 'Profit', formatter: (value) => formatPrice(value) },
{
key: 'fiat_value',
label: `In ${this.dailyStats.fiat_display_currency}`,
label: `In ${botStore.activeBot.dailyStats.fiat_display_currency}`,
formatter: (value) => formatPrice(value, 2),
},
{ key: 'trade_count', label: 'Trades' },
];
},
},
mounted() {
this.getDaily();
},
methods: {
...mapActions(StoreModules.ftbot, ['getDaily']),
});
onMounted(() => {
botStore.activeBot.getDaily();
});
return {
botStore,
dailyFields,
};
},
});
</script>

View File

@ -4,16 +4,16 @@
<div>
<h3>Whitelist Methods</h3>
<div v-if="pairlistMethods.length" class="list">
<b-list-group v-for="(method, key) in pairlistMethods" :key="key">
<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>
</div>
<!-- Show Whitelist -->
<h3>Whitelist</h3>
<div v-if="whitelist.length" class="list">
<b-list-group v-for="(pair, key) in whitelist" :key="key">
<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>
@ -31,12 +31,12 @@
<b-button
id="blacklist-add-btn"
class="mr-1"
:class="botApiVersion >= 1.12 ? 'col-6' : ''"
:class="botStore.activeBot.botApiVersion >= 1.12 ? 'col-6' : ''"
size="sm"
>+</b-button
>
>+
</b-button>
<b-button
v-if="botApiVersion >= 1.12"
v-if="botStore.activeBot.botApiVersion >= 1.12"
size="sm"
class="col-6"
title="Select pairs to delete pairs from your blacklist."
@ -68,14 +68,15 @@
size="sm"
type="submit"
@click="addBlacklistPair"
>Add</b-button
>
Add</b-button
>
</div>
</form>
</b-popover>
</div>
<div v-if="blacklist.length" class="list">
<b-list-group v-for="(pair, key) in blacklist" :key="key">
<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"
@ -91,87 +92,75 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BlacklistPayload, BlacklistResponse } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, ref, onMounted } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
export default defineComponent({
name: 'FTBotAPIPairList',
components: { DeleteIcon },
setup() {
const newblacklistpair = ref('');
const blackListShow = ref(false);
const blacklistSelect = ref<number[]>([]);
const botStore = useBotStore();
@Component({ components: { DeleteIcon } })
export default class FTBotAPIPairList extends Vue {
newblacklistpair = '';
const initBlacklist = () => {
if (botStore.activeBot.whitelist.length === 0) {
botStore.activeBot.getWhitelist();
}
if (botStore.activeBot.blacklist.length === 0) {
botStore.activeBot.getBlacklist();
}
};
blackListShow = false;
const addBlacklistPair = () => {
if (newblacklistpair.value) {
blackListShow.value = false;
blacklistSelect: number[] = [];
botStore.activeBot.addBlacklist({ blacklist: [newblacklistpair.value] });
newblacklistpair.value = '';
}
};
@ftbot.Action getWhitelist;
const blacklistSelectClick = (key) => {
console.log(key);
const index = blacklistSelect.value.indexOf(key);
if (index > -1) {
blacklistSelect.value.splice(index, 1);
} else {
blacklistSelect.value.push(key);
}
};
@ftbot.Action getBlacklist;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action addBlacklist!: (payload: BlacklistPayload) => Promise<BlacklistResponse>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action deleteBlacklist!: (payload: string[]) => Promise<BlacklistResponse>;
@ftbot.Getter [BotStoreGetters.whitelist]!: string[];
@ftbot.Getter [BotStoreGetters.blacklist]!: string[];
@ftbot.Getter [BotStoreGetters.pairlistMethods]!: string[];
@ftbot.Getter [BotStoreGetters.botApiVersion]: number;
created() {
this.initBlacklist();
}
initBlacklist() {
if (this.whitelist.length === 0) {
this.getWhitelist();
}
if (this.blacklist.length === 0) {
this.getBlacklist();
}
}
addBlacklistPair() {
if (this.newblacklistpair) {
this.blackListShow = false;
this.addBlacklist({ blacklist: [this.newblacklistpair] });
this.newblacklistpair = '';
}
}
blacklistSelectClick(key) {
console.log(key);
const index = this.blacklistSelect.indexOf(key);
if (index > -1) {
this.blacklistSelect.splice(index, 1);
} else {
this.blacklistSelect.push(key);
}
}
deletePairs() {
if (this.blacklistSelect.length === 0) {
console.log('nothing to delete');
return;
}
// const pairlist = this.blacklistSelect;
const pairlist = this.blacklist.filter(
(value, index) => this.blacklistSelect.indexOf(index) > -1,
);
console.log('Deleting pairs: ', pairlist);
this.deleteBlacklist(pairlist);
this.blacklistSelect = [];
}
}
const deletePairs = () => {
if (blacklistSelect.value.length === 0) {
console.log('nothing to delete');
return;
}
// const pairlist = blacklistSelect.value;
const pairlist = botStore.activeBot.blacklist.filter(
(value, index) => blacklistSelect.value.indexOf(index) > -1,
);
console.log('Deleting pairs: ', pairlist);
botStore.activeBot.deleteBlacklist(pairlist);
blacklistSelect.value = [];
};
onMounted(() => {
initBlacklist();
});
return {
addBlacklistPair,
deletePairs,
initBlacklist,
blacklistSelectClick,
botStore,
newblacklistpair,
blackListShow,
blacklistSelect,
};
},
});
</script>
<style scoped lang="scss">
@ -188,15 +177,18 @@ export default class FTBotAPIPairList extends Vue {
position: absolute;
transition: opacity 0.2s;
}
.list-group-item.active .check {
opacity: 1;
}
.list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
grid-gap: 0.5rem;
padding-bottom: 1rem;
}
.pair {
border: 1px solid #ccc;
background: #41b883;
@ -206,6 +198,7 @@ export default class FTBotAPIPairList extends Vue {
position: relative;
cursor: pointer;
}
.white {
background: white;
color: black;

View File

@ -10,7 +10,7 @@
>
<form ref="form" @submit.stop.prevent="handleSubmit">
<b-form-group
v-if="botApiVersion >= 2.13 && shortAllowed"
v-if="botStore.activeBot.botApiVersion >= 2.13 && botStore.activeBot.shortAllowed"
label="Order direction (Long or Short)"
label-for="order-direction"
invalid-feedback="Stake-amount must be empty or a positive number"
@ -45,8 +45,8 @@
></b-form-input>
</b-form-group>
<b-form-group
v-if="botApiVersion > 1.12"
:label="`*Stake-amount in ${stakeCurrency} [optional]`"
v-if="botStore.activeBot.botApiVersion > 1.12"
:label="`*Stake-amount in ${botStore.activeBot.stakeCurrency} [optional]`"
label-for="stake-input"
invalid-feedback="Stake-amount must be empty or a positive number"
>
@ -59,7 +59,7 @@
></b-form-input>
</b-form-group>
<b-form-group
v-if="botApiVersion > 1.1"
v-if="botStore.activeBot.botApiVersion > 1.1"
label="*OrderType"
label-for="ordertype-input"
invalid-feedback="OrderType"
@ -79,99 +79,86 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BotState, ForceEnterPayload, OrderSides } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { useBotStore } from '@/stores/ftbotwrapper';
import { ForceEnterPayload, OrderSides } from '@/types';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, ref, nextTick } from '@vue/composition-api';
@Component({})
export default class ForceBuyForm extends Vue {
pair = '';
export default defineComponent({
name: 'ForceBuyForm',
setup(_, { root }) {
const botStore = useBotStore();
price = null;
const form = ref<HTMLFormElement>();
const pair = ref('');
const price = ref<number | null>(null);
const stakeAmount = ref<number | null>(null);
const ordertype = ref('');
const orderSide = ref<OrderSides>(OrderSides.long);
stakeAmount = null;
const checkFormValidity = () => {
const valid = form.value?.checkValidity();
ordertype?: string = '';
return valid;
};
orderSide: OrderSides = OrderSides.long;
const handleSubmit = () => {
// Exit when the form isn't valid
if (!checkFormValidity()) {
return;
}
// call forcebuy
const payload: ForceEnterPayload = { pair: pair.value };
if (price.value) {
payload.price = Number(price.value);
}
if (ordertype.value) {
payload.ordertype = ordertype.value;
}
if (stakeAmount.value) {
payload.stakeamount = stakeAmount.value;
}
if (botStore.activeBot.botApiVersion >= 2.13) {
payload.side = orderSide.value;
}
botStore.activeBot.forcebuy(payload);
nextTick(() => {
root.$bvModal.hide('forcebuy-modal');
});
};
const resetForm = () => {
console.log('resetForm');
pair.value = '';
price.value = null;
stakeAmount.value = null;
if (botStore.activeBot.botApiVersion > 1.1) {
ordertype.value =
botStore.activeBot.botState?.order_types?.forcebuy ||
botStore.activeBot.botState?.order_types?.force_entry ||
botStore.activeBot.botState?.order_types?.buy ||
botStore.activeBot.botState?.order_types?.entry ||
'limit';
}
};
$refs!: {
form: HTMLFormElement;
};
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
@ftbot.Getter [BotStoreGetters.shortAllowed]?: boolean;
@ftbot.Getter [BotStoreGetters.botApiVersion]: number;
@ftbot.Getter [BotStoreGetters.stakeCurrency]!: string;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action forcebuy!: (payload: ForceEnterPayload) => Promise<string>;
created() {
this.$bvModal.show('forcebuy-modal');
}
close() {
this.$emit('close');
}
checkFormValidity() {
const valid = this.$refs.form.checkValidity();
return valid;
}
handleBuy(bvModalEvt) {
// Prevent modal from closing
bvModalEvt.preventDefault();
// Trigger submit handler
this.handleSubmit();
}
resetForm() {
console.log('resetForm');
this.pair = '';
this.price = null;
this.stakeAmount = null;
if (this.botApiVersion > 1.1) {
this.ordertype =
this.botState?.order_types?.forcebuy ||
this.botState?.order_types?.forceentry ||
this.botState?.order_types?.buy ||
this.botState?.order_types?.entry;
}
}
handleSubmit() {
// Exit when the form isn't valid
if (!this.checkFormValidity()) {
return;
}
// call forcebuy
const payload: ForceEnterPayload = { pair: this.pair };
if (this.price) {
payload.price = Number(this.price);
}
if (this.ordertype) {
payload.ordertype = this.ordertype;
}
if (this.stakeAmount) {
payload.stakeamount = this.stakeAmount;
}
if (this.botApiVersion >= 2.13) {
payload.side = this.orderSide;
}
this.forcebuy(payload);
this.$nextTick(() => {
this.$bvModal.hide('forcebuy-modal');
});
}
}
const handleBuy = (bvModalEvt) => {
// Prevent modal from closing
bvModalEvt.preventDefault();
// Trigger submit handler
handleSubmit();
};
return {
handleSubmit,
botStore,
form,
handleBuy,
resetForm,
pair,
price,
stakeAmount,
ordertype,
orderSide,
};
},
});
</script>

View File

@ -1,38 +1,36 @@
<template>
<div class="d-flex h-100 p-0 align-items-start">
<textarea v-model="formattedLogs" class="h-100" readonly></textarea>
<b-button size="sm" @click="getLogs">&#x21bb;</b-button>
<b-button size="sm" @click="botStore.activeBot.getLogs">&#x21bb;</b-button>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { LogLine } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { useBotStore } from '@/stores/ftbotwrapper';
import { defineComponent, onMounted, computed } from '@vue/composition-api';
const ftbot = namespace(StoreModules.ftbot);
export default defineComponent({
name: 'LogViewer',
setup() {
const botStore = useBotStore();
@Component({})
export default class LogViewer extends Vue {
@ftbot.Getter [BotStoreGetters.lastLogs]!: LogLine[];
onMounted(() => botStore.activeBot.getLogs());
@ftbot.Action getLogs;
const formattedLogs = computed(() => {
let result = '';
for (let i = 0, len = botStore.activeBot.lastLogs.length; i < len; i += 1) {
const log = botStore.activeBot.lastLogs[i];
result += `${log[0]} - ${log[2]} - ${log[3]} - ${log[4]}\n`;
}
return result;
});
mounted() {
this.getLogs();
}
get formattedLogs() {
let result = '';
for (let i = 0, len = this.lastLogs.length; i < len; i += 1) {
const log = this.lastLogs[i];
result += `${log[0]} - ${log[2]} - ${log[3]} - ${log[4]}\n`;
}
return result;
}
}
return {
botStore,
formattedLogs,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -2,10 +2,12 @@
<div>
<div class="mb-2">
<label class="mr-auto h3">Pair Locks</label>
<b-button class="float-right" size="sm" @click="getLocks">&#x21bb;</b-button>
<b-button class="float-right" size="sm" @click="botStore.activeBot.getLocks"
>&#x21bb;</b-button
>
</div>
<div>
<b-table class="table-sm" :items="currentLocks" :fields="tableFields">
<b-table class="table-sm" :items="botStore.activeBot.activeLocks" :fields="tableFields">
<template #cell(actions)="row">
<b-button
class="btn-xs ml-1"
@ -23,51 +25,43 @@
<script lang="ts">
import { timestampms } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { Lock } from '@/types';
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import { AlertActions } from '@/store/modules/alerts';
import StoreModules from '@/store/storeSubModules';
import { showAlert } from '@/stores/alerts';
import { defineComponent } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
const alerts = namespace(StoreModules.alerts);
@Component({
export default defineComponent({
name: 'PairLockList',
components: { DeleteIcon },
})
export default class PairLockList extends Vue {
@ftbot.Action getLocks;
setup() {
const botStore = useBotStore();
@ftbot.Getter [BotStoreGetters.currentLocks]!: Lock[];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action deleteLock!: (lockid: string) => Promise<string>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@alerts.Action [AlertActions.addAlert];
timestampms = timestampms;
get tableFields() {
return [
const tableFields = [
{ key: 'pair', label: 'Pair' },
{ key: 'lock_end_timestamp', label: 'Until', formatter: 'timestampms' },
{ key: 'reason', label: 'Reason' },
{ key: 'actions' },
];
}
removePairLock(item: Lock) {
console.log(item);
if (item.id !== undefined) {
this.deleteLock(item.id);
} else {
this.addAlert({ message: 'This Freqtrade version does not support deleting locks.' });
}
}
}
const removePairLock = (item: Lock) => {
console.log(item);
if (item.id !== undefined) {
botStore.activeBot.deleteLock(item.id);
} else {
showAlert('This Freqtrade version does not support deleting locks.');
}
};
return {
timestampms,
botStore,
tableFields,
removePairLock,
};
},
});
</script>
<style scoped></style>

View File

@ -7,9 +7,9 @@
:key="comb.pair"
button
class="d-flex justify-content-between align-items-center py-1"
:active="comb.pair === selectedPair"
:active="comb.pair === botStore.activeBot.selectedPair"
:title="`${comb.pair} - ${comb.tradeCount} trades`"
@click="setSelectedPair(comb.pair)"
@click="botStore.activeBot.setSelectedPair(comb.pair)"
>
<div>
{{ comb.pair }}
@ -20,7 +20,7 @@
<ProfitPill
v-if="backtestMode && comb.tradeCount > 0"
:profit-ratio="comb.profit"
:stake-currency="stakeCurrency"
:stake-currency="botStore.activeBot.stakeCurrency"
/>
</b-list-group-item>
</b-list-group>
@ -28,15 +28,11 @@
<script lang="ts">
import { formatPercent, timestampms } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { Lock, Trade } from '@/types';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import TradeProfit from '@/components/ftbot/TradeProfit.vue';
import ProfitPill from '@/components/general/ProfitPill.vue';
import StoreModules from '@/store/storeSubModules';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, computed } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
interface CombinedPairList {
pair: string;
@ -49,108 +45,102 @@ interface CombinedPairList {
tradeCount: number;
}
@Component({ components: { TradeProfit, ProfitPill } })
export default class PairSummary extends Vue {
@Prop({ required: true }) pairlist!: string[];
export default defineComponent({
name: 'PairSummary',
components: { TradeProfit, ProfitPill },
props: {
// TOOD: Should be string list
pairlist: { required: true, type: Array as () => string[] },
currentLocks: { required: false, type: Array as () => Lock[], default: () => [] },
trades: { required: true, type: Array as () => Trade[] },
sortMethod: { default: 'normal', type: String },
backtestMode: { required: false, default: false, type: Boolean },
},
setup(props) {
const botStore = useBotStore();
const combinedPairList = computed(() => {
const comb: CombinedPairList[] = [];
@Prop({ required: false, default: () => [] }) currentLocks!: Lock[];
props.pairlist.forEach((pair) => {
const trades: Trade[] = props.trades.filter((el) => el.pair === pair);
const allLocks = props.currentLocks.filter((el) => el.pair === pair);
let lockReason = '';
let locks;
@Prop({ required: true }) trades!: Trade[];
/** Sort method, "normal" (sorts by open trades > pairlist -> locks) or "profit" */
@Prop({ required: false, default: 'normal' }) sortMethod!: string;
@Prop({ required: false, default: false }) backtestMode!: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action setSelectedPair!: (pair: string) => void;
@ftbot.Getter [BotStoreGetters.selectedPair]!: string;
@ftbot.Getter [BotStoreGetters.stakeCurrency]!: string;
timestampms = timestampms;
formatPercent = formatPercent;
get combinedPairList() {
const comb: CombinedPairList[] = [];
this.pairlist.forEach((pair) => {
const trades: Trade[] = this.trades.filter((el) => el.pair === pair);
const allLocks = this.currentLocks.filter((el) => el.pair === pair);
let lockReason = '';
let locks;
// Sort to have longer timeframe in front
allLocks.sort((a, b) => (a.lock_end_timestamp > b.lock_end_timestamp ? -1 : 1));
if (allLocks.length > 0) {
[locks] = allLocks;
lockReason = `${timestampms(locks.lock_end_timestamp)} - ${locks.reason}`;
}
let profitString = '';
let profit = 0;
let profitAbs = 0;
trades.forEach((trade) => {
profit += trade.profit_ratio;
profitAbs += trade.profit_abs;
// Sort to have longer timeframe in front
allLocks.sort((a, b) => (a.lock_end_timestamp > b.lock_end_timestamp ? -1 : 1));
if (allLocks.length > 0) {
[locks] = allLocks;
lockReason = `${timestampms(locks.lock_end_timestamp)} - ${locks.reason}`;
}
let profitString = '';
let profit = 0;
let profitAbs = 0;
trades.forEach((trade) => {
profit += trade.profit_ratio;
profitAbs += trade.profit_abs;
});
const tradeCount = trades.length;
const trade = tradeCount ? trades[0] : undefined;
if (trades.length > 0) {
profitString = `Current profit: ${formatPercent(profit)}`;
}
if (trade) {
profitString += `\nOpen since: ${timestampms(trade.open_timestamp)}`;
}
comb.push({ pair, trade, locks, lockReason, profitString, profit, profitAbs, tradeCount });
});
const tradeCount = trades.length;
const trade = tradeCount ? trades[0] : undefined;
if (trades.length > 0) {
profitString = `Current profit: ${formatPercent(profit)}`;
if (props.sortMethod === 'profit') {
comb.sort((a, b) => {
if (a.profit > b.profit) {
return -1;
}
return 1;
});
} else {
// sort Pairs: "with open trade" -> available -> locked
comb.sort((a, b) => {
if (a.trade && !b.trade) {
return -1;
}
if (a.trade && b.trade) {
// 2 open trade pairs
return a.trade.trade_id > b.trade.trade_id ? 1 : -1;
}
if (!a.locks && b.locks) {
return -1;
}
if (a.locks && b.locks) {
// Both have locks
return a.locks.lock_end_timestamp > b.locks.lock_end_timestamp ? 1 : -1;
}
return 1;
});
}
if (trade) {
profitString += `\nOpen since: ${timestampms(trade.open_timestamp)}`;
}
comb.push({ pair, trade, locks, lockReason, profitString, profit, profitAbs, tradeCount });
return comb;
});
if (this.sortMethod === 'profit') {
comb.sort((a, b) => {
if (a.profit > b.profit) {
return -1;
}
return 1;
});
} else {
// sort Pairs: "with open trade" -> available -> locked
comb.sort((a, b) => {
if (a.trade && !b.trade) {
return -1;
}
if (a.trade && b.trade) {
// 2 open trade pairs
return a.trade.trade_id > b.trade.trade_id ? 1 : -1;
}
if (!a.locks && b.locks) {
return -1;
}
if (a.locks && b.locks) {
// Both have locks
return a.locks.lock_end_timestamp > b.locks.lock_end_timestamp ? 1 : -1;
}
return 1;
});
}
return comb;
}
get tableFields() {
return [
{ key: 'pair', label: 'Pair' },
{
key: 'locks.lock_end_timestamp',
label: 'Lock',
formatter: (value) => (value ? `${timestampms(value)}` : ''),
},
{
key: 'trade.current_profit',
label: 'Position',
formatter: (value) => formatPercent(value, 3),
},
];
}
}
const tableFields = computed(() => {
return [
{ key: 'pair', label: 'Pair' },
{
key: 'locks.lock_end_timestamp',
label: 'Lock',
formatter: (value) => (value ? `${timestampms(value)}` : ''),
},
{
key: 'trade.current_profit',
label: 'Position',
formatter: (value) => formatPercent(value, 3),
},
];
});
return {
combinedPairList,
tableFields,
botStore,
};
},
});
</script>
<style scoped>

View File

@ -3,38 +3,39 @@
<div class="mb-2">
<h3>Performance</h3>
</div>
<b-table class="table-sm" :items="performanceStats" :fields="tableFields"></b-table>
<b-table
class="table-sm"
:items="botStore.activeBot.performanceStats"
:fields="tableFields"
></b-table>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BotState, PerformanceEntry } from '@/types';
import { formatPrice } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, computed } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({})
export default class Performance extends Vue {
// TODO: Verify type of PerformanceStats!
@ftbot.Getter [BotStoreGetters.performanceStats]!: PerformanceEntry[];
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
get tableFields() {
return [
{ key: 'pair', label: 'Pair' },
{ key: 'profit', label: 'Profit %' },
{
key: 'profit_abs',
label: `Profit ${this.botState?.stake_currency}`,
formatter: (v: number) => formatPrice(v, 5),
},
{ key: 'count', label: 'Count' },
];
}
}
export default defineComponent({
name: 'Performance',
setup() {
const botStore = useBotStore();
const tableFields = computed(() => {
return [
{ key: 'pair', label: 'Pair' },
{ key: 'profit', label: 'Profit %' },
{
key: 'profit_abs',
label: `Profit ${botStore.activeBot.botState?.stake_currency}`,
formatter: (v: number) => formatPrice(v, 5),
},
{ key: 'count', label: 'Count' },
];
});
return {
tableFields,
botStore,
};
},
});
</script>

View File

@ -10,7 +10,7 @@
variant="secondary"
size="sm"
title="Auto Refresh All bots"
@click="allRefreshFull"
@click="botStore.allRefreshFull"
>
<RefreshIcon :size="16" />
</b-button>
@ -18,35 +18,30 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import RefreshIcon from 'vue-material-design-icons/Refresh.vue';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, computed } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
export default defineComponent({
name: 'ReloadControl',
components: { RefreshIcon },
setup() {
const botStore = useBotStore();
const autoRefreshLoc = computed({
get() {
return botStore.globalAutoRefresh;
},
set(newValue: boolean) {
botStore.setGlobalAutoRefresh(newValue);
},
});
@Component({ components: { RefreshIcon } })
export default class ReloadControl extends Vue {
refreshInterval: number | null = null;
refreshIntervalSlow: number | null = null;
@ftbot.Getter [MultiBotStoreGetters.globalAutoRefresh]!: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action setGlobalAutoRefresh!: (newValue: boolean) => void;
@ftbot.Action allRefreshFull;
get autoRefreshLoc() {
return this.globalAutoRefresh;
}
set autoRefreshLoc(newValue: boolean) {
this.setGlobalAutoRefresh(newValue);
}
}
return {
botStore,
autoRefreshLoc,
};
},
});
</script>
<style scoped></style>

View File

@ -4,71 +4,59 @@
<b-form-select
id="strategy-select"
v-model="locStrategy"
:options="strategyList"
@change="strategyChanged"
:options="botStore.activeBot.strategyList"
>
</b-form-select>
<div class="ml-2">
<b-button @click="getStrategyList">&#x21bb;</b-button>
<b-button @click="botStore.activeBot.getStrategyList">&#x21bb;</b-button>
</div>
</div>
<textarea v-if="showDetails && strategy" v-model="strategyCode" class="w-100 h-100"></textarea>
<textarea
v-if="showDetails && botStore.activeBot.strategy"
v-model="strategyCode"
class="w-100 h-100"
></textarea>
</div>
</template>
<script lang="ts">
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { StrategyResult } from '@/types';
import { Component, Vue, Prop, Emit } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { useBotStore } from '@/stores/ftbotwrapper';
import { defineComponent, computed, onMounted } from '@vue/composition-api';
const ftbot = namespace(StoreModules.ftbot);
export default defineComponent({
name: 'StrategySelect',
props: {
value: { type: String, required: true },
showDetails: { default: false, required: false, type: Boolean },
},
emits: ['input'],
setup(props, { emit }) {
const botStore = useBotStore();
@Component({})
export default class StrategySelect extends Vue {
@Prop() value!: string;
const strategyCode = computed((): string => botStore.activeBot.strategy?.code);
const locStrategy = computed({
get() {
return props.value;
},
set(strategy: string) {
botStore.activeBot.getStrategy(strategy);
emit('input', strategy);
},
});
@Prop({ default: false, required: false }) showDetails!: boolean;
@ftbot.Action getStrategyList;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action getStrategy!: (strategy: string) => void;
@ftbot.Getter [BotStoreGetters.strategyList]!: string[];
@ftbot.Getter [BotStoreGetters.strategy]: StrategyResult;
@Emit('input')
emitStrategy(strategy: string) {
this.getStrategy(strategy);
return strategy;
}
get strategyCode(): string {
return this.strategy?.code;
}
get locStrategy() {
return this.value;
}
set locStrategy(val) {
this.emitStrategy(val);
}
strategyChanged(newVal) {
this.value = newVal;
}
mounted() {
if (this.strategyList.length === 0) {
this.getStrategyList();
}
}
}
onMounted(() => {
if (botStore.activeBot.strategyList.length === 0) {
botStore.activeBot.getStrategyList();
}
});
return {
botStore,
strategyCode,
locStrategy,
};
},
});
</script>
<style></style>

View File

@ -37,61 +37,64 @@
</template>
<script lang="ts">
import { Component, Vue, Emit, Prop, Watch } from 'vue-property-decorator';
import { dateFromString, dateStringToTimeRange, timestampToDateString } from '@/shared/formatters';
import { defineComponent, ref, computed, onMounted, watch } from '@vue/composition-api';
const now = new Date();
@Component({})
export default class TimeRangeSelect extends Vue {
dateFrom = '';
dateTo = '';
export default defineComponent({
name: 'TimeRangeSelect',
props: {
value: { required: true, type: String },
},
setup(props, { emit }) {
const dateFrom = ref<string>('');
const dateTo = ref<string>('');
@Prop() value!: string;
const timeRange = computed(() => {
if (dateFrom.value !== '' || dateTo.value !== '') {
return `${dateStringToTimeRange(dateFrom.value)}-${dateStringToTimeRange(dateTo.value)}`;
}
return '';
});
@Emit('input')
emitTimeRange() {
return this.timeRange;
}
const updated = () => {
emit('input', timeRange.value);
};
@Watch('value')
valueChanged(val) {
console.log('TimeRange', val);
if (val !== this.value) {
this.updateInput();
}
}
const updateInput = () => {
const tr = props.value.split('-');
if (tr[0]) {
dateFrom.value = timestampToDateString(dateFromString(tr[0], 'yyyyMMdd'));
}
if (tr.length > 1 && tr[1]) {
dateTo.value = timestampToDateString(dateFromString(tr[1], 'yyyyMMdd'));
}
updated();
};
updateInput() {
const tr = this.value.split('-');
if (tr[0]) {
this.dateFrom = timestampToDateString(dateFromString(tr[0], 'yyyyMMdd'));
}
if (tr.length > 1 && tr[1]) {
this.dateTo = timestampToDateString(dateFromString(tr[1], 'yyyyMMdd'));
}
}
watch(
() => timeRange.value,
() => updated(),
);
created() {
if (!this.value) {
this.dateFrom = timestampToDateString(new Date(now.getFullYear(), now.getMonth() - 1, 1));
} else {
this.updateInput();
}
this.emitTimeRange();
}
onMounted(() => {
if (!props.value) {
dateFrom.value = timestampToDateString(new Date(now.getFullYear(), now.getMonth() - 1, 1));
} else {
updateInput();
}
emit('input', timeRange.value);
});
updated() {
this.emitTimeRange();
}
get timeRange() {
if (this.dateFrom !== '' || this.dateTo !== '') {
return `${dateStringToTimeRange(this.dateFrom)}-${dateStringToTimeRange(this.dateTo)}`;
}
return '';
}
}
return {
dateFrom,
dateTo,
timeRange,
updated,
};
},
});
</script>
<style scoped></style>

View File

@ -8,59 +8,61 @@
</template>
<script lang="ts">
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import { defineComponent, computed, ref } from '@vue/composition-api';
@Component({})
export default class Template extends Vue {
selectedTimeframe = '';
export default defineComponent({
name: 'TimefameSelect',
props: {
value: { default: '', type: String },
belowTimeframe: { required: false, default: '', type: String },
},
emits: ['input'],
setup(props, { emit }) {
const selectedTimeframe = ref('');
// The below list must always remain sorted correctly!
const availableTimeframesBase = [
// Placeholder value
{ value: '', text: 'Use strategy default' },
'1m',
'3m',
'5m',
'15m',
'30m',
'1h',
'2h',
'4h',
'6h',
'8h',
'12h',
'1d',
'3d',
'1w',
'2w',
'1M',
'1y',
];
@Prop({ default: '' }) value!: string;
const availableTimeframes = computed(() => {
if (!props.belowTimeframe) {
return availableTimeframesBase;
}
const idx = availableTimeframesBase.findIndex((v) => v === props.belowTimeframe);
// Filter available timeframes to be lower than this timeframe.
@Prop({ default: '', required: false }) belowTimeframe!: string;
return [...availableTimeframesBase].splice(0, idx);
});
@Emit('input')
emitSelectedTimeframe() {
return this.selectedTimeframe;
}
const emitSelectedTimeframe = () => {
emit('input', selectedTimeframe.value);
};
@Watch('value')
watchValue() {
this.selectedTimeframe = this.value;
}
get availableTimeframes() {
if (!this.belowTimeframe) {
return this.availableTimeframesBase;
}
const idx = this.availableTimeframesBase.findIndex((v) => v === this.belowTimeframe);
return [...this.availableTimeframesBase].splice(0, idx);
}
// The below list must always remain sorted correctly!
availableTimeframesBase = [
// Placeholder value
{ value: '', text: 'Use strategy default' },
'1m',
'3m',
'5m',
'15m',
'30m',
'1h',
'2h',
'4h',
'6h',
'8h',
'12h',
'1d',
'3d',
'1w',
'2w',
'1M',
'1y',
];
}
return {
availableTimeframesBase,
availableTimeframes,
emitSelectedTimeframe,
selectedTimeframe,
};
},
});
</script>
<style scoped></style>

View File

@ -99,29 +99,25 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { formatPercent, formatPriceCurrency, formatPrice, timestampms } from '@/shared/formatters';
import ValuePair from '@/components/general/ValuePair.vue';
import TradeProfit from '@/components/ftbot/TradeProfit.vue';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import { Trade } from '@/types';
@Component({
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
name: 'TradeDetail',
components: { ValuePair, TradeProfit, DateTimeTZ },
})
export default class TradeDetail extends Vue {
@Prop({ type: Object, required: true }) trade!: Trade;
@Prop({ type: String, required: true }) stakeCurrency!: string;
timestampms = timestampms;
formatPercent = formatPercent;
formatPrice = formatPrice;
formatPriceCurrency = formatPriceCurrency;
}
props: {
trade: { required: true, type: Object as () => Trade },
stakeCurrency: { required: true, type: String },
},
setup() {
return { timestampms, formatPercent, formatPrice, formatPriceCurrency };
},
});
</script>
<style scoped>
.detail-header {

View File

@ -26,7 +26,7 @@
<b-popover :target="`btn-actions_${row.index}`" triggers="focus" placement="left">
<trade-actions
:trade="row.item"
:bot-api-version="botApiVersion"
:bot-api-version="botStore.activeBot.botApiVersion"
@deleteTrade="removeTradeHandler"
@forceSell="forcesellHandler"
/>
@ -44,7 +44,7 @@
<template #cell(trade_id)="row">
{{ row.item.trade_id }}
{{
botApiVersion > 2.0 && row.item.trading_mode !== 'spot'
botStore.activeBot.botApiVersion > 2.0 && row.item.trading_mode !== 'spot'
? '| ' + (row.item.is_short ? 'Short' : 'Long')
: ''
}}
@ -80,197 +80,181 @@
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { formatPercent, formatPrice } from '@/shared/formatters';
import { MultiDeletePayload, MultiForcesellPayload, Trade } from '@/types';
import ActionIcon from 'vue-material-design-icons/GestureTap.vue';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import TradeProfit from './TradeProfit.vue';
import TradeActions from './TradeActions.vue';
const ftbot = namespace(StoreModules.ftbot);
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
@Component({
export default defineComponent({
name: 'TradeList',
components: { ActionIcon, DateTimeTZ, TradeProfit, TradeActions },
})
export default class TradeList extends Vue {
$refs!: {
tradesTable: HTMLFormElement;
};
props: {
trades: { required: true, type: Array as () => Array<Trade> },
title: { default: 'Trades', type: String },
stakeCurrency: { required: false, default: '', type: String },
activeTrades: { default: false, type: Boolean },
showFilter: { default: false, type: Boolean },
multiBotView: { default: false, type: Boolean },
emptyText: { default: 'No Trades to show.', type: String },
},
setup(props, { root }) {
const botStore = useBotStore();
const currentPage = ref(1);
const selectedItemIndex = ref();
const filterText = ref('');
const perPage = props.activeTrades ? 200 : 15;
const tradesTable = ref<HTMLFormElement>();
formatPercent = formatPercent;
formatPrice = formatPrice;
@Prop({ required: true }) trades!: Array<Trade>;
@Prop({ default: 'Trades' }) title!: string;
@Prop({ required: false, default: '' }) stakeCurrency!: string;
@Prop({ default: false }) activeTrades!: boolean;
@Prop({ default: false }) showFilter!: boolean;
@Prop({ default: false, type: Boolean }) multiBotView!: boolean;
@Prop({ default: 'No Trades to show.' }) emptyText!: string;
@ftbot.Getter [BotStoreGetters.detailTradeId]?: number;
@ftbot.Getter [BotStoreGetters.stakeCurrencyDecimals]!: number;
@ftbot.Getter [BotStoreGetters.botApiVersion]: number;
@ftbot.Action setDetailTrade;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action forceSellMulti!: (payload: MultiForcesellPayload) => Promise<string>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action deleteTradeMulti!: (payload: MultiDeletePayload) => Promise<string>;
currentPage = 1;
selectedItemIndex? = undefined;
filterText = '';
@Watch('detailTradeId')
watchTradeDetail(val) {
const index = this.trades.findIndex((v) => v.trade_id === val);
// Unselect when another tradeTable is selected!
if (index < 0) {
this.$refs.tradesTable.clearSelected();
}
}
get rows(): number {
return this.trades.length;
}
perPage = this.activeTrades ? 200 : 15;
// Added to table-fields for current trades
openFields: Record<string, string | Function>[] = [{ key: 'actions' }];
// Added to table-fields for historic trades
closedFields: Record<string, string | Function>[] = [
{ key: 'close_timestamp', label: 'Close date' },
{ key: 'exit_reason', label: 'Close Reason' },
];
tableFields: Record<string, string | Function>[] = [
this.multiBotView ? { key: 'botName', label: 'Bot' } : {},
{ key: 'trade_id', label: 'ID' },
{ key: 'pair', label: 'Pair' },
{ key: 'amount', label: 'Amount' },
{
key: 'stake_amount',
label: 'Stake amount',
formatter: (value: number) => this.formatPriceWithDecimals(value),
},
{
key: 'open_rate',
label: 'Open rate',
formatter: (value: number) => this.formatPrice(value),
},
{
key: this.activeTrades ? 'current_rate' : 'close_rate',
label: this.activeTrades ? 'Current rate' : 'Close rate',
formatter: (value: number) => this.formatPrice(value),
},
{
key: 'profit',
label: this.activeTrades ? 'Current profit %' : 'Profit %',
formatter: (value: number, key, item: Trade) => {
const percent = formatPercent(item.profit_ratio, 2);
return `${percent} ${`(${this.formatPriceWithDecimals(item.profit_abs)})`}`;
const openFields: Record<string, string | Function>[] = [{ key: 'actions' }];
const closedFields: Record<string, string | Function>[] = [
{ key: 'close_timestamp', label: 'Close date' },
{ key: 'exit_reason', label: 'Close Reason' },
];
const formatPriceWithDecimals = (price) => {
return formatPrice(price, botStore.activeBot.stakeCurrencyDecimals);
};
const rows = computed(() => {
return props.trades.length;
});
const tableFields: Record<string, string | Function>[] = [
props.multiBotView ? { key: 'botName', label: 'Bot' } : {},
{ key: 'trade_id', label: 'ID' },
{ key: 'pair', label: 'Pair' },
{ key: 'amount', label: 'Amount' },
{
key: 'stake_amount',
label: 'Stake amount',
formatter: (value: number) => formatPriceWithDecimals(value),
},
},
{ key: 'open_timestamp', label: 'Open date' },
...(this.activeTrades ? this.openFields : this.closedFields),
];
{
key: 'open_rate',
label: 'Open rate',
formatter: (value: number) => formatPrice(value),
},
{
key: props.activeTrades ? 'current_rate' : 'close_rate',
label: props.activeTrades ? 'Current rate' : 'Close rate',
formatter: (value: number) => formatPrice(value),
},
{
key: 'profit',
label: props.activeTrades ? 'Current profit %' : 'Profit %',
formatter: (value: number, key, item: Trade) => {
const percent = formatPercent(item.profit_ratio, 2);
return `${percent} ${`(${formatPriceWithDecimals(item.profit_abs)})`}`;
},
},
{ key: 'open_timestamp', label: 'Open date' },
...(props.activeTrades ? openFields : closedFields),
];
formatPriceWithDecimals(price) {
return formatPrice(price, this.stakeCurrencyDecimals);
}
forcesellHandler(item: Trade, ordertype: string | undefined = undefined) {
this.$bvModal
.msgBoxConfirm(`Really forcesell trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiForcesellPayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
if (ordertype) {
payload.ordertype = ordertype;
const forcesellHandler = (item: Trade, ordertype: string | undefined = undefined) => {
root.$bvModal
.msgBoxConfirm(`Really forcesell trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiForcesellPayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
if (ordertype) {
payload.ordertype = ordertype;
}
botStore
.forceSellMulti(payload)
.then((xxx) => console.log(xxx))
.catch((error) => console.log(error.response));
}
this.forceSellMulti(payload)
.then((xxx) => console.log(xxx))
.catch((error) => console.log(error.response));
}
});
}
});
};
handleContextMenuEvent(item, index, event) {
// stop browser context menu from appearing
if (!this.activeTrades) {
return;
}
event.preventDefault();
// log the selected item to the console
console.log(item);
}
removeTradeHandler(item) {
console.log(item);
this.$bvModal
.msgBoxConfirm(`Really delete trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiDeletePayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
this.deleteTradeMulti(payload).catch((error) => console.log(error.response));
}
});
}
onRowClicked(item, index) {
// Only allow single selection mode!
if (
item &&
item.trade_id !== this.detailTradeId &&
!this.$refs.tradesTable.isRowSelected(index)
) {
this.setDetailTrade(item);
} else {
console.log('unsetting item');
this.setDetailTrade(null);
}
}
onRowSelected() {
if (this.detailTradeId) {
const itemIndex = this.trades.findIndex((v) => v.trade_id === this.detailTradeId);
if (itemIndex >= 0) {
this.$refs.tradesTable.selectRow(itemIndex);
} else {
console.log(`Unsetting item for tradeid ${this.selectedItemIndex}`);
this.selectedItemIndex = undefined;
const handleContextMenuEvent = (item, index, event) => {
// stop browser context menu from appearing
if (!props.activeTrades) {
return;
}
}
}
}
event.preventDefault();
// log the selected item to the console
console.log(item);
};
const removeTradeHandler = (item) => {
console.log(item);
root.$bvModal
.msgBoxConfirm(`Really delete trade ${item.trade_id} (Pair ${item.pair})?`)
.then((value: boolean) => {
if (value) {
const payload: MultiDeletePayload = {
tradeid: String(item.trade_id),
botId: item.botId,
};
botStore.deleteTradeMulti(payload).catch((error) => console.log(error.response));
}
});
};
const onRowClicked = (item, index) => {
// Only allow single selection mode!
if (
item &&
item.trade_id !== botStore.activeBot.detailTradeId &&
!tradesTable.value?.isRowSelected(index)
) {
botStore.activeBot.setDetailTrade(item);
} else {
console.log('unsetting item');
botStore.activeBot.setDetailTrade(null);
}
};
const onRowSelected = () => {
if (botStore.activeBot.detailTradeId) {
const itemIndex = props.trades.findIndex(
(v) => v.trade_id === botStore.activeBot.detailTradeId,
);
if (itemIndex >= 0) {
tradesTable.value?.selectRow(itemIndex);
} else {
console.log(`Unsetting item for tradeid ${selectedItemIndex.value}`);
selectedItemIndex.value = undefined;
}
}
};
watch(
() => botStore.activeBot.detailTradeId,
(val: number) => {
const index = props.trades.findIndex((v) => v.trade_id === val);
// Unselect when another tradeTable is selected!
if (index < 0) {
tradesTable.value?.clearSelected();
}
},
);
return {
botStore,
currentPage,
selectedItemIndex,
filterText,
perPage,
tableFields,
rows,
tradesTable,
forcesellHandler,
handleContextMenuEvent,
removeTradeHandler,
onRowClicked,
onRowSelected,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -10,25 +10,27 @@
<script lang="ts">
import { formatPercent, timestampms } from '@/shared/formatters';
import { Trade } from '@/types';
import { Component, Prop, Vue } from 'vue-property-decorator';
import ProfitPill from '@/components/general/ProfitPill.vue';
@Component({ components: { ProfitPill } })
export default class TradeProfit extends Vue {
@Prop({ required: true, type: Object }) trade!: Trade;
import { defineComponent, computed } from '@vue/composition-api';
formatPercent = formatPercent;
timestampms = timestampms;
get profitDesc(): string {
let profit = `Current profit: ${formatPercent(this.trade.profit_ratio)} (${
this.trade.profit_abs
})`;
profit += `\nOpen since: ${timestampms(this.trade.open_timestamp)}`;
return profit;
}
}
export default defineComponent({
name: 'TradeProfit',
components: { ProfitPill },
props: {
trade: { required: true, type: Object as () => Trade },
},
setup(props) {
const profitDesc = computed((): string => {
let profit = `Current profit: ${formatPercent(props.trade.profit_ratio)} (${
props.trade.profit_abs
})`;
profit += `\nOpen since: ${timestampms(props.trade.open_timestamp)}`;
return profit;
});
return { profitDesc };
},
});
</script>
<style scoped></style>

View File

@ -4,38 +4,39 @@
<script lang="ts">
import { timestampms, timestampmsWithTimezone, timestampToDateString } from '@/shared/formatters';
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({})
export default class DateTimeTZ extends Vue {
@Prop({ required: true, type: Number }) date!: number;
import { defineComponent, computed } from '@vue/composition-api';
@Prop({ required: false, type: Boolean, default: false }) showTimezone!: boolean;
export default defineComponent({
name: 'DateTimeTZ',
props: {
date: { required: true, type: Number },
showTimezone: { required: false, type: Boolean, default: false },
dateOnly: { required: false, type: Boolean, default: false },
},
setup(props) {
const formattedDate = computed((): string => {
if (props.dateOnly) {
return timestampToDateString(props.date);
}
if (props.showTimezone) {
return timestampmsWithTimezone(props.date);
}
return timestampms(props.date);
});
@Prop({ required: false, type: Boolean, default: false }) dateOnly!: boolean;
const timezoneTooltip = computed((): string => {
const time1 = timestampmsWithTimezone(props.date);
const timeUTC = timestampmsWithTimezone(props.date, 'UTC');
if (time1 === timeUTC) {
return timeUTC;
}
timestampms = timestampms;
get formattedDate(): string {
if (this.dateOnly) {
return timestampToDateString(this.date);
}
if (this.showTimezone) {
return timestampmsWithTimezone(this.date);
}
return timestampms(this.date);
}
get timezoneTooltip(): string {
const time1 = timestampmsWithTimezone(this.date);
const timeUTC = timestampmsWithTimezone(this.date, 'UTC');
if (time1 === timeUTC) {
return timeUTC;
}
return `${time1}\n${timeUTC}`;
}
}
return `${time1}\n${timeUTC}`;
});
return { formattedDate, timezoneTooltip };
},
});
</script>
<style scoped></style>

View File

@ -20,41 +20,38 @@
</template>
<script lang="ts">
import { formatPercent, formatPrice, timestampms } from '@/shared/formatters';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { formatPercent, formatPrice } from '@/shared/formatters';
import ProfitSymbol from '@/components/general/ProfitSymbol.vue';
@Component({ components: { ProfitSymbol } })
export default class ProfitPill extends Vue {
@Prop({ required: false, default: undefined, type: Number }) profitRatio?: number;
import { defineComponent, computed } from '@vue/composition-api';
@Prop({ required: false, default: undefined, type: Number }) profitAbs?: number;
export default defineComponent({
name: 'ProfitPill',
components: { ProfitSymbol },
props: {
profitRatio: { required: false, default: undefined, type: Number },
profitAbs: { required: false, default: undefined, type: Number },
stakeCurrency: { required: true, type: String },
profitDesc: { required: false, default: '', type: String },
},
setup(props) {
const isProfitable = computed(() => {
return (
(props.profitRatio !== undefined && props.profitRatio > 0) ||
(props.profitRatio === undefined && props.profitAbs !== undefined && props.profitAbs > 0)
);
});
@Prop({ required: true, type: String }) stakeCurrency!: string;
@Prop({ required: false, default: '', type: String }) profitDesc!: string;
formatPercent = formatPercent;
timestampms = timestampms;
formatPrice = formatPrice;
get isProfitable() {
return (
(this.profitRatio !== undefined && this.profitRatio > 0) ||
(this.profitRatio === undefined && this.profitAbs !== undefined && this.profitAbs > 0)
);
}
get profitString(): string {
if (this.profitRatio !== undefined && this.profitAbs !== undefined) {
return `(${formatPrice(this.profitAbs, 3)})`;
}
return '';
}
}
const profitString = computed((): string => {
if (props.profitRatio !== undefined && props.profitAbs !== undefined) {
return `(${formatPrice(props.profitAbs, 3)})`;
}
return '';
});
return { profitString, isProfitable, formatPercent };
},
});
</script>
<style scoped lang="scss">

View File

@ -5,16 +5,22 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { defineComponent, computed } from '@vue/composition-api';
@Component({})
export default class ProfitSymbol extends Vue {
@Prop({ required: true, type: Number }) profit!: number;
get isProfitable() {
return this.profit > 0;
}
}
export default defineComponent({
name: 'ProfitSymbol',
props: {
profit: { type: Number, required: true },
},
setup(props) {
const isProfitable = computed(() => {
return props.profit > 0;
});
return {
isProfitable,
};
},
});
</script>
<style scoped lang="scss">

View File

@ -8,9 +8,9 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { defineComponent } from '@vue/composition-api';
export default Vue.extend({
export default defineComponent({
name: 'ValuePair',
props: {
description: {

View File

@ -6,13 +6,13 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from '@vue/composition-api';
import BotAlerts from '@/components/ftbot/BotAlerts.vue';
@Component({
export default defineComponent({
name: 'Body',
components: { BotAlerts },
})
export default class Body extends Vue {}
});
</script>
<style lang="scss" scoped>
.container-main {

View File

@ -10,12 +10,14 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { defineComponent } from '@vue/composition-api';
@Component({})
export default class DraggableContainer extends Vue {
@Prop({ required: true, type: String }) header!: string;
}
export default defineComponent({
name: 'DraggableContainer',
props: {
header: { required: true, type: String },
},
});
</script>
<style scoped>

View File

@ -12,15 +12,15 @@
<b-collapse id="nav-collapse" class="text-right text-md-center" is-nav>
<b-navbar-nav>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/trade"
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/trade"
>Trade</router-link
>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/dashboard"
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/dashboard"
>Dashboard</router-link
>
<router-link class="nav-link navbar-nav" to="/graph">Chart</router-link>
<router-link class="nav-link navbar-nav" to="/logs">Logs</router-link>
<router-link v-if="canRunBacktest" class="nav-link navbar-nav" to="/backtest"
<router-link v-if="botStore.canRunBacktest" class="nav-link navbar-nav" to="/backtest"
>Backtest</router-link
>
<BootswatchThemeSelect />
@ -31,7 +31,7 @@
<!-- TODO This should show outside of the dropdown in XS mode -->
<div class="d-flex justify-content-between">
<b-dropdown
v-if="botCount > 1"
v-if="botStore.botCount > 1"
size="sm"
class="m-1"
no-caret
@ -40,7 +40,7 @@
menu-class="my-0 py-0"
>
<template #button-content>
<BotEntry :bot="selectedBotObj" :no-buttons="true" />
<BotEntry :bot="botStore.selectedBotObj" :no-buttons="true" />
</template>
<BotList :small="true" />
</b-dropdown>
@ -48,24 +48,31 @@
</div>
<li class="d-none d-sm-block nav-item text-secondary mr-2">
<b-nav-text class="verticalCenter small mr-2">
{{ botName || 'No bot selected' }}
{{
(botStore.activeBotorUndefined && botStore.activeBotorUndefined.botName) ||
'No bot selected'
}}
</b-nav-text>
<b-nav-text class="verticalCenter">
{{ isBotOnline ? 'Online' : 'Offline' }}
{{
botStore.activeBotorUndefined && botStore.activeBotorUndefined.isBotOnline
? 'Online'
: 'Offline'
}}
</b-nav-text>
</li>
<li v-if="hasBots" class="nav-item">
<li v-if="botStore.hasBots" class="nav-item">
<!-- Hide dropdown on xs, instead show below -->
<b-nav-item-dropdown id="avatar-drop" right class="d-none d-sm-block">
<template #button-content>
<b-avatar size="2em" button>FT</b-avatar>
</template>
<b-dropdown-item>V: {{ getUiVersion }}</b-dropdown-item>
<b-dropdown-item>V: {{ settingsStore.uiVersion }}</b-dropdown-item>
<router-link class="dropdown-item" to="/settings">Settings</router-link>
<b-checkbox v-model="layoutLockedLocal" class="pl-5">Lock layout</b-checkbox>
<b-checkbox v-model="layoutStore.layoutLocked" class="pl-5">Lock layout</b-checkbox>
<b-dropdown-item @click="resetDynamicLayout">Reset Layout</b-dropdown-item>
<router-link
v-if="botCount === 1"
v-if="botStore.botCount === 1"
class="dropdown-item"
to="/"
@click.native="clickLogout()"
@ -77,16 +84,23 @@
<li class="nav-item text-secondary ml-2 d-sm-none d-flex justify-content-between">
<div class="d-flex">
<b-nav-text class="verticalCenter small mr-2">
{{ botName || 'No bot selected' }}
{{
(botStore.activeBotorUndefined && botStore.activeBotorUndefined.botName) ||
'No bot selected'
}}
</b-nav-text>
<b-nav-text class="verticalCenter">
{{ isBotOnline ? 'Online' : 'Offline' }}
{{
botStore.activeBotorUndefined && botStore.activeBotorUndefined.isBotOnline
? 'Online'
: 'Offline'
}}
</b-nav-text>
</div>
</li>
<router-link class="nav-link navbar-nav" to="/settings">Settings</router-link>
<router-link
v-if="botCount === 1"
v-if="botStore.botCount === 1"
class="nav-link navbar-nav"
to="/"
@click.native="clickLogout()"
@ -105,166 +119,126 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import LoginModal from '@/views/LoginModal.vue';
import { Action, namespace, Getter } from 'vuex-class';
import BootswatchThemeSelect from '@/components/BootswatchThemeSelect.vue';
import { LayoutActions, LayoutGetters } from '@/store/modules/layout';
import { BotStoreGetters } from '@/store/modules/ftbot';
import Favico from 'favico.js';
import { OpenTradeVizOptions, SettingsGetters } from '@/store/modules/settings';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import ReloadControl from '@/components/ftbot/ReloadControl.vue';
import BotEntry from '@/components/BotEntry.vue';
import BotList from '@/components/BotList.vue';
import { BotDescriptor } from '@/types';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, ref, onBeforeUnmount, onMounted, watch } from '@vue/composition-api';
import { useRoute } from 'vue2-helpers/vue-router';
import { OpenTradeVizOptions, useSettingsStore } from '@/stores/settings';
import { useLayoutStore } from '@/stores/layout';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
const layoutNs = namespace(StoreModules.layout);
const uiSettingsNs = namespace(StoreModules.uiSettings);
@Component({
export default defineComponent({
name: 'NavBar',
components: { LoginModal, BootswatchThemeSelect, ReloadControl, BotEntry, BotList },
})
export default class NavBar extends Vue {
pingInterval: number | null = null;
setup() {
const botStore = useBotStore();
botSelectOpen = false;
const settingsStore = useSettingsStore();
const layoutStore = useLayoutStore();
const route = useRoute();
const favicon = ref<Favico | undefined>(undefined);
const pingInterval = ref<number>();
@Action setLoggedIn;
const clickLogout = () => {
botStore.removeBot(botStore.selectedBot);
// TODO: This should be per bot
};
@Action loadUIVersion;
const setOpenTradesAsPill = (tradeCount: number) => {
if (!favicon.value) {
favicon.value = new Favico({
animation: 'none',
// position: 'up',
// fontStyle: 'normal',
// bgColor: '#',
// textColor: '#FFFFFF',
});
}
if (tradeCount !== 0 && settingsStore.openTradesInTitle === 'showPill') {
favicon.value.badge(tradeCount);
} else {
favicon.value.reset();
console.log('reset');
}
};
const resetDynamicLayout = (): void => {
console.log(`resetLayout called for ${route?.fullPath}`);
switch (route?.fullPath) {
case '/trade':
layoutStore.resetTradingLayout();
break;
case '/dashboard':
layoutStore.resetDashboardLayout();
break;
default:
}
};
const setTitle = () => {
let title = 'freqUI';
if (settingsStore.openTradesInTitle === OpenTradeVizOptions.asTitle) {
title = `(${botStore.activeBotorUndefined?.openTradeCount}) ${title}`;
}
if (botStore.activeBotorUndefined?.botName) {
title = `${title} - ${botStore.activeBotorUndefined?.botName}`;
}
document.title = title;
};
@Getter getUiVersion!: string;
onBeforeUnmount(() => {
if (pingInterval) {
clearInterval(pingInterval.value);
}
});
@ftbot.Action pingAll;
onMounted(() => {
settingsStore.loadUIVersion();
pingInterval.value = window.setInterval(botStore.pingAll, 60000);
botStore.allRefreshFull();
});
@ftbot.Action allGetState;
settingsStore.$subscribe((_, state) => {
const needsUpdate = settingsStore.openTradesInTitle !== state.openTradesInTitle;
if (needsUpdate) {
setTitle();
setOpenTradesAsPill(botStore.activeBotorUndefined?.openTradeCount || 0);
}
});
@ftbot.Action logout;
watch(
() => botStore.activeBotorUndefined?.botName,
() => setTitle(),
);
watch(
() => botStore.activeBotorUndefined?.openTradeCount,
() => {
console.log('openTradeCount changed');
if (
settingsStore.openTradesInTitle === OpenTradeVizOptions.showPill &&
botStore.activeBotorUndefined?.openTradeCount
) {
setOpenTradesAsPill(botStore.activeBotorUndefined.openTradeCount);
} else if (settingsStore.openTradesInTitle === OpenTradeVizOptions.asTitle) {
setTitle();
}
},
);
@ftbot.Getter [BotStoreGetters.isBotOnline]!: boolean;
return {
favicon,
@ftbot.Getter [MultiBotStoreGetters.hasBots]: boolean;
@ftbot.Getter [MultiBotStoreGetters.botCount]: number;
@ftbot.Getter [BotStoreGetters.botName]: string;
@ftbot.Getter [BotStoreGetters.openTradeCount]: number;
@ftbot.Getter [BotStoreGetters.canRunBacktest]!: boolean;
@ftbot.Getter [MultiBotStoreGetters.selectedBotObj]!: BotDescriptor;
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
@layoutNs.Action [LayoutActions.resetDashboardLayout];
@layoutNs.Action [LayoutActions.resetTradingLayout];
@layoutNs.Action [LayoutActions.setLayoutLocked];
@uiSettingsNs.Getter [SettingsGetters.openTradesInTitle]: string;
favicon: Favico | undefined = undefined;
mounted() {
this.pingAll();
this.loadUIVersion();
this.pingInterval = window.setInterval(this.pingAll, 60000);
if (this.hasBots) {
// Query botstate - this will enable / disable certain modes
this.allGetState();
}
}
beforeDestroy() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
}
clickLogout(): void {
this.logout();
// TODO: This should be per bot
this.setLoggedIn(false);
}
get layoutLockedLocal() {
return this.getLayoutLocked;
}
set layoutLockedLocal(value: boolean) {
this.setLayoutLocked(value);
}
setOpenTradesAsPill(tradeCount: number) {
if (!this.favicon) {
this.favicon = new Favico({
animation: 'none',
// position: 'up',
// fontStyle: 'normal',
// bgColor: '#',
// textColor: '#FFFFFF',
});
}
if (tradeCount !== 0 && this.openTradesInTitle === 'showPill') {
this.favicon.badge(tradeCount);
} else {
this.favicon.reset();
console.log('reset');
}
}
resetDynamicLayout(): void {
const route = this.$router.currentRoute.path;
console.log(`resetLayout called for ${route}`);
switch (route) {
case '/trade':
this.resetTradingLayout();
break;
case '/dashboard':
this.resetDashboardLayout();
break;
default:
}
}
setTitle() {
let title = 'freqUI';
if (this.openTradesInTitle === OpenTradeVizOptions.asTitle) {
title = `(${this.openTradeCount}) ${title}`;
}
if (this.botName) {
title = `${title} - ${this.botName}`;
}
document.title = title;
}
@Watch(BotStoreGetters.botName)
botnameChanged() {
this.setTitle();
}
@Watch(BotStoreGetters.openTradeCount)
openTradeCountChanged() {
console.log('openTradeCount changed');
if (this.openTradesInTitle === OpenTradeVizOptions.showPill) {
this.setOpenTradesAsPill(this.openTradeCount);
} else if (this.openTradesInTitle === OpenTradeVizOptions.asTitle) {
this.setTitle();
}
}
@Watch(SettingsGetters.openTradesInTitle)
openTradesSettingChanged() {
this.setTitle();
this.setOpenTradesAsPill(this.openTradeCount);
}
}
clickLogout,
resetDynamicLayout,
setTitle,
layoutStore,
botStore,
settingsStore,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -3,23 +3,23 @@
<!-- Only visible on xs (phone) viewport! -->
<hr class="my-0" />
<div class="d-flex flex-align-center justify-content-center">
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/open_trades">
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/open_trades">
<OpenTradesIcon />
Trades
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/trade_history">
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/trade_history">
<ClosedTradesIcon />
History
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/pairlist">
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/pairlist">
<PairListIcon />
Pairlist
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/balance">
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/balance">
<BalanceIcon />
Balance
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/dashboard">
<router-link v-if="!botStore.canRunBacktest" class="nav-link navbar-nav" to="/dashboard">
<DashboardIcon />
Dashboard
</router-link>
@ -28,23 +28,24 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { BotStoreGetters } from '@/store/modules/ftbot';
import OpenTradesIcon from 'vue-material-design-icons/FolderOpen.vue';
import ClosedTradesIcon from 'vue-material-design-icons/FolderLock.vue';
import BalanceIcon from 'vue-material-design-icons/Bank.vue';
import PairListIcon from 'vue-material-design-icons/ViewList.vue';
import DashboardIcon from 'vue-material-design-icons/ViewDashboardOutline.vue';
import { defineComponent } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace('ftbot');
@Component({
export default defineComponent({
name: 'NavFooter',
components: { OpenTradesIcon, ClosedTradesIcon, BalanceIcon, PairListIcon, DashboardIcon },
})
export default class NavFooter extends Vue {
@ftbot.Getter [BotStoreGetters.canRunBacktest]!: boolean;
}
setup() {
const botStore = useBotStore();
return {
botStore,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -2,16 +2,20 @@ import Vue from 'vue';
import './plugins/bootstrap-vue';
import './plugins/composition_api';
import App from './App.vue';
import store from './store';
import router from './router';
import { initApi } from './shared/apiService';
import { createPinia, PiniaVuePlugin } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import VueRouter from 'vue-router';
initApi(store);
Vue.use(PiniaVuePlugin);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
Vue.use(VueRouter);
Vue.config.productionTip = false;
new Vue({
store,
router,
render: (h) => h(App),
pinia,
}).$mount('#app');

View File

@ -1,12 +1,7 @@
import Vue from 'vue';
import VueRouter, { RouteConfig } from 'vue-router';
import Home from '@/views/Home.vue';
import Error404 from '@/views/Error404.vue';
import store from '@/store';
import StoreModules from '@/store/storeSubModules';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
Vue.use(VueRouter);
import { initBots, useBotStore } from '@/stores/ftbotwrapper';
const routes: Array<RouteConfig> = [
{
@ -88,8 +83,10 @@ const router = new VueRouter({
});
router.beforeEach((to, from, next) => {
const hasBots = store.getters[`${StoreModules.ftbot}/${MultiBotStoreGetters.hasBots}`];
if (!to.meta?.allowAnonymous && !hasBots) {
// Init bots here...
initBots();
const botStore = useBotStore();
if (!to.meta?.allowAnonymous && !botStore.hasBots) {
// Forward to login if login is required
next({
path: '/login',

View File

@ -1,11 +1,7 @@
import { useBotStore } from '@/stores/ftbotwrapper';
import axios from 'axios';
import { UserService } from './userService';
/**
* Global store variable - keep a reference here to be able to emmit alerts
*/
let globalStore;
export function useApi(userService: UserService, botId: string) {
const api = axios.create({
baseURL: userService.getBaseUrl(),
@ -60,7 +56,8 @@ export function useApi(userService: UserService, botId: string) {
}
if ((err.response && err.response.status === 500) || err.message === 'Network Error') {
console.log('Bot not running...');
globalStore.dispatch(`ftbot/${botId}/setIsBotOnline`, false);
const botStore = useBotStore();
botStore.botStores[botId]?.setIsBotOnline(false);
}
return new Promise((resolve, reject) => {
@ -73,12 +70,3 @@ export function useApi(userService: UserService, botId: string) {
api,
};
}
/**
* Initialize api so store is accessible.
* @param store Vuex store
*/
export function initApi(store) {
globalStore = store;
//
}

View File

@ -1,85 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { getCurrentTheme, getTheme, storeCurrentTheme } from '@/shared/themes';
import axios from 'axios';
import { UserService } from '@/shared/userService';
import { UiVersion } from '@/types';
import StoreModules from '@/store/storeSubModules';
import createBotStore, { MultiBotStoreGetters } from './modules/botStoreWrapper';
import alertsModule from './modules/alerts';
import layoutModule from './modules/layout';
import settingsModule from './modules/settings';
Vue.use(Vuex);
const initCurrentTheme = getCurrentTheme();
const store = new Vuex.Store({
modules: {
[StoreModules.alerts]: alertsModule,
[StoreModules.layout]: layoutModule,
[StoreModules.uiSettings]: settingsModule,
},
state: {
currentTheme: initCurrentTheme,
uiVersion: 'dev',
},
getters: {
isDarkTheme(state) {
const theme = getTheme(state.currentTheme);
if (theme) {
return theme.dark;
}
return true;
},
getChartTheme(state, getters) {
return getters.isDarkTheme ? 'dark' : 'light';
},
getUiVersion(state) {
return state.uiVersion;
},
loggedIn(state, getters) {
return getters[`${StoreModules.ftbot}/${MultiBotStoreGetters.hasBots}`];
},
},
mutations: {
mutateCurrentTheme(state, newTheme: string) {
storeCurrentTheme(newTheme);
state.currentTheme = newTheme;
},
setUIVersion(state, uiVersion: string) {
state.uiVersion = uiVersion;
},
},
actions: {
setCurrentTheme({ commit }, newTheme: string) {
commit('mutateCurrentTheme', newTheme);
},
setLoggedIn({ commit }, loggedin: boolean) {
commit('setLoggedIn', loggedin);
},
async loadUIVersion({ commit }) {
if (import.meta.env.PROD) {
try {
const result = await axios.get<UiVersion>('/ui_version');
const { version } = result.data;
commit('setUIVersion', version);
} catch (error) {
//
}
}
},
},
});
UserService.migrateLogin();
store.registerModule(StoreModules.ftbot, createBotStore(store));
Object.entries(UserService.getAvailableBots()).forEach(([, v]) => {
store.dispatch(`${StoreModules.ftbot}/addBot`, v);
});
store.dispatch(`${StoreModules.ftbot}/selectFirstBot`);
store.dispatch(`${StoreModules.ftbot}/startRefresh`);
export default store;

View File

@ -1,39 +0,0 @@
import { AlertType } from '@/types/alertTypes';
export enum AlertActions {
addAlert = 'addAlert',
removeAlert = 'removeAlert',
}
export enum AlertMutations {
addAlert = 'addAlert',
removeAlert = 'removeAlert',
}
export default {
namespaced: true,
state: {
activeMessages: [],
},
mutations: {
[AlertMutations.addAlert](state, message: AlertType) {
console.log(`adding message '${message.message}' to message queue`);
state.activeMessages.push(message);
},
[AlertMutations.removeAlert](state) {
state.activeMessages.shift();
},
},
actions: {
[AlertActions.addAlert]({ commit }, message: AlertType) {
commit(AlertMutations.addAlert, message);
},
[AlertActions.removeAlert]({ commit }) {
commit(AlertMutations.removeAlert);
},
},
};
export function showAlert(dispatch, message: string, severity = '') {
dispatch(`alerts/${AlertActions.addAlert}`, { message, severity }, { root: true });
}

View File

@ -1,397 +0,0 @@
import {
BotDescriptor,
BotDescriptors,
DailyPayload,
DailyRecord,
DailyReturnValue,
MultiDeletePayload,
MultiForcesellPayload,
RenameBotPayload,
Trade,
} from '@/types';
import { AxiosInstance } from 'axios';
import StoreModules from '../storeSubModules';
import { BotStoreActions, BotStoreGetters, createBotSubStore } from './ftbot';
const AUTH_SELECTED_BOT = 'ftSelectedBot';
interface FTMultiBotState {
selectedBot: string;
availableBots: BotDescriptors;
globalAutoRefresh: boolean;
refreshing: boolean;
refreshInterval: number | null;
refreshIntervalSlow: number | null;
}
export enum MultiBotStoreGetters {
hasBots = 'hasBots',
botCount = 'botCount',
nextBotId = 'nextBotId',
selectedBot = 'selectedBot',
selectedBotObj = 'selectedBotObj',
globalAutoRefresh = 'globalAutoRefresh',
allAvailableBots = 'allAvailableBots',
allAvailableBotsList = 'allAvailableBotsList',
allTradesAllBots = 'allTradesAllBots',
allOpenTradesAllBots = 'allOpenTradesAllBots',
allDailyStatsAllBots = 'allDailyStatsAllBots',
// Automatically created entries
allIsBotOnline = 'allIsBotOnline',
allAutoRefresh = 'allAutoRefresh',
allProfit = 'allProfit',
allOpenTrades = 'allOpenTrades',
allOpenTradeCount = 'allOpenTradeCount',
allClosedTrades = 'allClosedTrades',
allBotState = 'allBotState',
allBalance = 'allBalance',
}
const createAllGetters = [
'isBotOnline',
'autoRefresh',
'closedTrades',
'profit',
'openTrades',
'openTradeCount',
'closedTrades',
'botState',
'balance',
];
export default function createBotStore(store) {
const state: FTMultiBotState = {
selectedBot: '',
availableBots: {},
globalAutoRefresh: true,
refreshing: false,
refreshInterval: null,
refreshIntervalSlow: null,
};
// All getters working on all bots should be prefixed with all.
const getters = {
[MultiBotStoreGetters.hasBots](state: FTMultiBotState): boolean {
return Object.keys(state.availableBots).length > 0;
},
[MultiBotStoreGetters.botCount](state: FTMultiBotState): number {
return Object.keys(state.availableBots).length;
},
[MultiBotStoreGetters.nextBotId](state: FTMultiBotState): string {
let botCount = Object.keys(state.availableBots).length;
while (`ftbot.${botCount}` in state.availableBots) {
botCount += 1;
}
return `ftbot.${botCount}`;
},
[MultiBotStoreGetters.selectedBot](state: FTMultiBotState): string {
return state.selectedBot;
},
[MultiBotStoreGetters.selectedBotObj](state: FTMultiBotState): BotDescriptor {
return state.availableBots[state.selectedBot];
},
[MultiBotStoreGetters.globalAutoRefresh](state: FTMultiBotState): boolean {
return state.globalAutoRefresh;
},
[MultiBotStoreGetters.allAvailableBots](state: FTMultiBotState): BotDescriptors {
return state.availableBots;
},
[MultiBotStoreGetters.allAvailableBotsList](state: FTMultiBotState): string[] {
return Object.keys(state.availableBots);
},
[MultiBotStoreGetters.allTradesAllBots](state: FTMultiBotState, getters): Trade[] {
let resp: Trade[] = [];
getters.allAvailableBotsList.forEach((botId) => {
const trades = getters[`${botId}/${BotStoreGetters.trades}`].map((t) => ({ ...t, botId }));
resp = resp.concat(trades);
});
return resp;
},
[MultiBotStoreGetters.allOpenTradesAllBots](state: FTMultiBotState, getters): Trade[] {
let resp: Trade[] = [];
getters.allAvailableBotsList.forEach((botId) => {
const trades = getters[`${botId}/${BotStoreGetters.openTrades}`].map((t) => ({
...t,
}));
resp = resp.concat(trades);
});
return resp;
},
[MultiBotStoreGetters.allDailyStatsAllBots](state: FTMultiBotState, getters): DailyReturnValue {
const resp: Record<string, DailyRecord> = {};
getters.allAvailableBotsList.forEach((botId) => {
getters[`${botId}/${BotStoreGetters.dailyStats}`]?.data?.forEach((d) => {
if (!resp[d.date]) {
resp[d.date] = { ...d };
} else {
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].abs_profit += d.abs_profit;
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].fiat_value += d.fiat_value;
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].trade_count += d.trade_count;
}
});
});
const dailyReturn: DailyReturnValue = {
// eslint-disable-next-line @typescript-eslint/camelcase
stake_currency: 'USDT',
// eslint-disable-next-line @typescript-eslint/camelcase
fiat_display_currency: 'USD',
data: Object.values(resp),
};
return dailyReturn;
},
};
// Autocreate getters from botStores
Object.keys(BotStoreGetters).forEach((e) => {
getters[e] = (state, getters) => {
return getters[`${state.selectedBot}/${e}`];
};
});
// Create selected getters
createAllGetters.forEach((e: string) => {
const getterName = `all${e.charAt(0).toUpperCase() + e.slice(1)}`;
console.log('creating getter', e, getterName);
getters[getterName] = (state, getters) => {
const result = {};
getters.allAvailableBotsList.forEach((botId) => {
result[botId] = getters[`${botId}/${e}`];
});
return result;
};
});
const mutations = {
selectBot(state: FTMultiBotState, botId: string) {
if (botId in state.availableBots) {
state.selectedBot = botId;
} else {
console.warn(`Botid ${botId} not available, but selected.`);
}
},
setGlobalAutoRefresh(state, value: boolean) {
state.globalAutoRefresh = value;
},
setRefreshing(state, refreshing: boolean) {
state.refreshing = refreshing;
},
addBot(state: FTMultiBotState, bot: BotDescriptor) {
// When Vue gets initialized, only existing objects will be added with reactivity.
// To add reactivity to new property, we need to mutate the already reactive object.
state.availableBots = {
...state.availableBots,
[bot.botId]: bot,
};
},
renameBot(state: FTMultiBotState, bot: RenameBotPayload) {
state.availableBots[bot.botId].botName = bot.botName;
},
removeBot(state: FTMultiBotState, botId: string) {
if (botId in state.availableBots) {
delete state.availableBots[botId];
}
},
setRefreshInterval(state: FTMultiBotState, interval: number | null) {
state.refreshInterval = interval;
},
setRefreshIntervalSlow(state: FTMultiBotState, interval: number | null) {
state.refreshIntervalSlow = interval;
},
};
const actions = {
// Actions automatically filled below
addBot({ dispatch, getters, commit }, bot: BotDescriptor) {
if (Object.keys(getters.allAvailableBots).includes(bot.botId)) {
// throw 'Bot already present';
// TODO: handle error!
console.log('Bot already present');
return;
}
console.log('add bot', bot);
store.registerModule(
[StoreModules.ftbot, bot.botId],
createBotSubStore(bot.botId, bot.botName),
);
dispatch(`${bot.botId}/botAdded`);
commit('addBot', bot);
},
renameBot({ dispatch, getters, commit }, bot: RenameBotPayload) {
if (!Object.keys(getters.allAvailableBots).includes(bot.botId)) {
// TODO: handle error!
console.error('Bot not found');
return;
}
dispatch(`${bot.botId}/rename`, bot.botName).then(() => {
commit('renameBot', bot);
});
},
removeBot({ commit, getters, dispatch }, botId: string) {
if (Object.keys(getters.allAvailableBots).includes(botId)) {
dispatch(`${botId}/logout`);
store.unregisterModule([StoreModules.ftbot, botId]);
commit('removeBot', botId);
} else {
console.warn(`bot ${botId} not found! could not remove`);
}
},
selectFirstBot({ commit, getters }) {
if (getters.hasBots) {
const selBotId = localStorage.getItem(AUTH_SELECTED_BOT);
const firstBot = Object.keys(getters.allAvailableBots)[0];
let selBot: string | undefined = firstBot;
if (selBotId) {
selBot = Object.keys(getters.allAvailableBots).find((x) => x === selBotId);
}
commit('selectBot', getters.allAvailableBots[selBot || firstBot].botId);
}
},
selectBot({ commit }, botId: string) {
localStorage.setItem(AUTH_SELECTED_BOT, botId);
commit('selectBot', botId);
},
setGlobalAutoRefresh({ commit }, value: boolean) {
commit('setGlobalAutoRefresh', value);
},
allRefreshFrequent({ dispatch, getters }, forceUpdate = false) {
getters.allAvailableBotsList.forEach((e) => {
if (
getters[`${e}/${BotStoreGetters.refreshNow}`] &&
(getters[MultiBotStoreGetters.globalAutoRefresh] || forceUpdate)
) {
// console.log('refreshing', e);
dispatch(`${e}/${BotStoreActions.refreshFrequent}`);
}
});
},
allRefreshSlow({ dispatch, getters }, forceUpdate = false) {
getters.allAvailableBotsList.forEach((e) => {
if (
getters[`${e}/${BotStoreGetters.refreshNow}`] &&
(getters[MultiBotStoreGetters.globalAutoRefresh] || forceUpdate)
) {
dispatch(`${e}/${BotStoreActions.refreshSlow}`, forceUpdate);
}
});
},
async allRefreshFull({ commit, dispatch, state, getters }) {
if (state.refreshing) {
return;
}
commit('setRefreshing', true);
try {
// Ensure all bots status is correct.
await dispatch('pingAll');
getters.allAvailableBotsList.forEach(async (e) => {
if (
getters[`${e}/${BotStoreGetters.isBotOnline}`] &&
!getters[`${e}/${BotStoreGetters.botStatusAvailable}`]
) {
await dispatch('allGetState');
}
});
const updates: Promise<AxiosInstance>[] = [];
updates.push(dispatch('allRefreshFrequent', false));
updates.push(dispatch('allRefreshSlow', true));
// updates.push(dispatch('getDaily'));
// updates.push(dispatch('getBalance'));
await Promise.all(updates);
console.log('refreshing_end');
} finally {
commit('setRefreshing', false);
}
},
startRefresh({ state, dispatch, commit }) {
console.log('Starting automatic refresh.');
dispatch('allRefreshFull');
if (!state.refreshInterval) {
// Set interval for refresh
const refreshInterval = window.setInterval(() => {
dispatch('allRefreshFrequent');
}, 5000);
commit('setRefreshInterval', refreshInterval);
}
if (!state.refreshIntervalSlow) {
const refreshIntervalSlow = window.setInterval(() => {
dispatch('allRefreshSlow', false);
}, 60000);
commit('setRefreshIntervalSlow', refreshIntervalSlow);
}
},
stopRefresh({ state, commit }: { state: FTMultiBotState; commit: any }) {
console.log('Stopping automatic refresh.');
if (state.refreshInterval) {
window.clearInterval(state.refreshInterval);
commit('setRefreshInterval', null);
}
if (state.refreshIntervalSlow) {
window.clearInterval(state.refreshIntervalSlow);
commit('setRefreshIntervalSlow', null);
}
},
async pingAll({ getters, dispatch }) {
await Promise.all(
getters.allAvailableBotsList.map(async (e) => {
try {
await dispatch(`${e}/ping`);
} catch {
// pass
}
}),
);
},
allGetState({ getters, dispatch }) {
getters.allAvailableBotsList.forEach((e) => {
dispatch(`${e}/getState`);
});
},
allGetDaily({ getters, dispatch }, payload: DailyPayload) {
getters.allAvailableBotsList.forEach((e) => {
dispatch(`${e}/getDaily`, payload);
});
},
async forceSellMulti({ dispatch }, forcesellPayload: MultiForcesellPayload) {
return dispatch(`${forcesellPayload.botId}/${[BotStoreActions.forcesell]}`, forcesellPayload);
},
async deleteTradeMulti({ dispatch }, deletePayload: MultiDeletePayload) {
return dispatch(
`${deletePayload.botId}/${[BotStoreActions.deleteTrade]}`,
deletePayload.tradeid,
);
},
};
// Autocreate Actions from botstores
Object.keys(BotStoreActions).forEach((e) => {
actions[e] = ({ state, dispatch, getters }, ...args) => {
if (getters.hasBots) {
return dispatch(`${state.selectedBot}/${e}`, ...args);
}
console.warn(`bot ${state.selectedBot} is not registered.`);
return {};
};
});
return {
namespaced: true,
// modules: {
// 'ftbot.0': createBotSubStore('ftbot.0'),
// },
state,
mutations,
getters,
actions,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +0,0 @@
import { getPlotConfigName, getAllPlotConfigNames } from '@/shared/storage';
import {
BotState,
Trade,
PlotConfig,
StrategyResult,
BalanceInterface,
DailyReturnValue,
LockResponse,
PlotConfigStorage,
ProfitInterface,
BacktestResult,
StrategyBacktestResult,
BacktestSteps,
LogLine,
SysInfoResponse,
LoadingStatus,
BacktestHistoryEntry,
} from '@/types';
export interface FtbotStateType {
ping: string;
botStatusAvailable: boolean;
isBotOnline: boolean;
autoRefresh: boolean;
refreshing: boolean;
version: string;
lastLogs: LogLine[];
refreshRequired: boolean;
trades: Trade[];
openTrades: Trade[];
tradeCount: number;
performanceStats: Performance[];
whitelist: string[];
blacklist: string[];
profit: ProfitInterface | {};
botState?: BotState;
balance: BalanceInterface | {};
dailyStats: DailyReturnValue | {};
pairlistMethods: string[];
detailTradeId?: number;
selectedPair: string;
// TODO: type me
candleData: {};
candleDataStatus: LoadingStatus;
// TODO: type me
history: {};
historyStatus: LoadingStatus;
strategyPlotConfig?: PlotConfig;
customPlotConfig: PlotConfigStorage;
plotConfigName: string;
availablePlotConfigNames: string[];
strategyList: string[];
strategy: StrategyResult | {};
pairlist: string[];
currentLocks?: LockResponse;
backtestRunning: boolean;
backtestProgress: number;
backtestStep: BacktestSteps;
backtestTradeCount: number;
backtestResult?: BacktestResult;
selectedBacktestResultKey: string;
backtestHistory: Record<string, StrategyBacktestResult>;
backtestHistoryList: BacktestHistoryEntry[];
sysinfo: SysInfoResponse | {};
}
const state = (): FtbotStateType => {
return {
ping: '',
botStatusAvailable: false,
isBotOnline: false,
autoRefresh: false,
refreshing: false,
version: '',
lastLogs: [],
refreshRequired: true,
trades: [],
openTrades: [],
tradeCount: 0,
performanceStats: [],
whitelist: [],
blacklist: [],
profit: {},
botState: undefined,
balance: {},
dailyStats: {},
pairlistMethods: [],
detailTradeId: undefined,
selectedPair: '',
candleData: {},
candleDataStatus: 'loading',
history: {},
historyStatus: 'loading',
strategyPlotConfig: undefined,
customPlotConfig: {},
plotConfigName: getPlotConfigName(),
availablePlotConfigNames: getAllPlotConfigNames(),
strategyList: [],
strategy: {},
pairlist: [],
currentLocks: undefined,
// backtesting
backtestRunning: false,
backtestProgress: 0.0,
backtestStep: BacktestSteps.none,
backtestTradeCount: 0,
backtestResult: undefined,
selectedBacktestResultKey: '',
backtestHistory: {},
backtestHistoryList: [],
sysinfo: {},
};
};
export default state;

View File

@ -1,170 +0,0 @@
import { GridItemData } from 'vue-grid-layout';
export enum TradeLayout {
multiPane = 'g-multiPane',
openTrades = 'g-openTrades',
tradeHistory = 'g-tradeHistory',
tradeDetail = 'g-tradeDetail',
chartView = 'g-chartView',
}
export enum DashboardLayout {
dailyChart = 'g-dailyChart',
botComparison = 'g-botComparison',
allOpenTrades = 'g-allOpenTrades',
cumChartChart = 'g-cumChartChart',
tradesLogChart = 'g-TradesLogChart',
}
export enum LayoutGetters {
getDashboardLayoutSm = 'getDashboardLayoutSm',
getDashboardLayout = 'getDashboardLayout',
getTradingLayoutSm = 'getTradingLayoutSm',
getTradingLayout = 'getTradingLayout',
getLayoutLocked = 'getLayoutLocked',
}
export enum LayoutActions {
setDashboardLayout = 'setDashboardLayout',
setTradingLayout = 'setTradingLayout',
resetDashboardLayout = 'resetDashboardLayout',
resetTradingLayout = 'resetTradingLayout',
setLayoutLocked = 'setLayoutLocked',
}
export enum LayoutMutations {
setDashboardLayout = 'setDashboardLayout',
setTradingLayout = 'setTradingLayout',
setLayoutLocked = 'setLayoutLocked',
}
// Define default layouts
const DEFAULT_TRADING_LAYOUT: GridItemData[] = [
{ i: TradeLayout.multiPane, x: 0, y: 0, w: 3, h: 35 },
{ i: TradeLayout.chartView, x: 3, y: 0, w: 9, h: 14 },
{ i: TradeLayout.tradeDetail, x: 3, y: 19, w: 9, h: 6 },
{ i: TradeLayout.openTrades, x: 3, y: 14, w: 9, h: 5 },
{ i: TradeLayout.tradeHistory, x: 3, y: 25, w: 9, h: 10 },
];
// Currently only multiPane is visible
const DEFAULT_TRADING_LAYOUT_SM: GridItemData[] = [
{ i: TradeLayout.multiPane, x: 0, y: 0, w: 12, h: 10 },
{ i: TradeLayout.chartView, x: 0, y: 10, w: 12, h: 0 },
{ i: TradeLayout.tradeDetail, x: 0, y: 19, w: 12, h: 0 },
{ i: TradeLayout.openTrades, x: 0, y: 8, w: 12, h: 0 },
{ i: TradeLayout.tradeHistory, x: 0, y: 25, w: 12, h: 0 },
];
const DEFAULT_DASHBOARD_LAYOUT: GridItemData[] = [
{ i: DashboardLayout.botComparison, x: 0, y: 0, w: 8, h: 6 } /* Bot Comparison */,
{ i: DashboardLayout.dailyChart, x: 8, y: 0, w: 4, h: 6 },
{ i: DashboardLayout.allOpenTrades, x: 0, y: 6, w: 8, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 8, y: 6, w: 4, h: 6 },
{ i: DashboardLayout.tradesLogChart, x: 0, y: 12, w: 12, h: 4 },
];
const DEFAULT_DASHBOARD_LAYOUT_SM: GridItemData[] = [
{ i: DashboardLayout.botComparison, x: 0, y: 0, w: 12, h: 6 } /* Bot Comparison */,
{ i: DashboardLayout.allOpenTrades, x: 0, y: 6, w: 12, h: 8 },
{ i: DashboardLayout.dailyChart, x: 0, y: 14, w: 12, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 0, y: 20, w: 12, h: 6 },
{ i: DashboardLayout.tradesLogChart, x: 0, y: 26, w: 12, h: 4 },
];
const STORE_DASHBOARD_LAYOUT = 'ftDashboardLayout';
const STORE_TRADING_LAYOUT = 'ftTradingLayout';
const STORE_LAYOUT_LOCK = 'ftLayoutLocked';
function getLayoutLocked() {
const fromStore = localStorage.getItem(STORE_LAYOUT_LOCK);
if (fromStore) {
return JSON.parse(fromStore);
}
return true;
}
function getLayout(storageString: string, defaultLayout: GridItemData[]) {
const fromStore = localStorage.getItem(storageString);
if (fromStore) {
return JSON.parse(fromStore);
}
return JSON.parse(JSON.stringify(defaultLayout));
}
/**
* Helper function finding a layout entry
* @param gridLayout Array of grid layouts used in this layout. Must be passed to GridLayout, too.
* @param name Name within the dashboard layout to find
*/
export function findGridLayout(gridLayout: GridItemData[], name: string): GridItemData {
let layout = gridLayout.find((value) => value.i === name);
if (!layout) {
layout = { i: name, x: 0, y: 0, w: 4, h: 6 };
}
return layout;
}
export default {
namespaced: true,
state: {
dashboardLayout: getLayout(STORE_DASHBOARD_LAYOUT, DEFAULT_DASHBOARD_LAYOUT),
tradingLayout: getLayout(STORE_TRADING_LAYOUT, DEFAULT_TRADING_LAYOUT),
layoutLocked: getLayoutLocked(),
},
getters: {
[LayoutGetters.getDashboardLayoutSm]() {
return [...DEFAULT_DASHBOARD_LAYOUT_SM];
},
[LayoutGetters.getDashboardLayout](state) {
return state.dashboardLayout;
},
[LayoutGetters.getTradingLayoutSm]() {
return [...DEFAULT_TRADING_LAYOUT_SM];
},
[LayoutGetters.getTradingLayout](state) {
return state.tradingLayout;
},
[LayoutGetters.getLayoutLocked](state) {
return state.layoutLocked;
},
},
mutations: {
[LayoutMutations.setDashboardLayout](state, layout) {
state.dashboardLayout = layout;
localStorage.setItem(STORE_DASHBOARD_LAYOUT, JSON.stringify(layout));
},
[LayoutMutations.setTradingLayout](state, layout) {
state.tradingLayout = layout;
localStorage.setItem(STORE_TRADING_LAYOUT, JSON.stringify(layout));
},
[LayoutMutations.setLayoutLocked](state, locked: boolean) {
state.layoutLocked = locked;
localStorage.setItem(STORE_LAYOUT_LOCK, JSON.stringify(locked));
},
},
actions: {
[LayoutActions.setDashboardLayout]({ commit }, layout) {
commit(LayoutMutations.setDashboardLayout, layout);
},
[LayoutActions.setTradingLayout]({ commit }, layout) {
commit(LayoutMutations.setTradingLayout, layout);
},
[LayoutActions.setLayoutLocked]({ commit }, locked: boolean) {
commit(LayoutMutations.setLayoutLocked, locked);
},
[LayoutActions.resetDashboardLayout]({ commit }) {
commit(
LayoutMutations.setDashboardLayout,
JSON.parse(JSON.stringify(DEFAULT_DASHBOARD_LAYOUT)),
);
},
[LayoutActions.resetTradingLayout]({ commit }) {
commit(LayoutMutations.setTradingLayout, JSON.parse(JSON.stringify(DEFAULT_TRADING_LAYOUT)));
},
},
};

View File

@ -1,96 +0,0 @@
import { setTimezone } from '@/shared/formatters';
const STORE_UI_SETTINGS = 'ftUISettings';
export enum OpenTradeVizOptions {
showPill = 'showPill',
asTitle = 'asTitle',
noOpenTrades = 'noOpenTrades',
}
export enum SettingsGetters {
openTradesInTitle = 'openTradesInTitle',
timezone = 'timezone',
backgroundSync = 'backgroundSync',
}
export enum SettingsActions {
setOpenTradesInTitle = 'setOpenTradesInTitle',
setTimeZone = 'setTimeZone',
setBackgroundSync = 'setBackgroundSync',
}
export enum SettingsMutations {
setOpenTrades = 'setOpenTrades',
setTimeZone = 'setTimeZone',
setBackgroundSync = 'setBackgroundSync',
}
export interface SettingsType {
openTradesInTitle: string;
timezone: string;
backgroundSync: boolean;
}
function getSettings() {
const fromStore = localStorage.getItem(STORE_UI_SETTINGS);
if (fromStore) {
return JSON.parse(fromStore);
}
return {};
}
const storedSettings = getSettings();
function updateSetting(key: string, value: string | boolean) {
const settings = getSettings() || {};
settings[key] = value;
localStorage.setItem(STORE_UI_SETTINGS, JSON.stringify(settings));
}
const state: SettingsType = {
openTradesInTitle: storedSettings?.openTradesInTitle || OpenTradeVizOptions.showPill,
timezone: storedSettings.timezone || 'UTC',
backgroundSync: storedSettings.backgroundSync || true,
};
export default {
namespaced: true,
state,
getters: {
[SettingsGetters.openTradesInTitle](state): string {
return state.openTradesInTitle;
},
[SettingsGetters.timezone](state): string {
return state.timezone;
},
[SettingsGetters.backgroundSync](state): boolean {
return state.backgroundSync;
},
},
mutations: {
[SettingsMutations.setOpenTrades](state, value: string) {
state.openTradesInTitle = value;
updateSetting('openTradesInTitle', value);
},
[SettingsMutations.setTimeZone](state, timezone: string) {
state.timezone = timezone;
updateSetting('timezone', timezone);
},
[SettingsMutations.setBackgroundSync](state, backgroundSync: boolean) {
state.backgroundSync = backgroundSync;
updateSetting('backgroundSync', backgroundSync);
},
},
actions: {
[SettingsActions.setOpenTradesInTitle]({ commit }, locked: boolean) {
commit(SettingsMutations.setOpenTrades, locked);
},
[SettingsActions.setTimeZone]({ commit }, timezone: string) {
setTimezone(timezone);
commit(SettingsMutations.setTimeZone, timezone);
},
[SettingsActions.setBackgroundSync]({ commit }, timezone: string) {
commit(SettingsMutations.setBackgroundSync, timezone);
},
},
};

View File

@ -1,9 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
enum StoreModules {
ftbot = 'ftbot',
alerts = 'alerts',
layout = 'layout',
uiSettings = 'uiSettings',
}
export default StoreModules;

21
src/stores/alerts.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { AlertType } from '@/types/alertTypes';
export const useAlertsStore = defineStore('alerts', {
state: () => {
return { activeMessages: [] as AlertType[] };
},
actions: {
addAlert(message: AlertType) {
this.activeMessages.push(message);
},
removeAlert() {
this.activeMessages.shift();
},
},
});
export function showAlert(message: string, severity = '') {
const alertsStore = useAlertsStore();
alertsStore.addAlert({ message, severity });
}

811
src/stores/ftbot.ts Normal file
View File

@ -0,0 +1,811 @@
import { useApi } from '@/shared/apiService';
import {
getAllPlotConfigNames,
getPlotConfigName,
storeCustomPlotConfig,
storePlotConfigName,
} from '@/shared/storage';
import { useUserService } from '@/shared/userService';
import { parseParams } from '@/shared/apiParamParser';
import {
BotState,
Trade,
PlotConfig,
StrategyResult,
BalanceInterface,
DailyReturnValue,
LockResponse,
PlotConfigStorage,
ProfitInterface,
BacktestResult,
StrategyBacktestResult,
BacktestSteps,
LogLine,
SysInfoResponse,
LoadingStatus,
BacktestHistoryEntry,
RunModes,
EMPTY_PLOTCONFIG,
DailyPayload,
BlacklistResponse,
WhitelistResponse,
StrategyListResult,
AvailablePairPayload,
AvailablePairResult,
PairHistoryPayload,
PairCandlePayload,
StatusResponse,
ForceSellPayload,
DeleteTradeResponse,
BacktestStatus,
BacktestPayload,
BlacklistPayload,
ForceEnterPayload,
TradeResponse,
} from '@/types';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { defineStore } from 'pinia';
import { showAlert } from './alerts';
export function createBotSubStore(botId: string, botName: string) {
const userService = useUserService(botId);
const { api } = useApi(userService, botId);
const useBotStore = defineStore(botId, {
state: () => {
return {
ping: '',
botStatusAvailable: false,
isBotOnline: false,
autoRefresh: false,
refreshing: false,
versionState: '',
lastLogs: [] as LogLine[],
refreshRequired: true,
trades: [] as Trade[],
openTrades: [] as Trade[],
tradeCount: 0,
performanceStats: [] as Performance[],
whitelist: [] as string[],
blacklist: [] as string[],
profit: {} as ProfitInterface,
botState: {} as BotState,
balance: {} as BalanceInterface,
dailyStats: {} as DailyReturnValue,
pairlistMethods: [] as string[],
detailTradeId: null as number | null,
selectedPair: '',
// TODO: type me
candleData: {},
candleDataStatus: LoadingStatus.loading,
// TODO: type me
history: {},
historyStatus: LoadingStatus.loading,
strategyPlotConfig: undefined as PlotConfig | undefined,
customPlotConfig: {} as PlotConfigStorage,
plotConfigName: getPlotConfigName(),
availablePlotConfigNames: getAllPlotConfigNames(),
strategyList: [] as string[],
strategy: {} as StrategyResult,
pairlist: [] as string[],
currentLocks: undefined as LockResponse | undefined,
// backtesting
backtestRunning: false,
backtestProgress: 0.0,
backtestStep: BacktestSteps.none,
backtestTradeCount: 0,
backtestResult: undefined as BacktestResult | undefined,
selectedBacktestResultKey: '',
backtestHistory: {} as Record<string, StrategyBacktestResult>,
backtestHistoryList: [] as BacktestHistoryEntry[],
sysInfo: {} as SysInfoResponse,
};
},
getters: {
version: (state) => state.botState?.version || state.versionState,
botApiVersion: (state) => state.botState?.api_version || 1.0,
stakeCurrency: (state) => state.botState?.stake_currency || '',
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],
shortAllowed: (state) => state.botState?.short_allowed || false,
openTradeCount: (state) => state.openTrades.length,
isTrading: (state) =>
state.botState?.runmode === RunModes.LIVE || state.botState?.runmode === RunModes.DRY_RUN,
timeframe: (state) => state.botState?.timeframe || '',
closedTrades: (state) => {
return state.trades
.filter((item) => !item.is_open)
.sort((a, b) =>
// Sort by close timestamp, then by tradeid
b.close_timestamp && a.close_timestamp
? b.close_timestamp - a.close_timestamp
: b.trade_id - a.trade_id,
);
},
tradeDetail: (state) => {
let dTrade = state.openTrades.find((item) => item.trade_id === state.detailTradeId);
if (!dTrade) {
dTrade = state.trades.find((item) => item.trade_id === state.detailTradeId);
}
return dTrade;
},
plotConfig: (state) =>
state.customPlotConfig[state.plotConfigName] || { ...EMPTY_PLOTCONFIG },
refreshNow: (state) => {
if (
state.autoRefresh &&
state.isBotOnline &&
state.botStatusAvailable &&
state.botState?.runmode !== RunModes.WEBSERVER
) {
return true;
}
// TODO: This backgroundSyncCheck is still missing above
// const bgRefresh = rootGetters['uiSettings/backgroundSync'];
// const selectedBot = rootGetters[`${StoreModules.ftbot}/selectedBot`];
// if (
// (selectedBot === botId || bgRefresh) &&
// getters.autoRefresh &&
// getters.isBotOnline &&
// getters.botStatusAvailable &&
// !getters.isWebserverMode
// ) {
// return true;
// }
return false;
},
botName: (state) => state.botState?.bot_name || 'freqtrade',
allTrades: (state) => [...state.openTrades, ...state.trades] as Trade[],
activeLocks: (state) => state.currentLocks?.locks || [],
},
actions: {
botAdded() {
this.autoRefresh = userService.getAutoRefresh();
},
async fetchPing() {
try {
const result = await api.get('/ping');
const now = Date.now();
// TODO: Name collision!
this.ping = `${result.data.status} ${now.toString()}`;
this.isBotOnline = true;
return Promise.resolve();
} catch (error) {
console.log('ping fail');
this.isBotOnline = false;
return Promise.reject();
}
},
logout() {
userService.logout();
},
rename(name: string) {
userService.renameBot(name);
},
setRefreshRequired(refreshRequired: boolean) {
this.refreshRequired = refreshRequired;
},
setAutoRefresh(newRefreshValue) {
this.autoRefresh = newRefreshValue;
// TODO: Investigate this -
// this ONLY works if ReloadControl is only visible once,otherwise it triggers twice
if (newRefreshValue) {
// dispatch('startRefresh', true);
} else {
// dispatch('stopRefresh');
}
userService.setAutoRefresh(newRefreshValue);
},
setIsBotOnline(isBotOnline: boolean) {
this.isBotOnline = isBotOnline;
},
async refreshSlow(forceUpdate = false) {
if (this.refreshing && !forceUpdate) {
return;
}
// Refresh data only when needed
if (forceUpdate || this.refreshRequired) {
// TODO: Should be AxiosInstance
const updates: Promise<any>[] = [];
updates.push(this.getPerformance());
updates.push(this.getProfit());
updates.push(this.getTrades());
updates.push(this.getBalance());
// /* white/blacklist might be refreshed more often as they are not expensive on the backend */
updates.push(this.getWhitelist());
updates.push(this.getBlacklist());
await Promise.all(updates);
this.refreshRequired = false;
}
return Promise.resolve();
},
async refreshFrequent() {
// Refresh data that's needed in near realtime
await this.getOpenTrades();
await this.getState();
await this.getLocks();
},
setDetailTrade(trade: Trade | null) {
this.detailTradeId = trade?.trade_id || null;
this.selectedPair = trade ? trade.pair : this.selectedPair;
},
setSelectedPair(pair: string) {
this.selectedPair = pair;
},
saveCustomPlotConfig(plotConfig: PlotConfigStorage) {
this.customPlotConfig = plotConfig;
storeCustomPlotConfig(plotConfig);
this.availablePlotConfigNames = getAllPlotConfigNames();
},
updatePlotConfigName(plotConfigName: string) {
// Set default plot config name
this.plotConfigName = plotConfigName;
storePlotConfigName(plotConfigName);
},
setPlotConfigName(plotConfigName: string) {
// TODO: This is identical to updatePlotConfigName
this.plotConfigName = plotConfigName;
storePlotConfigName(plotConfigName);
},
async getTrades() {
try {
let totalTrades = 0;
const pageLength = 500;
const fetchTrades = async (limit: number, offset: number) => {
return api.get<TradeResponse>('/trades', {
params: { limit, offset },
});
};
const res = await fetchTrades(pageLength, 0);
const result: TradeResponse = res.data;
let { trades } = result;
if (Array.isArray(trades) && trades.length !== result.total_trades) {
// Pagination necessary
// Don't use Promise.all - this would fire all requests at once, which can
// cause problems for big sqlite databases
do {
// eslint-disable-next-line no-await-in-loop
const res = await fetchTrades(pageLength, trades.length);
const result: TradeResponse = res.data;
trades = trades.concat(result.trades);
totalTrades = res.data.total_trades;
} while (trades.length !== totalTrades);
const tradesCount = trades.length;
// Add botId to all trades
trades = trades.map((t) => ({
...t,
botId,
botName,
botTradeId: `${botId}__${t.trade_id}`,
}));
this.trades = trades;
this.tradeCount = tradesCount;
}
return Promise.resolve();
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
return Promise.reject(error);
}
},
getOpenTrades() {
return api
.get<never, AxiosResponse<Trade[]>>('/status')
.then((result) => {
// Check if trade-id's are different in this call, then trigger a full refresh
if (
Array.isArray(this.openTrades) &&
Array.isArray(result.data) &&
(this.openTrades.length !== result.data.length ||
!this.openTrades.every(
(val, index) => val.trade_id === result.data[index].trade_id,
))
) {
// Open trades changed, so we should refresh now.
this.refreshRequired = true;
// dispatch('refreshSlow', null, { root: true });
const openTrades = result.data.map((t) => ({
...t,
botId,
botName,
botTradeId: `${botId}__${t.trade_id}`,
}));
this.openTrades = openTrades;
} else {
this.openTrades = [];
}
})
.catch(console.error);
},
getLocks() {
return api
.get('/locks')
.then((result) => (this.currentLocks = result.data))
.catch(console.error);
},
async deleteLock(lockid: string) {
try {
const res = await api.delete<LockResponse>(`/locks/${lockid}`);
showAlert(`Deleted Lock ${lockid}.`);
this.currentLocks = res.data;
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert(`Failed to delete lock ${lockid}`, 'danger');
return Promise.reject(error);
}
},
getPairCandles(payload: PairCandlePayload) {
if (payload.pair && payload.timeframe) {
this.historyStatus = LoadingStatus.loading;
return api
.get('/pair_candles', {
params: { ...payload },
})
.then((result) => {
this.candleData = {
...this.candleData,
[`${payload.pair}__${payload.timeframe}`]: {
pair: payload.pair,
timeframe: payload.timeframe,
data: result.data,
},
};
this.historyStatus = LoadingStatus.success;
})
.catch((err) => {
console.error(err);
this.historyStatus = LoadingStatus.error;
});
}
// Error branchs
const error = 'pair or timeframe not specified';
console.error(error);
return new Promise((resolve, reject) => {
reject(error);
});
},
getPairHistory(payload: PairHistoryPayload) {
if (payload.pair && payload.timeframe && payload.timerange) {
this.historyStatus = LoadingStatus.loading;
return 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;
});
}
// Error branchs
const error = 'pair or timeframe or timerange not specified';
console.error(error);
return new Promise((resolve, reject) => {
reject(error);
});
},
async getStrategyPlotConfig() {
try {
const result = await api.get<PlotConfig>('/plot_config');
const plotConfig = result.data;
if (plotConfig.subplots === null) {
// Subplots should not be null but an empty object
// TODO: Remove this fix when fix in freqtrade is populated further.
plotConfig.subplots = {};
}
this.strategyPlotConfig = result.data;
return Promise.resolve();
} catch (data) {
console.error(data);
return Promise.reject(data);
}
},
getStrategyList() {
return api
.get<StrategyListResult>('/strategies')
.then((result) => (this.strategyList = result.data.strategies))
.catch(console.error);
},
async getStrategy(strategy: string) {
try {
const result = await api.get<StrategyResult>(`/strategy/${strategy}`, {});
this.strategy = result.data;
return Promise.resolve(result.data);
} catch (error) {
console.error(error);
return Promise.reject(error);
}
},
async getAvailablePairs(payload: AvailablePairPayload) {
try {
const result = await api.get<AvailablePairResult>('/available_pairs', {
params: { ...payload },
});
// result is of type AvailablePairResult
const { pairs } = result.data;
this.pairlist = pairs;
return Promise.resolve(result.data);
} catch (error) {
console.error(error);
return Promise.reject(error);
}
},
async getPerformance() {
try {
const result = await api.get<Performance[]>('/performance');
this.performanceStats = result.data;
return Promise.resolve(result.data);
} catch (error) {
console.error(error);
return Promise.reject(error);
}
},
getWhitelist() {
return api
.get<WhitelistResponse>('/whitelist')
.then((result) => {
this.whitelist = result.data.whitelist;
this.pairlistMethods = result.data.method;
return Promise.resolve(result.data);
})
.catch((error) => {
// console.error(error);
return Promise.reject(error);
});
},
getBlacklist() {
return api
.get<BlacklistResponse>('/blacklist')
.then((result) => (this.blacklist = result.data.blacklist))
.catch(console.error);
},
getProfit() {
return api
.get('/profit')
.then((result) => (this.profit = result.data))
.catch(console.error);
},
async getBalance() {
try {
const result = await api.get('/balance');
this.balance = result.data;
return Promise.resolve(result.data);
} catch (error) {
return Promise.reject(error);
}
},
async getDaily(payload: DailyPayload = {}) {
const { timescale = 20 } = payload;
try {
const { data } = await api.get<DailyReturnValue>('/daily', { params: { timescale } });
this.dailyStats = data;
return Promise.resolve(data);
} catch (error) {
return Promise.reject(error);
}
},
getState() {
return api
.get('/show_config')
.then((result) => {
this.botState = result.data;
this.botStatusAvailable = true;
})
.catch(console.error);
},
getLogs() {
return api
.get('/logs')
.then((result) => (this.lastLogs = result.data.logs))
.catch(console.error);
},
// // Post methods
// // TODO: Migrate calls to API to a seperate module unrelated to pinia?
async startBot() {
try {
const res = await api.post<{}, AxiosResponse<StatusResponse>>('/start', {});
console.log(res.data);
showAlert(res.data.status);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert('Error starting bot.');
return Promise.reject(error);
}
},
async stopBot() {
try {
const res = await api.post<{}, AxiosResponse<StatusResponse>>('/stop', {});
showAlert(res.data.status);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert('Error stopping bot.');
return Promise.reject(error);
}
},
async stopBuy() {
try {
const res = await api.post<{}, AxiosResponse<StatusResponse>>('/stopbuy', {});
showAlert(res.data.status);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert('Error calling stopbuy.');
return Promise.reject(error);
}
},
async reloadConfig() {
try {
const res = await api.post<{}, AxiosResponse<StatusResponse>>('/reload_config', {});
console.log(res.data);
showAlert(res.data.status);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert('Error reloading.');
return Promise.reject(error);
}
},
async deleteTrade(tradeid: string) {
try {
const res = await api.delete<DeleteTradeResponse>(`/trades/${tradeid}`);
showAlert(res.data.result_msg ? res.data.result_msg : `Deleted Trade ${tradeid}`);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert(`Failed to delete trade ${tradeid}`, 'danger');
return Promise.reject(error);
}
},
async startTrade() {
try {
const res = await api.post('/start_trade', {});
return Promise.resolve(res);
} catch (error) {
return Promise.reject(error);
}
},
async forceexit(payload: ForceSellPayload) {
try {
const res = await api.post<ForceSellPayload, AxiosResponse<StatusResponse>>(
'/forcesell',
payload,
);
showAlert(`Sell order for ${payload.tradeid} created`);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
}
showAlert(`Failed to create sell order for ${payload.tradeid}`, 'danger');
return Promise.reject(error);
}
},
async forcebuy(payload: ForceEnterPayload) {
if (payload && payload.pair) {
try {
// TODO: Update forcebuy to forceenter ...
const res = await api.post<
ForceEnterPayload,
AxiosResponse<StatusResponse | TradeResponse>
>('/forcebuy', payload);
showAlert(`Order for ${payload.pair} created.`);
return Promise.resolve(res);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
showAlert(
`Error occured entering: '${(error as any).response?.data?.error}'`,
'danger',
);
}
return Promise.reject(error);
}
}
// Error branchs
const error = 'Pair is empty';
console.error(error);
return Promise.reject(error);
},
async addBlacklist(payload: BlacklistPayload) {
console.log(`Adding ${payload} to blacklist`);
if (payload && payload.blacklist) {
try {
const result = await api.post<BlacklistPayload, AxiosResponse<BlacklistResponse>>(
'/blacklist',
payload,
);
this.blacklist = result.data.blacklist;
if (result.data.errors && Object.keys(result.data.errors).length !== 0) {
const { errors } = result.data;
Object.keys(errors).forEach((pair) => {
showAlert(
`Error while adding pair ${pair} to Blacklist: ${errors[pair].error_msg}`,
);
});
} else {
showAlert(`Pair ${payload.blacklist} added.`);
}
return Promise.resolve(result.data);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
showAlert(
`Error occured while adding pairs to Blacklist: '${
(error as any).response?.data?.error
}'`,
'danger',
);
}
return Promise.reject(error);
}
}
// Error branchs
const error = 'Pair is empty';
console.error(error);
return Promise.reject(error);
},
async deleteBlacklist(blacklistPairs: Array<string>) {
console.log(`Deleting ${blacklistPairs} from blacklist.`);
if (blacklistPairs) {
try {
const result = await api.delete<BlacklistPayload, AxiosResponse<BlacklistResponse>>(
'/blacklist',
{
params: {
// eslint-disable-next-line @typescript-eslint/camelcase
pairs_to_delete: blacklistPairs,
},
paramsSerializer: (params) => parseParams(params),
},
);
this.blacklist = result.data.blacklist;
if (result.data.errors && Object.keys(result.data.errors).length !== 0) {
const { errors } = result.data;
Object.keys(errors).forEach((pair) => {
showAlert(
`Error while removing pair ${pair} from Blacklist: ${errors[pair].error_msg}`,
);
});
} else {
showAlert(`Pair ${blacklistPairs} removed.`);
}
return Promise.resolve(result.data);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
showAlert(
`Error occured while removing pairs from Blacklist: '${
(error as any).response?.data?.error
}'`,
'danger',
);
}
return Promise.reject(error);
}
}
// Error branchs
const error = 'Pair is empty';
console.error(error);
return Promise.reject(error);
},
async startBacktest(payload: BacktestPayload) {
try {
const result = await api.post<BacktestPayload, AxiosResponse<BacktestStatus>>(
'/backtest',
payload,
);
this.updateBacktestRunning(result.data);
} catch (err) {
console.log(err);
}
},
async pollBacktest() {
const result = await api.get<BacktestStatus>('/backtest');
this.updateBacktestRunning(result.data);
if (result.data.running === false && result.data.backtest_result) {
this.updateBacktestResult(result.data.backtest_result);
}
},
async removeBacktest() {
this.backtestHistory = {};
try {
const { data } = await api.delete<BacktestStatus>('/backtest');
this.updateBacktestRunning(data);
return Promise.resolve(data);
} catch (err) {
return Promise.reject(err);
}
},
updateBacktestRunning(backtestStatus: BacktestStatus) {
this.backtestRunning = backtestStatus.running;
this.backtestProgress = backtestStatus.progress;
this.backtestStep = backtestStatus.step;
this.backtestTradeCount = backtestStatus.trade_count || 0;
},
async stopBacktest() {
try {
const { data } = await api.get<BacktestStatus>('/backtest/abort');
this.updateBacktestRunning(data);
return Promise.resolve(data);
} catch (err) {
return Promise.reject(err);
}
},
async getBacktestHistory() {
const result = await api.get<BacktestHistoryEntry[]>('/backtest/history');
this.backtestHistoryList = result.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 stratKey = `${key}_${strat.total_trades}_${strat.profit_total.toFixed(3)}`;
// this.backtestHistory[stratKey] = strat;
this.backtestHistory = { ...this.backtestHistory, ...{ [stratKey]: strat } };
this.selectedBacktestResultKey = stratKey;
});
},
async getBacktestHistoryResult(payload: BacktestHistoryEntry) {
const result = await api.get<BacktestStatus>('/backtest/history/result', {
params: { filename: payload.filename, strategy: payload.strategy },
});
if (result.data.backtest_result) {
this.updateBacktestResult(result.data.backtest_result);
}
},
setBacktestResultKey(key: string) {
this.selectedBacktestResultKey = key;
},
async getSysInfo() {
try {
const { data } = await api.get<SysInfoResponse>('/sysinfo');
this.sysInfo = data;
return Promise.resolve(data);
} catch (err) {
return Promise.reject(err);
}
},
},
});
return useBotStore();
}

317
src/stores/ftbotwrapper.ts Normal file
View File

@ -0,0 +1,317 @@
import { UserService } from '@/shared/userService';
import {
BalanceInterface,
BotDescriptor,
BotDescriptors,
BotState,
DailyPayload,
DailyRecord,
DailyReturnValue,
MultiDeletePayload,
MultiForcesellPayload,
ProfitInterface,
RenameBotPayload,
Trade,
} from '@/types';
import { AxiosInstance } from 'axios';
import { defineStore } from 'pinia';
import { createBotSubStore } from './ftbot';
const AUTH_SELECTED_BOT = 'ftSelectedBot';
export type BotSubStore = ReturnType<typeof createBotSubStore>;
export interface SubStores {
[key: string]: BotSubStore;
}
export const useBotStore = defineStore('wrapper', {
state: () => {
return {
selectedBot: '',
availableBots: {} as BotDescriptors,
globalAutoRefresh: true,
refreshing: false,
refreshInterval: null as number | null,
refreshIntervalSlow: null as number | null,
botStores: {} as SubStores,
};
},
getters: {
hasBots: (state) => Object.keys(state.availableBots).length > 0,
botCount: (state) => Object.keys(state.availableBots).length,
allBotStores: (state) => Object.values(state.botStores),
activeBot: (state) => state.botStores[state.selectedBot] as BotSubStore,
activeBotorUndefined: (state) => state.botStores[state.selectedBot] as BotSubStore | undefined,
canRunBacktest: (state) => state.botStores[state.selectedBot]?.canRunBacktest ?? false,
selectedBotObj: (state) => state.availableBots[state.selectedBot],
nextBotId: (state) => {
let botCount = Object.keys(state.availableBots).length;
while (`ftbot.${botCount}` in state.availableBots) {
botCount += 1;
}
return `ftbot.${botCount}`;
},
allProfit: (state): Record<string, ProfitInterface> => {
const result: Record<string, ProfitInterface> = {};
Object.entries(state.botStores).forEach(([k, botStore]) => {
result[k] = botStore.profit;
});
return result;
},
allOpenTradeCount: (state): Record<string, number> => {
const result: Record<string, number> = {};
Object.entries(state.botStores).forEach(([k, botStore]) => {
result[k] = botStore.openTradeCount;
});
return result;
},
allOpenTrades: (state): Record<string, Trade[]> => {
const result: Record<string, Trade[]> = {};
Object.entries(state.botStores).forEach(([k, botStore]) => {
result[k] = botStore.openTrades;
});
return result;
},
allBalance: (state): Record<string, BalanceInterface> => {
const result: Record<string, BalanceInterface> = {};
Object.entries(state.botStores).forEach(([k, botStore]) => {
result[k] = botStore.balance;
});
return result;
},
allBotState: (state): Record<string, BotState> => {
const result: Record<string, BotState> = {};
Object.entries(state.botStores).forEach(([k, botStore]) => {
result[k] = botStore.botState;
});
return result;
},
allOpenTradesAllBots: (state): Trade[] => {
const result: Trade[] = [];
Object.entries(state.botStores).forEach(([, botStore]) => {
result.push(...botStore.openTrades);
});
return result;
},
allTradesAllBots: (state): Trade[] => {
const result: Trade[] = [];
Object.entries(state.botStores).forEach(([, botStore]) => {
result.push(...botStore.trades);
});
return result;
},
allDailyStatsAllBots: (state): DailyReturnValue => {
// Return aggregated daily stats for all bots - sorted ascending.
const resp: Record<string, DailyRecord> = {};
Object.entries(state.botStores).forEach(([, botStore]) => {
botStore.dailyStats?.data?.forEach((d) => {
if (!resp[d.date]) {
resp[d.date] = { ...d };
} else {
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].abs_profit += d.abs_profit;
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].fiat_value += d.fiat_value;
// eslint-disable-next-line @typescript-eslint/camelcase
resp[d.date].trade_count += d.trade_count;
}
});
});
const dailyReturn: DailyReturnValue = {
// eslint-disable-next-line @typescript-eslint/camelcase
stake_currency: 'USDT',
// eslint-disable-next-line @typescript-eslint/camelcase
fiat_display_currency: 'USD',
data: Object.values(resp).sort((a, b) => (a.date > b.date ? 1 : -1)),
};
return dailyReturn;
},
},
actions: {
selectBot(botId: string) {
if (botId in this.availableBots) {
localStorage.setItem(AUTH_SELECTED_BOT, botId);
this.selectedBot = botId;
} else {
console.warn(`Botid ${botId} not available, but selected.`);
}
},
addBot(bot: BotDescriptor) {
if (Object.keys(this.availableBots).includes(bot.botId)) {
// throw 'Bot already present';
// TODO: handle error!
console.log('Bot already present');
return;
}
console.log('add bot', bot);
const botStore = createBotSubStore(bot.botId, bot.botName);
botStore.botAdded();
this.botStores[bot.botId] = botStore;
this.availableBots[bot.botId] = bot;
this.botStores = { ...this.botStores };
this.availableBots = { ...this.availableBots };
},
renameBot(bot: RenameBotPayload) {
if (!Object.keys(this.availableBots).includes(bot.botId)) {
// TODO: handle error!
console.error('Bot not found');
return;
}
this.botStores[bot.botId].rename(bot.botName);
this.availableBots[bot.botId].botName = bot.botName;
},
removeBot(botId: string) {
if (Object.keys(this.availableBots).includes(botId)) {
this.botStores[botId].logout();
this.botStores[botId].$dispose();
delete this.botStores[botId];
delete this.availableBots[botId];
this.botStores = { ...this.botStores };
this.availableBots = { ...this.availableBots };
// commit('removeBot', botId);
} else {
console.warn(`bot ${botId} not found! could not remove`);
}
},
selectFirstBot() {
if (this.hasBots) {
const selBotId = localStorage.getItem(AUTH_SELECTED_BOT);
const firstBot = Object.keys(this.availableBots)[0];
let selBot: string | undefined = firstBot;
if (selBotId) {
selBot = Object.keys(this.availableBots).find((x) => x === selBotId);
}
this.selectBot(this.availableBots[selBot || firstBot].botId);
}
},
setGlobalAutoRefresh(value: boolean) {
// TODO: could be removed.
this.globalAutoRefresh = value;
},
async allRefreshFrequent(forceUpdate = false) {
const updates: Promise<any>[] = [];
this.allBotStores.forEach(async (e) => {
if (e.refreshNow && (this.globalAutoRefresh || forceUpdate)) {
updates.push(e.refreshFrequent());
}
});
await Promise.all(updates);
return Promise.resolve();
},
async allRefreshSlow(forceUpdate = false) {
this.allBotStores.forEach(async (e) => {
if (e.refreshNow && (this.globalAutoRefresh || forceUpdate)) {
await e.refreshSlow(forceUpdate);
}
});
},
async allRefreshFull() {
if (this.refreshing) {
return;
}
this.refreshing = true;
try {
// Ensure all bots status is correct.
await this.pingAll();
const botStoreUpdates: Promise<any>[] = [];
this.allBotStores.forEach((e) => {
if (e.isBotOnline && !e.botStatusAvailable) {
botStoreUpdates.push(e.getState());
}
});
await Promise.all(botStoreUpdates);
const updates: Promise<any>[] = [];
updates.push(this.allRefreshFrequent(false));
updates.push(this.allRefreshSlow(true));
// updates.push(this.getDaily());
// updates.push(this.getBalance());
await Promise.all(updates);
console.log('refreshing_end');
} finally {
this.refreshing = false;
}
},
startRefresh() {
console.log('Starting automatic refresh.');
this.allRefreshFull();
if (!this.refreshInterval) {
// Set interval for refresh
const refreshInterval = window.setInterval(() => {
this.allRefreshFrequent();
}, 5000);
this.refreshInterval = refreshInterval;
}
if (!this.refreshIntervalSlow) {
const refreshIntervalSlow = window.setInterval(() => {
this.allRefreshSlow(false);
}, 60000);
this.refreshIntervalSlow = refreshIntervalSlow;
}
},
stopRefresh() {
console.log('Stopping automatic refresh.');
if (this.refreshInterval) {
window.clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.refreshIntervalSlow) {
window.clearInterval(this.refreshIntervalSlow);
this.refreshIntervalSlow = null;
}
},
async pingAll() {
await Promise.all(
Object.entries(this.botStores).map(async ([_, v]) => {
try {
await v.fetchPing();
} catch {
// pass
}
}),
);
},
allGetState() {
Object.entries(this.botStores).map(async ([_, v]) => {
try {
await v.getState();
} catch {
// pass
}
});
},
async allGetDaily(payload: DailyPayload) {
const updates: Promise<any>[] = [];
this.allBotStores.forEach((e) => {
if (e.isBotOnline) {
updates.push(e.getDaily(payload));
}
});
await Promise.all(updates);
},
async forceSellMulti(forcesellPayload: MultiForcesellPayload) {
return this.botStores[forcesellPayload.botId].forceexit(forcesellPayload);
},
async deleteTradeMulti(deletePayload: MultiDeletePayload) {
return this.botStores[deletePayload.botId].deleteTrade(deletePayload.tradeid);
},
},
});
export function initBots() {
UserService.migrateLogin();
const botStore = useBotStore();
// This might need to be moved to the parent (?)
Object.entries(UserService.getAvailableBots()).forEach(([, v]) => {
botStore.addBot(v);
});
botStore.selectFirstBot();
}

125
src/stores/layout.ts Normal file
View File

@ -0,0 +1,125 @@
import { defineStore } from 'pinia';
import { GridItemData } from 'vue-grid-layout';
export enum TradeLayout {
multiPane = 'g-multiPane',
openTrades = 'g-openTrades',
tradeHistory = 'g-tradeHistory',
tradeDetail = 'g-tradeDetail',
chartView = 'g-chartView',
}
export enum DashboardLayout {
dailyChart = 'g-dailyChart',
botComparison = 'g-botComparison',
allOpenTrades = 'g-allOpenTrades',
cumChartChart = 'g-cumChartChart',
tradesLogChart = 'g-TradesLogChart',
}
// Define default layouts
const DEFAULT_TRADING_LAYOUT: GridItemData[] = [
{ i: TradeLayout.multiPane, x: 0, y: 0, w: 3, h: 35 },
{ i: TradeLayout.chartView, x: 3, y: 0, w: 9, h: 14 },
{ i: TradeLayout.tradeDetail, x: 3, y: 19, w: 9, h: 6 },
{ i: TradeLayout.openTrades, x: 3, y: 14, w: 9, h: 5 },
{ i: TradeLayout.tradeHistory, x: 3, y: 25, w: 9, h: 10 },
];
// Currently only multiPane is visible
const DEFAULT_TRADING_LAYOUT_SM: GridItemData[] = [
{ i: TradeLayout.multiPane, x: 0, y: 0, w: 12, h: 10 },
{ i: TradeLayout.chartView, x: 0, y: 10, w: 12, h: 0 },
{ i: TradeLayout.tradeDetail, x: 0, y: 19, w: 12, h: 0 },
{ i: TradeLayout.openTrades, x: 0, y: 8, w: 12, h: 0 },
{ i: TradeLayout.tradeHistory, x: 0, y: 25, w: 12, h: 0 },
];
const DEFAULT_DASHBOARD_LAYOUT: GridItemData[] = [
{ i: DashboardLayout.botComparison, x: 0, y: 0, w: 8, h: 6 } /* Bot Comparison */,
{ i: DashboardLayout.dailyChart, x: 8, y: 0, w: 4, h: 6 },
{ i: DashboardLayout.allOpenTrades, x: 0, y: 6, w: 8, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 8, y: 6, w: 4, h: 6 },
{ i: DashboardLayout.tradesLogChart, x: 0, y: 12, w: 12, h: 4 },
];
const DEFAULT_DASHBOARD_LAYOUT_SM: GridItemData[] = [
{ i: DashboardLayout.botComparison, x: 0, y: 0, w: 12, h: 6 } /* Bot Comparison */,
{ i: DashboardLayout.allOpenTrades, x: 0, y: 6, w: 12, h: 8 },
{ i: DashboardLayout.dailyChart, x: 0, y: 14, w: 12, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 0, y: 20, w: 12, h: 6 },
{ i: DashboardLayout.tradesLogChart, x: 0, y: 26, w: 12, h: 4 },
];
const STORE_LAYOUTS = 'ftLayoutSettings';
function migrateLayoutSettings() {
const STORE_DASHBOARD_LAYOUT = 'ftDashboardLayout';
const STORE_TRADING_LAYOUT = 'ftTradingLayout';
const STORE_LAYOUT_LOCK = 'ftLayoutLocked';
// If new does not exist
if (localStorage.getItem(STORE_LAYOUTS) === null) {
console.log('Migrating dashboard settings');
const layoutLocked = localStorage.getItem(STORE_LAYOUT_LOCK);
const tradingLayout = localStorage.getItem(STORE_TRADING_LAYOUT);
const dashboardLayout = localStorage.getItem(STORE_DASHBOARD_LAYOUT);
const res = {
dashboardLayout,
tradingLayout,
layoutLocked,
};
localStorage.setItem(STORE_LAYOUTS, JSON.stringify(res));
}
localStorage.removeItem(STORE_LAYOUT_LOCK);
localStorage.removeItem(STORE_TRADING_LAYOUT);
localStorage.removeItem(STORE_DASHBOARD_LAYOUT);
}
migrateLayoutSettings();
/**
* Helper function finding a layout entry
* @param gridLayout Array of grid layouts used in this layout. Must be passed to GridLayout, too.
* @param name Name within the dashboard layout to find
*/
export function findGridLayout(gridLayout: GridItemData[], name: string): GridItemData {
let layout = gridLayout.find((value) => value.i === name);
if (!layout) {
layout = { i: name, x: 0, y: 0, w: 4, h: 6 };
}
return layout;
}
export const useLayoutStore = defineStore('layoutStore', {
state: () => {
return {
dashboardLayout: JSON.parse(JSON.stringify(DEFAULT_DASHBOARD_LAYOUT)),
tradingLayout: JSON.parse(JSON.stringify(DEFAULT_TRADING_LAYOUT)),
layoutLocked: true,
};
},
getters: {
getDashboardLayoutSm: () => [...DEFAULT_DASHBOARD_LAYOUT_SM],
getTradingLayoutSm: () => [...DEFAULT_TRADING_LAYOUT_SM],
},
actions: {
resetTradingLayout() {
this.tradingLayout = JSON.parse(JSON.stringify(DEFAULT_TRADING_LAYOUT));
},
resetDashboardLayout() {
this.dashboardLayout = JSON.parse(JSON.stringify(DEFAULT_DASHBOARD_LAYOUT));
},
},
persist: {
key: STORE_LAYOUTS,
afterRestore: (context) => {
console.log('after restore - ', context.store);
if (context.store.dashboardLayout === null) {
context.store.dashboardLayout = JSON.parse(JSON.stringify(DEFAULT_DASHBOARD_LAYOUT));
}
if (context.store.tradingLayout === null) {
context.store.tradingLayout = JSON.parse(JSON.stringify(DEFAULT_TRADING_LAYOUT));
}
},
},
});

73
src/stores/settings.ts Normal file
View File

@ -0,0 +1,73 @@
import { defineStore } from 'pinia';
import { setTimezone } from '@/shared/formatters';
import { getCurrentTheme, getTheme } from '@/shared/themes';
import axios from 'axios';
import { UiVersion } from '@/types';
const STORE_UI_SETTINGS = 'ftUISettings';
export enum OpenTradeVizOptions {
showPill = 'showPill',
asTitle = 'asTitle',
noOpenTrades = 'noOpenTrades',
}
export interface SettingsType {
openTradesInTitle?: string;
timezone?: string;
backgroundSync?: boolean;
}
export const useSettingsStore = defineStore('uiSettings', {
// other options...
state: () => {
return {
openTradesInTitle: OpenTradeVizOptions.showPill as string,
timezone: 'UTC',
backgroundSync: true,
// TODO: needs proper migration ...
currentTheme: getCurrentTheme(),
uiVersion: 'dev',
};
},
getters: {
isDarkTheme(state) {
const theme = getTheme(state.currentTheme);
if (theme) {
return theme.dark;
}
return true;
},
chartTheme(): string {
return this.isDarkTheme ? 'dark' : 'light';
},
},
actions: {
setOpenTradesInTitle(locked: string) {
this.openTradesInTitle = locked;
},
setTimeZone(timezone: string) {
setTimezone(timezone);
this.timezone = timezone;
},
setBackgroundSync(value: boolean) {
this.backgroundSync = value;
},
async loadUIVersion() {
if (import.meta.env.PROD) {
try {
const result = await axios.get<UiVersion>('/ui_version');
const { version } = result.data;
this.uiVersion = version;
// commit('setUIVersion', version);
} catch (error) {
//
}
}
},
},
persist: {
key: STORE_UI_SETTINGS,
},
});

View File

@ -258,4 +258,8 @@ export interface UiVersion {
version: string;
}
export type LoadingStatus = 'loading' | 'success' | 'error';
export enum LoadingStatus {
loading,
success,
error,
}

View File

@ -2,21 +2,23 @@
<div class="container-fluid" style="max-height: calc(100vh - 60px)">
<div class="container-fluid">
<div class="row mb-2"></div>
<p v-if="!canRunBacktest">Bot must be in webserver mode to enable Backtesting.</p>
<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"
:disabled="canRunBacktest"
:disabled="botStore.activeBot.canRunBacktest"
>
<b-form-radio
v-if="botApiVersion >= 2.15"
v-if="botStore.activeBot.botApiVersion >= 2.15"
v-model="btFormMode"
name="bt-form-radios"
button
class="mx-1 flex-samesize-items"
value="historicResults"
:disabled="!canRunBacktest"
:disabled="!botStore.activeBot.canRunBacktest"
>Load Results</b-form-radio
>
<b-form-radio
@ -25,7 +27,7 @@
button
class="mx-1 flex-samesize-items"
value="run"
:disabled="!canRunBacktest"
:disabled="!botStore.activeBot.canRunBacktest"
>Run backtest</b-form-radio
>
<b-form-radio
@ -57,8 +59,11 @@
>Visualize result</b-form-radio
>
</div>
<small v-show="backtestRunning" class="text-right bt-running-label col-8 col-lg-3"
>Backtest running: {{ backtestStep }} {{ formatPercent(backtestProgress, 2) }}</small
<small
v-show="botStore.activeBot.backtestRunning"
class="text-right bt-running-label col-8 col-lg-3"
>Backtest running: {{ botStore.activeBot.backtestStep }}
{{ formatPercent(botStore.activeBot.backtestProgress, 2) }}</small
>
</div>
</div>
@ -79,8 +84,8 @@
<transition name="fade" mode="in-out">
<BacktestResultSelect
v-if="btFormMode !== 'visualize' && showLeftBar"
:backtest-history="backtestHistory"
:selected-backtest-result-key="selectedBacktestResultKey"
:backtest-history="botStore.activeBot.backtestHistory"
:selected-backtest-result-key="botStore.activeBot.selectedBacktestResultKey"
@selectionChange="setBacktestResult"
/>
</transition>
@ -97,7 +102,7 @@
<span>Strategy</span>
<StrategySelect v-model="strategy"></StrategySelect>
</div>
<b-card bg-variant="light" :disabled="backtestRunning">
<b-card bg-variant="light" :disabled="botStore.activeBot.backtestRunning">
<!-- Backtesting parameters -->
<b-form-group
label-cols-lg="2"
@ -211,7 +216,7 @@
<b-button
id="start-backtest"
variant="primary"
:disabled="backtestRunning || !canRunBacktest"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
class="mx-1"
@click="clickBacktest"
>
@ -219,31 +224,31 @@
</b-button>
<b-button
variant="primary"
:disabled="backtestRunning || !canRunBacktest"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
class="mx-1"
@click="pollBacktest"
@click="botStore.activeBot.pollBacktest"
>
Load backtest result
</b-button>
<b-button
variant="primary"
class="mx-1"
:disabled="!backtestRunning"
@click="stopBacktest"
:disabled="!botStore.activeBot.backtestRunning"
@click="botStore.activeBot.stopBacktest"
>Stop Backtest</b-button
>
<b-button
variant="primary"
class="mx-1"
:disabled="backtestRunning || !canRunBacktest"
@click="removeBacktest"
:disabled="botStore.activeBot.backtestRunning || !botStore.activeBot.canRunBacktest"
@click="botStore.activeBot.removeBacktest"
>Reset Backtest</b-button
>
</div>
</div>
<BacktestResultView
v-if="hasBacktestResult && btFormMode == 'results'"
:backtest-result="selectedBacktestResult"
:backtest-result="botStore.activeBot.selectedBacktestResult"
class="flex-fill"
/>
@ -251,9 +256,12 @@
v-if="hasBacktestResult && btFormMode == 'visualize-summary'"
class="text-center flex-fill mt-2 d-flex flex-column"
>
<TradesLogChart :trades="selectedBacktestResult.trades" class="trades-log" />
<TradesLogChart
:trades="botStore.activeBot.selectedBacktestResult.trades"
class="trades-log"
/>
<CumProfitChart
:trades="selectedBacktestResult.trades"
:trades="botStore.activeBot.selectedBacktestResult.trades"
profit-column="profit_abs"
class="cum-profit"
:show-title="true"
@ -272,19 +280,19 @@
<PairSummary
class="col-md-2 overflow-auto"
style="max-height: calc(100vh - 200px)"
:pairlist="selectedBacktestResult.pairlist"
:trades="selectedBacktestResult.trades"
:pairlist="botStore.activeBot.selectedBacktestResult.pairlist"
:trades="botStore.activeBot.selectedBacktestResult.trades"
sort-method="profit"
:backtest-mode="true"
/>
<CandleChartContainer
:available-pairs="selectedBacktestResult.pairlist"
:available-pairs="botStore.activeBot.selectedBacktestResult.pairlist"
:historic-view="!!true"
:timeframe="timeframe"
:plot-config="selectedPlotConfig"
:timerange="timerange"
:strategy="strategy"
:trades="selectedBacktestResult.trades"
:trades="botStore.activeBot.selectedBacktestResult.trades"
class="col-md-10 candle-chart-container px-0 w-100 h-100"
>
</CandleChartContainer>
@ -292,9 +300,9 @@
<b-card header="Single trades" class="row mt-2 w-100">
<TradeList
class="row trade-history mt-2 w-100"
:trades="selectedBacktestResult.trades"
:trades="botStore.activeBot.selectedBacktestResult.trades"
:show-filter="true"
:stake-currency="selectedBacktestResult.stake_currency"
:stake-currency="botStore.activeBot.selectedBacktestResult.stake_currency"
/>
</b-card>
</div>
@ -302,14 +310,11 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import TimeRangeSelect from '@/components/ftbot/TimeRangeSelect.vue';
import BacktestResultView from '@/components/ftbot/BacktestResultView.vue';
import BacktestResultSelect from '@/components/ftbot/BacktestResultSelect.vue';
import CandleChartContainer from '@/components/charts/CandleChartContainer.vue';
import StrategySelect from '@/components/ftbot/StrategySelect.vue';
import ValuePair from '@/components/general/ValuePair.vue';
import CumProfitChart from '@/components/charts/CumProfitChart.vue';
import TradesLogChart from '@/components/charts/TradesLog.vue';
import PairSummary from '@/components/ftbot/PairSummary.vue';
@ -317,23 +322,15 @@ import TimeframeSelect from '@/components/ftbot/TimeframeSelect.vue';
import TradeList from '@/components/ftbot/TradeList.vue';
import BacktestHistoryLoad from '@/components/ftbot/BacktestHistoryLoad.vue';
import {
BacktestPayload,
BacktestSteps,
BotState,
PairHistory,
PairHistoryPayload,
PlotConfig,
StrategyBacktestResult,
} from '@/types';
import { BacktestPayload, PlotConfig } from '@/types';
import { getCustomPlotConfig, getPlotConfigName } from '@/shared/storage';
import { formatPercent } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, computed, ref, onMounted, watch } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'Backtesting',
components: {
BacktestResultView,
BacktestResultSelect,
@ -343,154 +340,125 @@ const ftbot = namespace(StoreModules.ftbot);
CumProfitChart,
TradesLogChart,
StrategySelect,
ValuePair,
PairSummary,
TimeframeSelect,
TradeList,
},
})
export default class Backtesting extends Vue {
pollInterval: number | null = null;
setup() {
const botStore = useBotStore();
showLeftBar = false;
selectedTimeframe = '';
selectedDetailTimeframe = '';
strategy = '';
timerange = '';
enableProtections = false;
maxOpenTrades = '';
stakeAmountUnlimited = false;
stakeAmount = '';
startingCapital = '';
btFormMode = 'run';
selectedPlotConfig: PlotConfig = getCustomPlotConfig(getPlotConfigName());
@ftbot.Getter [BotStoreGetters.backtestRunning]!: boolean;
@ftbot.Getter [BotStoreGetters.backtestStep]!: BacktestSteps;
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
@ftbot.Getter [BotStoreGetters.botApiVersion]: number;
@ftbot.Getter [BotStoreGetters.backtestProgress]!: number;
@ftbot.Getter [BotStoreGetters.backtestHistory]!: StrategyBacktestResult[];
@ftbot.Getter [BotStoreGetters.selectedBacktestResultKey]!: string;
@ftbot.Getter [BotStoreGetters.history]!: PairHistory;
@ftbot.Getter [BotStoreGetters.selectedBacktestResult]!: StrategyBacktestResult;
@ftbot.Getter [BotStoreGetters.canRunBacktest]!: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action getPairHistory!: (payload: PairHistoryPayload) => void;
@ftbot.Action getState;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action startBacktest!: (payload: BacktestPayload) => void;
@ftbot.Action pollBacktest!: () => void;
@ftbot.Action removeBacktest!: () => void;
@ftbot.Action stopBacktest!: () => void;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action setBacktestResultKey!: (key: string) => void;
formatPercent = formatPercent;
get hasBacktestResult() {
return this.backtestHistory ? Object.keys(this.backtestHistory).length !== 0 : false;
}
get timeframe(): string {
try {
return this.selectedBacktestResult.timeframe;
} catch (err) {
return '';
}
}
mounted() {
this.getState();
}
setBacktestResult(key: string) {
this.setBacktestResultKey(key);
// Set parameters for this result
this.strategy = this.selectedBacktestResult.strategy_name;
this.selectedTimeframe = this.selectedBacktestResult.timeframe;
this.selectedDetailTimeframe = this.selectedBacktestResult.timeframe_detail || '';
this.timerange = this.selectedBacktestResult.timerange;
}
clickBacktest() {
const btPayload: BacktestPayload = {
strategy: this.strategy,
timerange: this.timerange,
// eslint-disable-next-line @typescript-eslint/camelcase
enable_protections: this.enableProtections,
};
const openTradesInt = parseInt(this.maxOpenTrades, 10);
if (openTradesInt) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.max_open_trades = openTradesInt;
}
if (this.stakeAmountUnlimited) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.stake_amount = 'unlimited';
} else {
const stakeAmount = Number(this.stakeAmount);
if (stakeAmount) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.stake_amount = stakeAmount.toString();
const hasBacktestResult = computed(() =>
botStore.activeBot.backtestHistory
? Object.keys(botStore.activeBot.backtestHistory).length !== 0
: false,
);
const timeframe = computed((): string => {
try {
return botStore.activeBot.selectedBacktestResult.timeframe;
} catch (err) {
return '';
}
}
});
const startingCapital = Number(this.startingCapital);
if (startingCapital) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.dry_run_wallet = startingCapital;
}
const strategy = ref('');
const selectedTimeframe = ref('');
const selectedDetailTimeframe = ref('');
const timerange = ref('');
const showLeftBar = ref(false);
const enableProtections = ref(false);
const stakeAmountUnlimited = ref(false);
const maxOpenTrades = ref('');
const stakeAmount = ref('');
const startingCapital = ref('');
const btFormMode = ref('run');
const pollInterval = ref<number | null>(null);
const selectedPlotConfig = ref<PlotConfig>(getCustomPlotConfig(getPlotConfigName()));
if (this.selectedTimeframe) {
btPayload.timeframe = this.selectedTimeframe;
}
if (this.selectedDetailTimeframe) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.timeframe_detail = this.selectedDetailTimeframe;
}
const setBacktestResult = (key: string) => {
botStore.activeBot.setBacktestResultKey(key);
this.startBacktest(btPayload);
}
// Set parameters for this result
strategy.value = botStore.activeBot.selectedBacktestResult.strategy_name;
selectedTimeframe.value = botStore.activeBot.selectedBacktestResult.timeframe;
selectedDetailTimeframe.value =
botStore.activeBot.selectedBacktestResult.timeframe_detail || '';
timerange.value = botStore.activeBot.selectedBacktestResult.timerange;
};
@Watch('backtestRunning')
backtestRunningChanged() {
if (this.backtestRunning === true) {
this.pollInterval = window.setInterval(this.pollBacktest, 1000);
} else if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
}
const clickBacktest = () => {
const btPayload: BacktestPayload = {
strategy: strategy.value,
timerange: timerange.value,
// eslint-disable-next-line @typescript-eslint/camelcase
enable_protections: enableProtections.value,
};
const openTradesInt = parseInt(maxOpenTrades.value, 10);
if (openTradesInt) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.max_open_trades = openTradesInt;
}
if (stakeAmountUnlimited.value) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.stake_amount = 'unlimited';
} else {
const stakeAmountLoc = Number(stakeAmount.value);
if (stakeAmountLoc) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.stake_amount = stakeAmountLoc.toString();
}
}
const startingCapitalLoc = Number(startingCapital.value);
if (startingCapitalLoc) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.dry_run_wallet = startingCapitalLoc;
}
if (selectedTimeframe.value) {
btPayload.timeframe = selectedTimeframe.value;
}
if (selectedDetailTimeframe.value) {
// eslint-disable-next-line @typescript-eslint/camelcase
btPayload.timeframe_detail = selectedDetailTimeframe.value;
}
botStore.activeBot.startBacktest(btPayload);
};
onMounted(() => botStore.activeBot.getState());
watch(
() => botStore.activeBot.backtestRunning,
() => {
if (botStore.activeBot.backtestRunning === true) {
pollInterval.value = window.setInterval(botStore.activeBot.pollBacktest, 1000);
} else if (pollInterval.value) {
clearInterval(pollInterval.value);
pollInterval.value = null;
}
},
);
return {
botStore,
formatPercent,
hasBacktestResult,
timeframe,
setBacktestResult,
strategy,
selectedTimeframe,
selectedDetailTimeframe,
timerange,
enableProtections,
showLeftBar,
stakeAmountUnlimited,
maxOpenTrades,
stakeAmount,
startingCapital,
btFormMode,
selectedPlotConfig,
clickBacktest,
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -2,7 +2,7 @@
<GridLayout
class="h-100 w-100"
:row-height="50"
:layout.sync="gridLayout"
:layout="gridLayout"
:vertical-compact="false"
:margin="[5, 5]"
:responsive-layouts="responsiveGridLayouts"
@ -11,7 +11,7 @@
:responsive="true"
:prevent-collision="true"
:cols="{ lg: 12, md: 12, sm: 12, xs: 4, xxs: 2 }"
@layout-updated="layoutUpdated"
@layout-updated="layoutUpdatedEvent"
@breakpoint-changed="breakpointChanged"
>
<GridItem
@ -24,10 +24,10 @@
:min-h="4"
drag-allow-from=".drag-header"
>
<DraggableContainer :header="`Daily Profit ${botCount > 1 ? 'combined' : ''}`">
<DraggableContainer :header="`Daily Profit ${botStore.botCount > 1 ? 'combined' : ''}`">
<DailyChart
v-if="allDailyStatsAllBots"
:daily-stats="allDailyStatsAllBots"
v-if="botStore.allDailyStatsAllBots"
:daily-stats="botStore.allDailyStatsAllBots"
:show-title="false"
/>
</DraggableContainer>
@ -57,7 +57,7 @@
drag-allow-from=".drag-header"
>
<DraggableContainer header="Open Trades">
<trade-list :active-trades="true" :trades="allOpenTradesAllBots" multi-bot-view />
<trade-list :active-trades="true" :trades="botStore.allOpenTradesAllBots" multi-bot-view />
</DraggableContainer>
</GridItem>
<GridItem
@ -71,7 +71,7 @@
drag-allow-from=".drag-header"
>
<DraggableContainer header="Cumulative Profit">
<CumProfitChart :trades="allTradesAllBots" :show-title="false" />
<CumProfitChart :trades="botStore.allTradesAllBots" :show-title="false" />
</DraggableContainer>
</GridItem>
<GridItem
@ -85,7 +85,7 @@
drag-allow-from=".drag-header"
>
<DraggableContainer header="Trades Log">
<TradesLogChart :trades="allTradesAllBots" :show-title="false" />
<TradesLogChart :trades="botStore.allTradesAllBots" :show-title="false" />
</DraggableContainer>
</GridItem>
</GridLayout>
@ -94,8 +94,6 @@
<script lang="ts">
import { formatPrice } from '@/shared/formatters';
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { GridLayout, GridItem, GridItemData } from 'vue-grid-layout';
import DailyChart from '@/components/charts/DailyChart.vue';
@ -105,21 +103,12 @@ import BotComparisonList from '@/components/ftbot/BotComparisonList.vue';
import TradeList from '@/components/ftbot/TradeList.vue';
import DraggableContainer from '@/components/layout/DraggableContainer.vue';
import {
DashboardLayout,
findGridLayout,
LayoutActions,
LayoutGetters,
} from '@/store/modules/layout';
import { Trade, DailyReturnValue, DailyPayload, ClosedTrade } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, ref, computed, onMounted } from '@vue/composition-api';
import { DashboardLayout, findGridLayout, useLayoutStore } from '@/stores/layout';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
const layoutNs = namespace(StoreModules.layout);
@Component({
export default defineComponent({
name: 'Dashboard',
components: {
GridLayout,
GridItem,
@ -130,111 +119,87 @@ const layoutNs = namespace(StoreModules.layout);
TradeList,
DraggableContainer,
},
})
export default class Dashboard extends Vue {
@ftbot.Getter [MultiBotStoreGetters.botCount]!: number;
setup() {
const botStore = useBotStore();
@ftbot.Getter [MultiBotStoreGetters.allOpenTradesAllBots]!: Trade[];
const layoutStore = useLayoutStore();
const currentBreakpoint = ref('');
@ftbot.Getter [MultiBotStoreGetters.allTradesAllBots]!: ClosedTrade[];
@ftbot.Getter [MultiBotStoreGetters.allDailyStatsAllBots]!: Record<string, DailyReturnValue>;
@ftbot.Getter [BotStoreGetters.performanceStats]!: PerformanceEntry[];
@ftbot.Action getPerformance;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action allGetDaily!: (payload?: DailyPayload) => void;
@ftbot.Action getTrades;
@ftbot.Action getOpenTrades;
@ftbot.Action getProfit;
@layoutNs.Getter [LayoutGetters.getDashboardLayoutSm]!: GridItemData[];
@layoutNs.Getter [LayoutGetters.getDashboardLayout]!: GridItemData[];
@layoutNs.Action [LayoutActions.setDashboardLayout];
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
formatPrice = formatPrice;
localGridLayout: GridItemData[] = [];
currentBreakpoint = '';
get isLayoutLocked() {
return this.getLayoutLocked || !this.isResizableLayout;
}
get isResizableLayout() {
return ['', 'sm', 'md', 'lg', 'xl'].includes(this.currentBreakpoint);
}
get gridLayout() {
if (this.isResizableLayout) {
return this.getDashboardLayout;
}
return this.localGridLayout;
}
set gridLayout(newLayout) {
// Dummy setter to make gridLayout happy. Updates happen through layoutUpdated.
}
layoutUpdated(newLayout) {
// Frozen layouts for small screen sizes.
if (this.isResizableLayout) {
console.log('newlayout', newLayout);
console.log('saving dashboard');
this.setDashboardLayout(newLayout);
}
}
get gridLayoutDaily(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.dailyChart);
}
get gridLayoutBotComparison(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.botComparison);
}
get gridLayoutAllOpenTrades(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.allOpenTrades);
}
get gridLayoutCumChart(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.cumChartChart);
}
get gridLayoutTradesLogChart(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.tradesLogChart);
}
get responsiveGridLayouts() {
return {
sm: this.getDashboardLayoutSm,
const breakpointChanged = (newBreakpoint) => {
// // console.log('breakpoint:', newBreakpoint);
currentBreakpoint.value = newBreakpoint;
};
}
const isResizableLayout = computed(() =>
['', 'sm', 'md', 'lg', 'xl'].includes(currentBreakpoint.value),
);
const isLayoutLocked = computed(() => {
return layoutStore.layoutLocked || !isResizableLayout;
});
mounted() {
this.allGetDaily({ timescale: 30 });
this.getTrades();
this.getOpenTrades();
this.getPerformance();
this.getProfit();
this.localGridLayout = [...this.getDashboardLayoutSm];
}
const gridLayout = computed((): GridItemData[] => {
if (isResizableLayout) {
return layoutStore.dashboardLayout;
}
return [...layoutStore.getDashboardLayoutSm];
});
breakpointChanged(newBreakpoint) {
// console.log('breakpoint:', newBreakpoint);
this.currentBreakpoint = newBreakpoint;
}
}
const layoutUpdatedEvent = (newLayout) => {
if (isResizableLayout) {
console.log('newlayout', newLayout);
console.log('saving dashboard');
layoutStore.dashboardLayout = newLayout;
}
};
const gridLayoutDaily = computed((): GridItemData => {
return findGridLayout(gridLayout.value, DashboardLayout.dailyChart);
});
const gridLayoutBotComparison = computed((): GridItemData => {
return findGridLayout(gridLayout.value, DashboardLayout.botComparison);
});
const gridLayoutAllOpenTrades = computed((): GridItemData => {
return findGridLayout(gridLayout.value, DashboardLayout.allOpenTrades);
});
const gridLayoutCumChart = computed((): GridItemData => {
return findGridLayout(gridLayout.value, DashboardLayout.cumChartChart);
});
const gridLayoutTradesLogChart = computed((): GridItemData => {
return findGridLayout(gridLayout.value, DashboardLayout.tradesLogChart);
});
const responsiveGridLayouts = computed(() => {
return {
sm: layoutStore.getDashboardLayoutSm,
};
});
onMounted(async () => {
await botStore.allGetDaily({ timescale: 30 });
botStore.activeBot.getTrades();
botStore.activeBot.getOpenTrades();
botStore.activeBot.getProfit();
});
return {
botStore,
formatPrice,
isLayoutLocked,
layoutUpdatedEvent,
breakpointChanged,
gridLayout,
gridLayoutDaily,
gridLayoutBotComparison,
gridLayoutAllOpenTrades,
gridLayoutCumChart,
gridLayoutTradesLogChart,
responsiveGridLayouts,
};
},
});
</script>
<style scoped></style>

View File

@ -10,9 +10,11 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { defineComponent } from '@vue/composition-api';
export default Vue.extend({});
export default defineComponent({
name: 'Error404',
});
</script>
<style scoped></style>

View File

@ -4,7 +4,7 @@
<!-- Currently only available in Webserver mode -->
<!-- <b-checkbox v-model="historicView">HistoricData</b-checkbox> -->
<!-- </div> -->
<div v-if="historicView" class="mx-md-3 mt-2">
<div v-if="botStore.activeBot.isWebserverMode" class="mx-md-3 mt-2">
<div class="d-flex flex-wrap">
<div class="col-md-3 text-left">
<span>Strategy</span>
@ -20,12 +20,18 @@
<div class="mx-2 mt-2 pb-1 h-100">
<CandleChartContainer
:available-pairs="historicView ? pairlist : whitelist"
:historic-view="historicView"
:timeframe="historicView ? selectedTimeframe : timeframe"
:trades="trades"
:timerange="historicView ? timerange : ''"
:strategy="historicView ? strategy : ''"
:available-pairs="
botStore.activeBot.isWebserverMode
? botStore.activeBot.pairlist
: botStore.activeBot.whitelist
"
:historic-view="botStore.activeBot.isWebserverMode"
:timeframe="
botStore.activeBot.isWebserverMode ? selectedTimeframe : botStore.activeBot.timeframe
"
:trades="botStore.activeBot.trades"
:timerange="botStore.activeBot.isWebserverMode ? timerange : ''"
:strategy="botStore.activeBot.isWebserverMode ? strategy : ''"
:plot-config-modal="false"
>
</CandleChartContainer>
@ -34,61 +40,42 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import CandleChartContainer from '@/components/charts/CandleChartContainer.vue';
import TimeRangeSelect from '@/components/ftbot/TimeRangeSelect.vue';
import TimeframeSelect from '@/components/ftbot/TimeframeSelect.vue';
import StrategySelect from '@/components/ftbot/StrategySelect.vue';
import { AvailablePairPayload, AvailablePairResult, Trade, WhitelistResponse } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, onMounted, ref } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
@Component({
export default defineComponent({
name: 'Graphs',
components: { CandleChartContainer, StrategySelect, TimeRangeSelect, TimeframeSelect },
})
export default class Graphs extends Vue {
historicView = false;
setup() {
const botStore = useBotStore();
const strategy = ref('');
const timerange = ref('');
const selectedTimeframe = ref('');
strategy = '';
onMounted(() => {
if (botStore.activeBot.isWebserverMode) {
// this.refresh();
botStore.activeBot.getAvailablePairs({ timeframe: botStore.activeBot.timeframe });
// .then((val) => {
// console.log(val);
// });
} else if (!botStore.activeBot.whitelist || botStore.activeBot.whitelist.length === 0) {
botStore.activeBot.getWhitelist();
}
});
timerange = '';
selectedTimeframe = '';
@ftbot.Getter [BotStoreGetters.pairlist]!: string[];
@ftbot.Getter [BotStoreGetters.whitelist]!: string[];
@ftbot.Getter [BotStoreGetters.trades]!: Trade[];
@ftbot.Getter [BotStoreGetters.timeframe]!: string;
@ftbot.Getter [BotStoreGetters.isWebserverMode]!: boolean;
@ftbot.Action public getWhitelist!: () => Promise<WhitelistResponse>;
@ftbot.Action public getAvailablePairs!: (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
payload: AvailablePairPayload,
) => Promise<AvailablePairResult>;
mounted() {
this.historicView = this.isWebserverMode;
if (!this.whitelist || this.whitelist.length === 0) {
this.getWhitelist();
}
if (this.historicView) {
// this.refresh();
this.getAvailablePairs({ timeframe: this.timeframe });
// .then((val) => {
// console.log(val);
// });
}
}
}
return {
botStore,
strategy,
timerange,
selectedTimeframe,
};
},
});
</script>
<style scoped></style>

View File

@ -21,14 +21,14 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from '@vue/composition-api';
import BotList from '@/components/BotList.vue';
@Component({
export default defineComponent({
name: 'Home',
components: { BotList },
})
export default class Home extends Vue {}
});
</script>
<style lang="scss" scoped>

View File

@ -5,13 +5,13 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from '@vue/composition-api';
import LogViewer from '@/components/ftbot/LogViewer.vue';
@Component({
export default defineComponent({
name: 'LogView',
components: { LogViewer },
})
export default class LogView extends Vue {}
});
</script>
<style scoped></style>

View File

@ -1,43 +1,43 @@
<template>
<div>
<b-button v-b-modal.modal-prevent-closing>{{ loginText }}</b-button>
<b-modal id="modal-prevent-closing" ref="modal" title="Login to your bot" @ok="handleOk">
<b-modal id="modal-prevent-closing" ref="modalRef" title="Login to your bot" @ok="handleOk">
<Login id="loginForm" ref="loginForm" in-modal @loginResult="handleLoginResult" />
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { defineComponent, ref } from '@vue/composition-api';
import Login from '@/components/Login.vue';
@Component({
export default defineComponent({
name: 'LoginModal',
components: { Login },
})
export default class LoginModal extends Vue {
$refs!: {
loginForm: HTMLFormElement;
modal: HTMLElement;
};
@Prop({ required: false, default: 'Login', type: String }) loginText!: string;
resetLogin() {
// this.$refs.loginForm.resetLogin();
}
handleLoginResult(result: boolean) {
if (result) {
(this.$refs.modal as any).hide();
}
}
handleOk(evt) {
evt.preventDefault();
this.$refs.loginForm.handleSubmit();
}
}
props: {
loginText: { required: false, default: 'Login', type: String },
},
setup() {
const modalRef = ref<HTMLElement>();
const loginForm = ref<HTMLFormElement>();
const handleLoginResult = (result: boolean) => {
if (result) {
(modalRef.value as any)?.hide();
}
};
const handleOk = (evt) => {
evt.preventDefault();
loginForm.value?.handleSubmit();
};
return {
modalRef,
loginForm,
handleOk,
handleLoginResult,
};
},
});
</script>
<style scoped></style>

View File

@ -7,14 +7,14 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from '@vue/composition-api';
import Login from '@/components/Login.vue';
@Component({
export default defineComponent({
name: 'LoginView',
components: { Login },
})
export default class LoginView extends Vue {}
});
</script>
<style scoped>

View File

@ -2,11 +2,11 @@
<div class="container mt-3">
<b-card header="FreqUI Settings">
<div class="text-left">
<p>UI Version: {{ getUiVersion }}</p>
<p>UI Version: {{ settingsStore.uiVersion }}</p>
<b-form-group
description="Lock dynamic layouts, so they cannot move anymore. Can also be set from the navbar at the top."
>
<b-checkbox v-model="layoutLockedLocal">Lock layout</b-checkbox>
<b-checkbox v-model="layoutStore.layoutLocked">Lock layout</b-checkbox>
</b-form-group>
<b-form-group description="Reset dynamic layouts to how they were.">
<b-button size="sm" @click="resetDynamicLayout">Reset layout</b-button>
@ -16,7 +16,7 @@
description="Decide if open trades should be visualized"
>
<b-form-select
v-model="openTradesVisualization"
v-model="settingsStore.openTradesInTitle"
:options="openTradesOptions"
></b-form-select>
</b-form-group>
@ -24,10 +24,13 @@
label="UTC Timezone"
description="Select timezone (we recommend UTC is recommended as exchanges usually work in UTC)"
>
<b-form-select v-model="timezoneLoc" :options="timezoneOptions"></b-form-select>
<b-form-select
v-model="settingsStore.timezone"
:options="timezoneOptions"
></b-form-select>
</b-form-group>
<b-form-group description="Keep background sync running while other bots are selected.">
<b-checkbox v-model="backgroundSyncLocal">Background sync</b-checkbox>
<b-checkbox v-model="settingsStore.backgroundSync">Background sync</b-checkbox>
</b-form-group>
</div>
</b-card>
@ -35,90 +38,39 @@
</template>
<script lang="ts">
import { AlertActions } from '@/store/modules/alerts';
import { LayoutActions, LayoutGetters } from '@/store/modules/layout';
import { OpenTradeVizOptions, SettingsActions, SettingsGetters } from '@/store/modules/settings';
import StoreModules from '@/store/storeSubModules';
import { Component, Vue } from 'vue-property-decorator';
import { namespace, Getter } from 'vuex-class';
import { defineComponent } from '@vue/composition-api';
import { OpenTradeVizOptions, useSettingsStore } from '@/stores/settings';
import { useLayoutStore } from '@/stores/layout';
import { showAlert } from '@/stores/alerts';
const layoutNs = namespace(StoreModules.layout);
const uiSettingsNs = namespace(StoreModules.uiSettings);
const alerts = namespace(StoreModules.alerts);
export default defineComponent({
name: 'Settings',
setup() {
const settingsStore = useSettingsStore();
const layoutStore = useLayoutStore();
@Component({})
export default class Settings extends Vue {
@Getter getUiVersion!: string;
const timezoneOptions = ['UTC', Intl.DateTimeFormat().resolvedOptions().timeZone];
const openTradesOptions = [
{ value: OpenTradeVizOptions.showPill, text: 'Show pill in icon' },
{ value: OpenTradeVizOptions.asTitle, text: 'Show in title' },
{ value: OpenTradeVizOptions.noOpenTrades, text: "Don't show open trades in header" },
];
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
@layoutNs.Action [LayoutActions.setLayoutLocked];
@layoutNs.Action [LayoutActions.resetTradingLayout];
@layoutNs.Action [LayoutActions.resetDashboardLayout];
@alerts.Action [AlertActions.addAlert];
@uiSettingsNs.Getter [SettingsGetters.openTradesInTitle]: string;
@uiSettingsNs.Getter [SettingsGetters.timezone]: string;
@uiSettingsNs.Getter [SettingsGetters.backgroundSync]: boolean;
@uiSettingsNs.Action [SettingsActions.setOpenTradesInTitle];
@uiSettingsNs.Action [SettingsActions.setTimeZone];
@uiSettingsNs.Action [SettingsActions.setBackgroundSync];
openTradesOptions = [
{ value: OpenTradeVizOptions.showPill, text: 'Show pill in icon' },
{ value: OpenTradeVizOptions.asTitle, text: 'Show in title' },
{ value: OpenTradeVizOptions.noOpenTrades, text: "Don't show open trades in header" },
];
// Careful when adding new timezones here - eCharts only supports UTC or user timezone
timezoneOptions = ['UTC', Intl.DateTimeFormat().resolvedOptions().timeZone];
get timezoneLoc() {
return this.timezone;
}
set timezoneLoc(value: string) {
this[SettingsActions.setTimeZone](value);
}
get openTradesVisualization() {
return this.openTradesInTitle;
}
set openTradesVisualization(value: string) {
this.setOpenTradesInTitle(value);
}
get layoutLockedLocal() {
return this.getLayoutLocked;
}
set layoutLockedLocal(value: boolean) {
this.setLayoutLocked(value);
}
get backgroundSyncLocal(): boolean {
return this.backgroundSync;
}
set backgroundSyncLocal(value: boolean) {
this.setBackgroundSync(value);
}
resetDynamicLayout(): void {
this.resetTradingLayout();
this.resetDashboardLayout();
this.addAlert({ message: 'Layouts have been reset.' });
}
}
//
const resetDynamicLayout = () => {
layoutStore.resetTradingLayout();
layoutStore.resetDashboardLayout();
showAlert('Layouts have been reset.');
};
return {
resetDynamicLayout,
settingsStore,
layoutStore,
timezoneOptions,
openTradesOptions,
};
},
});
</script>
<style scoped></style>

View File

@ -8,66 +8,60 @@
empty-text="Currently no open trades."
/> -->
<CustomTradeList
v-if="!history && !detailTradeId"
:trades="openTrades"
v-if="!history && !botStore.activeBot.detailTradeId"
:trades="botStore.activeBot.openTrades"
title="Open trades"
:active-trades="true"
:stake-currency-decimals="stakeCurrencyDecimals"
:stake-currency-decimals="botStore.activeBot.stakeCurrencyDecimals"
empty-text="No open Trades."
/>
<CustomTradeList
v-if="history && !detailTradeId"
:trades="closedTrades"
v-if="history && !botStore.activeBot.detailTradeId"
:trades="botStore.activeBot.closedTrades"
title="Trade history"
:stake-currency-decimals="stakeCurrencyDecimals"
:stake-currency-decimals="botStore.activeBot.stakeCurrencyDecimals"
empty-text="No closed trades so far."
/>
<div v-if="detailTradeId" class="d-flex flex-column">
<b-button size="sm" class="align-self-start mt-1 ml-1" @click="setDetailTrade(null)"
<div v-if="botStore.activeBot.detailTradeId" class="d-flex flex-column">
<b-button
size="sm"
class="align-self-start mt-1 ml-1"
@click="botStore.activeBot.setDetailTrade(null)"
><BackIcon /> Back</b-button
>
<TradeDetail :trade="tradeDetail" :stake-currency="stakeCurrency" />
<TradeDetail
:trade="botStore.activeBot.tradeDetail"
:stake-currency="botStore.activeBot.stakeCurrency"
/>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import CustomTradeList from '@/components/ftbot/CustomTradeList.vue';
import TradeDetail from '@/components/ftbot/TradeDetail.vue';
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
import { defineComponent } from '@vue/composition-api';
import { useBotStore } from '@/stores/ftbotwrapper';
import { Trade } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import StoreModules from '@/store/storeSubModules';
const ftbot = namespace(StoreModules.ftbot);
// TODO: TradeDetail could be extracted into a sub-route to allow direct access
@Component({
export default defineComponent({
name: 'TradesList',
components: {
CustomTradeList,
TradeDetail,
BackIcon,
},
})
export default class TradesList extends Vue {
@Prop({ default: false }) history!: boolean;
props: {
history: { default: false, type: Boolean },
},
setup() {
const botStore = useBotStore();
@ftbot.Getter [BotStoreGetters.openTrades]!: Trade[];
@ftbot.Getter [BotStoreGetters.closedTrades]!: Trade[];
@ftbot.Getter [BotStoreGetters.stakeCurrencyDecimals]!: number;
@ftbot.Getter [BotStoreGetters.stakeCurrency]!: string;
@ftbot.Getter [BotStoreGetters.detailTradeId]?: number;
@ftbot.Getter [BotStoreGetters.tradeDetail]!: Trade;
@ftbot.Action setDetailTrade;
}
return {
botStore,
};
},
});
</script>
<style scoped></style>

View File

@ -25,7 +25,11 @@
<DraggableContainer header="Multi Pane">
<b-tabs content-class="mt-3" class="mt-1">
<b-tab title="Pairs combined" active>
<PairSummary :pairlist="whitelist" :current-locks="currentLocks" :trades="openTrades" />
<PairSummary
:pairlist="botStore.activeBot.whitelist"
:current-locks="botStore.activeBot.activeLocks"
:trades="botStore.activeBot.openTrades"
/>
</b-tab>
<b-tab title="General">
<div class="d-flex justify-content-center">
@ -64,7 +68,7 @@
<DraggableContainer header="Open Trades">
<TradeList
class="open-trades"
:trades="openTrades"
:trades="botStore.activeBot.openTrades"
title="Open trades"
:active-trades="true"
empty-text="Currently no open trades."
@ -83,7 +87,7 @@
<DraggableContainer header="Closed Trades">
<TradeList
class="trade-history"
:trades="closedTrades"
:trades="botStore.activeBot.closedTrades"
title="Trade history"
:show-filter="true"
empty-text="No closed trades so far."
@ -91,7 +95,7 @@
</DraggableContainer>
</GridItem>
<GridItem
v-if="detailTradeId && gridLayoutTradeDetail.h != 0"
v-if="botStore.activeBot.detailTradeId && gridLayoutTradeDetail.h != 0"
:i="gridLayoutTradeDetail.i"
:x="gridLayoutTradeDetail.x"
:y="gridLayoutTradeDetail.y"
@ -101,7 +105,10 @@
drag-allow-from=".card-header"
>
<DraggableContainer header="Trade Detail">
<TradeDetail :trade="tradeDetail" :stake-currency="stakeCurrency" />
<TradeDetail
:trade="botStore.activeBot.tradeDetail"
:stake-currency="botStore.activeBot.stakeCurrency"
/>
</DraggableContainer>
</GridItem>
<GridItem
@ -116,10 +123,10 @@
>
<DraggableContainer header="Chart">
<CandleChartContainer
:available-pairs="whitelist"
:available-pairs="botStore.activeBot.whitelist"
:historic-view="!!false"
:timeframe="timeframe"
:trades="allTrades"
:timeframe="botStore.activeBot.timeframe"
:trades="botStore.activeBot.allTrades"
>
</CandleChartContainer>
</DraggableContainer>
@ -128,8 +135,6 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { GridLayout, GridItem, GridItemData } from 'vue-grid-layout';
import Balance from '@/components/ftbot/Balance.vue';
@ -145,15 +150,12 @@ import Performance from '@/components/ftbot/Performance.vue';
import TradeDetail from '@/components/ftbot/TradeDetail.vue';
import TradeList from '@/components/ftbot/TradeList.vue';
import { Lock, Trade } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { TradeLayout, findGridLayout, LayoutGetters, LayoutActions } from '@/store/modules/layout';
import StoreModules from '@/store/storeSubModules';
import { defineComponent, ref, computed } from '@vue/composition-api';
import { useLayoutStore, findGridLayout, TradeLayout } from '@/stores/layout';
import { useBotStore } from '@/stores/ftbotwrapper';
const ftbot = namespace(StoreModules.ftbot);
const layoutNs = namespace(StoreModules.layout);
@Component({
export default defineComponent({
name: 'Trading',
components: {
Balance,
BotControls,
@ -170,98 +172,78 @@ const layoutNs = namespace(StoreModules.layout);
TradeDetail,
TradeList,
},
})
export default class Trading extends Vue {
@ftbot.Getter [BotStoreGetters.detailTradeId]!: number;
setup() {
const botStore = useBotStore();
const layoutStore = useLayoutStore();
const currentBreakpoint = ref('');
@ftbot.Getter [BotStoreGetters.openTrades]!: Trade[];
@ftbot.Getter [BotStoreGetters.closedTrades]!: Trade[];
@ftbot.Getter [BotStoreGetters.allTrades]!: Trade[];
@ftbot.Getter [BotStoreGetters.tradeDetail]!: Trade;
@ftbot.Getter [BotStoreGetters.timeframe]!: string;
@ftbot.Getter [BotStoreGetters.currentLocks]!: Lock[];
@ftbot.Getter [BotStoreGetters.whitelist]!: string[];
@ftbot.Getter [BotStoreGetters.stakeCurrency]!: string;
@layoutNs.Getter [LayoutGetters.getTradingLayout]!: GridItemData[];
@layoutNs.Getter [LayoutGetters.getTradingLayoutSm]!: GridItemData[];
@layoutNs.Action [LayoutActions.setTradingLayout];
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
currentBreakpoint = '';
localGridLayout: GridItemData[] = [];
get isLayoutLocked() {
return this.getLayoutLocked || !this.isResizableLayout;
}
get isResizableLayout() {
return ['', 'sm', 'md', 'lg', 'xl'].includes(this.currentBreakpoint);
}
get gridLayout(): GridItemData[] {
if (this.isResizableLayout) {
return this.getTradingLayout;
}
return this.localGridLayout;
}
set gridLayout(newLayout) {
// Dummy setter to make gridLayout happy. Updates happen through layoutUpdated.
}
get gridLayoutMultiPane(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.multiPane);
}
get gridLayoutOpenTrades(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.openTrades);
}
get gridLayoutTradeHistory(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.tradeHistory);
}
get gridLayoutTradeDetail(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.tradeDetail);
}
get gridLayoutChartView(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.chartView);
}
mounted() {
this.localGridLayout = [...this.getTradingLayoutSm];
}
layoutUpdatedEvent(newLayout) {
if (this.isResizableLayout) {
this.setTradingLayout(newLayout);
}
}
get responsiveGridLayouts() {
return {
sm: this[LayoutGetters.getTradingLayoutSm],
const breakpointChanged = (newBreakpoint) => {
// console.log('breakpoint:', newBreakpoint);
currentBreakpoint.value = newBreakpoint;
};
}
const isResizableLayout = computed(() =>
['', 'sm', 'md', 'lg', 'xl'].includes(currentBreakpoint.value),
);
const isLayoutLocked = computed(() => {
return layoutStore.layoutLocked || !isResizableLayout;
});
const gridLayout = computed((): GridItemData[] => {
if (isResizableLayout) {
return layoutStore.tradingLayout;
}
return [...layoutStore.getTradingLayoutSm];
});
breakpointChanged(newBreakpoint) {
console.log('breakpoint:', newBreakpoint);
this.currentBreakpoint = newBreakpoint;
}
}
const gridLayoutMultiPane = computed(() => {
return findGridLayout(gridLayout.value, TradeLayout.multiPane);
});
const gridLayoutOpenTrades = computed(() => {
return findGridLayout(gridLayout.value, TradeLayout.openTrades);
});
const gridLayoutTradeHistory = computed(() => {
return findGridLayout(gridLayout.value, TradeLayout.tradeHistory);
});
const gridLayoutTradeDetail = computed(() => {
return findGridLayout(gridLayout.value, TradeLayout.tradeDetail);
});
const gridLayoutChartView = computed(() => {
return findGridLayout(gridLayout.value, TradeLayout.chartView);
});
const responsiveGridLayouts = computed(() => {
return {
sm: layoutStore.getTradingLayoutSm,
};
});
const layoutUpdatedEvent = (newLayout) => {
if (isResizableLayout) {
layoutStore.tradingLayout = newLayout;
}
};
return {
botStore,
layoutStore,
breakpointChanged,
layoutUpdatedEvent,
isLayoutLocked,
gridLayout,
gridLayoutMultiPane,
gridLayoutOpenTrades,
gridLayoutTradeHistory,
gridLayoutTradeDetail,
gridLayoutChartView,
responsiveGridLayouts,
isResizableLayout,
};
},
});
</script>
<style scoped></style>

View File

@ -1462,6 +1462,11 @@
resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-1.4.9.tgz#6fa65284f545887b52d421f23b4fa1c41bc0ad4b"
integrity sha512-l6YOeg5LEXmfPqyxAnBaCv1FMRw0OGKJ4m6nOWRm6ngt5TuHcj5ZoBRN+LXh3J0u6Ur3C4VA+RiKT+M0eItr/g==
"@vue/devtools-api@^6.1.4":
version "6.1.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.1.4.tgz#b4aec2f4b4599e11ba774a50c67fa378c9824e53"
integrity sha512-IiA0SvDrJEgXvVxjNkHPFfDx6SXw0b/TUkqMcDZWNg9fnCAHbTpoo59YfJ9QLFkwa3raau5vSlRVzMSLDnfdtQ==
"@vue/eslint-config-prettier@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@vue/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#ad5912b308f4ae468458e02a2b05db0b9d246700"
@ -4353,6 +4358,19 @@ pify@^2.2.0:
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
pinia-plugin-persistedstate@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-1.5.1.tgz#9ea81e5a245e87941c750fdb5eb303bab399427b"
integrity sha512-X0jKWvA3kbpYe8RuIyLaZDEAFvsv3+QmBkMzInBHl0O57+eVJjswXHnIWeFAeFjktrE0cJbGHw2sBMgkcleySQ==
pinia@^2.0.13:
version "2.0.13"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.13.tgz#6656fc290dae120a9f0cb2f5c520df400d41b8c5"
integrity sha512-B7rSqm1xNpwcPMnqns8/gVBfbbi7lWTByzS6aPZ4JOXSJD4Y531rZHDCoYWBwLyHY/8hWnXljgiXp6rRyrofcw==
dependencies:
"@vue/devtools-api" "^6.1.4"
vue-demi "*"
pirates@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b"
@ -5225,7 +5243,7 @@ vue-class-component@^7.2.5:
resolved "https://registry.yarnpkg.com/vue-class-component/-/vue-class-component-7.2.6.tgz#8471e037b8e4762f5a464686e19e5afc708502e4"
integrity sha512-+eaQXVrAm/LldalI272PpDe3+i4mPis0ORiMYxF6Ae4hyuCh15W8Idet7wPUEs4N4YptgFHGys4UrgNQOMyO6w==
vue-demi@0.12.5, vue-demi@^0.12.1:
vue-demi@*, vue-demi@0.12.5, vue-demi@^0.12.1:
version "0.12.5"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.5.tgz#8eeed566a7d86eb090209a11723f887d28aeb2d1"
integrity sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==
@ -5273,11 +5291,6 @@ vue-material-design-icons@^5.0.0:
resolved "https://registry.yarnpkg.com/vue-material-design-icons/-/vue-material-design-icons-5.0.0.tgz#146275a05adfbd7508baf7a19b68bad77aea557c"
integrity sha512-lYSJFW/TyQqmg7MvUbEB8ua1mwWy/v8qve7QJuA/UWUAXC4/yVUdAm4pg/sM9+k5n7VLckBv6ucOROuGBsGPDQ==
vue-property-decorator@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz#266a2eac61ba6527e2e68a6933cfb98fddab5457"
integrity sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==
vue-router@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.3.tgz#041048053e336829d05dafacf6a8fb669a2e7999"
@ -5301,26 +5314,16 @@ vue-template-es2015-compiler@^1.9.0, vue-template-es2015-compiler@^1.9.1:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue2-helpers@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/vue2-helpers/-/vue2-helpers-1.1.7.tgz#f105313979af0260ef446c583fd2fa75b067afd1"
integrity sha512-NLF7bYFPyoKMvn/Bkxr7+7Ure/kZpWmd6pQpG613dT0Sn6EwI+2+LwVUQyDkDk4P0UaAwvn/QEYzhBRzDzuGLw==
vue@^2.6.14:
version "2.6.14"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==
vuex-class@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/vuex-class/-/vuex-class-0.3.2.tgz#c7e96a076c1682137d4d23a8dcfdc63f220e17a8"
integrity sha512-m0w7/FMsNcwJgunJeM+wcNaHzK2KX1K1rw2WUQf7Q16ndXHo7pflRyOV/E8795JO/7fstyjH3EgqBI4h4n4qXQ==
vuex-composition-helpers@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/vuex-composition-helpers/-/vuex-composition-helpers-1.1.0.tgz#a18d00192fbb0205630202aade1ec6d5f05d4c28"
integrity sha512-36f3MWRCW6QqtP3NLyLbtTPv8qWwbac7gAK9fM4ZtDWTCWuAeBoZEiM+bmPQweAQoMM7GRSXmw/90Egiqg0DCA==
vuex@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"
integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==
w3c-hr-time@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"