Merge pull request #478 from freqtrade/multi_bot

add multi bot support
This commit is contained in:
Matthias 2021-10-06 20:18:00 +02:00 committed by GitHub
commit 9fa15b72f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2288 additions and 1244 deletions

View File

@ -0,0 +1,82 @@
<template>
<div class="d-flex align-items-center justify-content-between w-100">
<span class="mr-2">{{ bot.botName || bot.botId }}</span>
<div class="align-items-center d-flex">
<span class="ml-2 align-middle">{{
allIsBotOnline[bot.botId] ? '&#128994;' : '&#128308;'
}}</span>
<b-form-checkbox
v-model="autoRefreshLoc"
class="ml-auto float-right mr-2 my-auto"
title="AutoRefresh"
variant="secondary"
@change="changeEvent"
>
R
</b-form-checkbox>
<div v-if="!noButtons" class="d-flex flex-align-cent">
<!-- <b-button class="ml-1" size="sm" title="Edit bot">
<EditIcon :size="16" title="Edit Button" />
</b-button> -->
<b-button class="ml-1" size="sm" title="Delete bot" @click.prevent="clickRemoveBot(bot)">
<DeleteIcon :size="16" title="Delete Bot" />
</b-button>
</div>
</div>
</div>
</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 EditIcon from 'vue-material-design-icons/Cog.vue';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import { BotDescriptor, BotDescriptors } from '@/types';
const ftbot = namespace('ftbot');
@Component({ components: { LoginModal, DeleteIcon, EditIcon } })
export default class BotList extends Vue {
@Prop({ default: false, type: Object }) bot!: BotDescriptor;
@Prop({ default: false, type: Boolean }) noButtons!: boolean;
@ftbot.Getter [MultiBotStoreGetters.allIsBotOnline];
@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);
}
});
}
}
</script>
<style scoped></style>

View File

@ -0,0 +1,46 @@
<template>
<div v-if="botCount > 0">
<h3 v-if="!small">Available bots</h3>
<b-list-group>
<b-list-group-item
v-for="bot in allAvailableBots"
:key="bot.botId"
:active="bot.botId === selectedBot"
button
:title="`${bot.botId} - ${bot.botName} - ${bot.botUrl}`"
@click="selectBot(bot.botId)"
>
<bot-entry :bot="bot" :no-buttons="small" />
</b-list-group-item>
</b-list-group>
<LoginModal v-if="!small" class="mt-2" login-text="Add new bot" />
</div>
</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 { BotDescriptors } from '@/types';
const ftbot = namespace('ftbot');
@Component({ components: { LoginModal, BotEntry } })
export default class BotList extends Vue {
@Prop({ default: false, type: Boolean }) small!: boolean;
@ftbot.Getter [MultiBotStoreGetters.botCount]: number;
@ftbot.Getter [MultiBotStoreGetters.selectedBot]: string;
@ftbot.Getter [MultiBotStoreGetters.allIsBotOnline]: Record<string, boolean>;
@ftbot.Getter [MultiBotStoreGetters.allAvailableBots]: BotDescriptors;
@ftbot.Action selectBot;
}
</script>
<style scoped></style>

View File

@ -1,6 +1,14 @@
<template>
<div>
<form ref="form" novalidate @submit.stop.prevent="handleSubmit" @reset="handleReset">
<b-form-group label="Bot Name" label-for="name-input">
<b-form-input
id="name-input"
v-model="auth.botName"
placeholder="Bot Name"
@keydown.enter.native="handleOk"
></b-form-input>
</b-form-group>
<b-form-group
:state="urlState"
label="API Url"
@ -26,6 +34,7 @@
v-model="auth.username"
:state="nameState"
required
placeholder="Freqtrader"
@keydown.enter.native="handleOk"
></b-form-input>
</b-form-group>
@ -60,18 +69,29 @@
<script lang="ts">
import { Component, Vue, Emit, Prop } from 'vue-property-decorator';
import { Action } from 'vuex-class';
import userService from '@/shared/userService';
import { setBaseUrl } from '@/shared/apiService';
import { Action, namespace } from 'vuex-class';
import { useUserService } from '@/shared/userService';
import { AuthPayload } from '@/types';
import { AuthPayload, BotDescriptor } from '@/types';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
const defaultURL = window.location.origin || 'http://localhost:8080';
const ftbot = namespace('ftbot');
@Component({})
export default class Login extends Vue {
@Action setLoggedIn;
@ftbot.Getter [MultiBotStoreGetters.nextBotId]: string;
@ftbot.Getter [MultiBotStoreGetters.selectedBot]: string;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action addBot!: (payload: BotDescriptor) => void;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ftbot.Action selectBot!: (botId: string) => void;
@Prop({ default: false }) inModal!: boolean;
$refs!: {
@ -79,6 +99,7 @@ export default class Login extends Vue {
};
auth: AuthPayload = {
botName: '',
url: defaultURL,
username: '',
password: '',
@ -127,12 +148,22 @@ export default class Login extends Vue {
return;
}
this.errorMessage = '';
const userService = useUserService(this.nextBotId);
// Push the name to submitted names
userService
.login(this.auth)
.then(() => {
this.setLoggedIn(true);
setBaseUrl(userService.getAPIUrl());
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);
if (this.inModal === false) {
if (typeof this.$route.query.redirect === 'string') {

View File

@ -24,8 +24,8 @@ import {
LegendComponent,
TimelineComponent,
TitleComponent,
TooltipComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
VisualMapPiecewiseComponent,
} from 'echarts/components';
@ -37,9 +37,9 @@ use([
DataZoomComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
TimelineComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
VisualMapPiecewiseComponent,

View File

@ -1,5 +1,5 @@
<template>
<v-chart v-if="trades.length > 0" :option="chartOptions" autoresize :theme="getChartTheme" />
<v-chart v-if="trades" :option="chartOptions" autoresize :theme="getChartTheme" />
</template>
<script lang="ts">
@ -20,7 +20,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { ClosedTrade, CumProfitData } from '@/types';
import { ClosedTrade, CumProfitData, CumProfitDataPerDate } from '@/types';
use([
BarChart,
@ -53,24 +53,54 @@ export default class CumProfitChart extends Vue {
@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];
res.push({ date: trade.close_timestamp, profit });
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 += profit;
if (resD[trade.close_timestamp][trade.botId]) {
resD[trade.close_timestamp][trade.botId] += profit;
} else {
resD[trade.close_timestamp][trade.botId] = profit;
}
}
res.push({ date: trade.close_timestamp, profit, [trade.botId]: profit });
if (!this.botList.includes(trade.botId)) {
this.botList.push(trade.botId);
}
}
}
return res;
// 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];
// });
return obj;
});
}
get chartOptions(): EChartsOption {
return {
const chartOptionsLoc: EChartsOption = {
title: {
text: 'Cumulative Profit',
show: this.showTitle,
@ -151,6 +181,24 @@ export default class CumProfitChart extends Vue {
},
],
};
// 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;
}
}
</script>

View File

@ -52,6 +52,7 @@ const CHART_TRADE_COUNT = 'Trade Count';
},
})
export default class HourlyChart extends Vue {
// TODO: This chart is not used at the moment!
@Prop({ required: true }) trades!: Trade[];
@Prop({ default: true, type: Boolean }) showTitle!: boolean;

View File

@ -124,9 +124,12 @@ 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';
const ftbot = namespace('ftbot');
const alerts = namespace('alerts');
@Component({
components: { PlotIndicator },
})
@ -202,6 +205,8 @@ export default class PlotConfigurator extends Vue {
@ftbot.Getter [BotStoreGetters.plotConfigName]!: string;
@alerts.Action [AlertActions.addAlert];
get plotConfigJson() {
return JSON.stringify(this.plotConfig, null, 2);
}
@ -334,9 +339,14 @@ export default class PlotConfigurator extends Vue {
}
async loadPlotConfigFromStrategy() {
await this.getStrategyPlotConfig();
this.plotConfig = this.strategyPlotConfig;
this.emitPlotConfig();
try {
await this.getStrategyPlotConfig();
this.plotConfig = this.strategyPlotConfig;
this.emitPlotConfig();
} catch (data) {
//
this.addAlert({ message: 'Failed to load Plot configuration from Strategy.' });
}
}
}
</script>

View File

@ -62,7 +62,7 @@ export default class TradesLogChart extends Vue {
.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];
const entry = [i, (trade.profit_ratio * 100).toFixed(2), trade.pair, trade.botName];
res.push(entry);
}
@ -86,7 +86,7 @@ export default class TradesLogChart extends Vue {
tooltip: {
trigger: 'axis',
formatter: (params) => {
return `Profit %<br>${params[0].data[1]} % ${params[0].data[2]}`;
return `Profit %<br>${params[0].data[1]} % ${params[0].data[2]} | ${params[0].data[3]}`;
},
axisPointer: {
type: 'line',

View File

@ -0,0 +1,119 @@
<template>
<b-table
ref="tradesTable"
small
hover
show-empty
primary-key="botId"
:items="tableItems"
:fields="tableFields"
>
<template #cell(profitClosed)="row">
<profit-pill
v-if="row.item.profitClosed"
:profit-ratio="row.item.profitClosedRatio"
:profit-abs="row.item.profitClosed"
:stake-currency="row.item.stakeCurrency"
/>
</template>
<template #cell(profitOpen)="row">
<profit-pill
v-if="row.item.profitClosed"
:profit-ratio="row.item.profitOpenRatio"
:profit-abs="row.item.profitOpen"
:stake-currency="row.item.stakeCurrency"
/>
</template>
<template #cell(balance)="row">
<div v-if="row.item.balance">
<span :title="row.item.stakeCurrency"
>{{ formatPrice(row.item.balance, row.item.stakeCurrencyDecimals) }}
</span>
<span clas="text-small">{{ row.item.stakeCurrency }}</span>
</div>
</template>
<template #cell(winVsLoss)="row">
<div v-if="row.item.losses !== undefined">
<span class="text-profit">{{ row.item.wins }}</span> /
<span class="text-loss">{{ row.item.losses }}</span>
</div>
</template>
</b-table>
</template>
<script lang="ts">
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
import { BalanceInterface, BotDescriptors, BotState, ProfitInterface } 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';
const ftbot = namespace('ftbot');
@Component({ components: { ProfitPill } })
export default class BotComparisonList extends Vue {
@ftbot.Getter [MultiBotStoreGetters.allProfit]!: Record<string, ProfitInterface>;
@ftbot.Getter [MultiBotStoreGetters.allOpenTradeCount]!: Record<string, number>;
@ftbot.Getter [MultiBotStoreGetters.allBotState]!: Record<string, BotState>;
@ftbot.Getter [MultiBotStoreGetters.allBalance]!: Record<string, BalanceInterface>;
@ftbot.Getter [MultiBotStoreGetters.allAvailableBots]!: BotDescriptors;
formatPrice = formatPrice;
get tableItems() {
const val: any[] = [];
const summary = {
botId: 'Summary',
profitClosed: 0,
profitClosedRatio: undefined,
profitOpen: 0,
profitOpenRatio: undefined,
stakeCurrency: 'USDT',
wins: 0,
losses: 0,
};
Object.entries(this.allProfit).forEach(([k, v]) => {
// 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_sum || 0,
stakeCurrency: this.allBotState[k]?.stake_currency || '',
profitOpenRatio: v.profit_all_ratio_sum - v.profit_closed_ratio_sum,
profitOpen: v.profit_all_coin - v.profit_closed_coin,
wins: v.winning_trades,
losses: v.losing_trades,
balance: this.allBalance[k]?.total,
stakeCurrencyDecimals: this.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;
}
});
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' },
];
}
</script>
<style scoped></style>

View File

@ -1,58 +1,56 @@
<template>
<div class="container-fluid">
<div class="row">
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || isRunning"
title="Start Trading"
@click="startBot()"
>
<PlayIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="Stop Trading - Also stops handling open trades."
@click="handleStopBot()"
>
<StopIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="StopBuy - Stops buying, but still handles open trades"
@click="handleStopBuy()"
>
<PauseIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading"
title="Reload Config - reloads configuration including strategy, resetting all settings changed on the fly."
@click="handleReloadConfig()"
>
<ReloadIcon />
</button>
<button
v-if="botState && botState.forcebuy_enabled"
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="Force Buy - Immediately buy an asset at an optional price. Sells are then handled according to strategy rules."
@click="initiateForcebuy"
>
<ForceBuyIcon />
</button>
<button
v-if="isWebserverMode && false"
:disabled="isTrading"
class="btn btn-secondary btn-sm ml-1"
title="Start Trading mode"
@click="startTrade()"
>
<PlayIcon />
</button>
<ForceBuyForm :modal-show="forcebuyShow" @close="$bvModal.hide('forcebuy-modal')" />
</div>
<div>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || isRunning"
title="Start Trading"
@click="startBot()"
>
<PlayIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="Stop Trading - Also stops handling open trades."
@click="handleStopBot()"
>
<StopIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="StopBuy - Stops buying, but still handles open trades"
@click="handleStopBuy()"
>
<PauseIcon />
</button>
<button
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading"
title="Reload Config - reloads configuration including strategy, resetting all settings changed on the fly."
@click="handleReloadConfig()"
>
<ReloadIcon />
</button>
<button
v-if="botState && botState.forcebuy_enabled"
class="btn btn-secondary btn-sm ml-1"
:disabled="!isTrading || !isRunning"
title="Force Buy - Immediately buy an asset at an optional price. Sells are then handled according to strategy rules."
@click="initiateForcebuy"
>
<ForceBuyIcon />
</button>
<button
v-if="isWebserverMode && false"
:disabled="isTrading"
class="btn btn-secondary btn-sm ml-1"
title="Start Trading mode"
@click="startTrade()"
>
<PlayIcon />
</button>
<ForceBuyForm :modal-show="forcebuyShow" @close="$bvModal.hide('forcebuy-modal')" />
</div>
</template>

View File

@ -15,9 +15,10 @@
<script lang="ts">
import Vue from 'vue';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import DailyChart from '@/components/charts/DailyChart.vue';
import { formatPrice } from '@/shared/formatters';
import { BotStoreGetters } from '@/store/modules/ftbot';
export default Vue.extend({
name: 'DailyStats',
@ -25,7 +26,7 @@ export default Vue.extend({
DailyChart,
},
computed: {
...mapState('ftbot', ['dailyStats']),
...mapGetters('ftbot', [BotStoreGetters.dailyStats]),
dailyFields() {
return [
{ key: 'date', label: 'Day' },

View File

@ -14,9 +14,7 @@
{{ comb.pair }}
<span v-if="comb.locks" :title="comb.lockReason"> &#128274; </span>
</div>
<b-badge :variant="comb.profit > 0 ? 'success' : 'danger'" pill :title="comb.profitString">{{
comb.profit ? formatPercent(comb.profit) : ''
}}</b-badge>
<TradeProfit v-if="comb.trade" :trade="comb.trade" />
</b-list-group-item>
</b-list-group>
</template>
@ -27,6 +25,7 @@ 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';
const ftbot = namespace('ftbot');
@ -39,7 +38,7 @@ interface CombinedPairList {
profit: number;
}
@Component({})
@Component({ components: { TradeProfit } })
export default class PairSummary extends Vue {
@Prop({ required: true }) pairlist!: string[];

View File

@ -5,6 +5,7 @@
</template>
<script lang="ts">
// TODO: evaluate if this is still used
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Trade } from '@/types';

View File

@ -1,23 +1,29 @@
<template>
<div class="container-fluid">
<div class="row">
<button class="m-1 btn btn-primary" @click="refreshAll(true)"><RefreshIcon /></button>
<b-form-checkbox
v-model="autoRefreshLoc"
class="ml-auto float-right mr-2 my-auto"
title="AutoRefresh"
switch
>AutoRefresh</b-form-checkbox
>
</div>
<div class="d-flex flex-align-center ml-2">
<b-form-checkbox
v-model="autoRefreshLoc"
class="ml-auto float-right my-auto"
title="AutoRefresh"
></b-form-checkbox>
<b-button
class="m-1"
variant="secondary"
size="sm"
title="Auto Refresh All bots"
@click="allRefreshFull"
>
<RefreshIcon :size="16" />
</b-button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import { Action, State } from 'vuex-class';
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';
const ftbot = namespace('ftbot');
@Component({ components: { RefreshIcon } })
export default class ReloadControl extends Vue {
@ -25,85 +31,19 @@ export default class ReloadControl extends Vue {
refreshIntervalSlow: number | null = null;
created() {
if (this.loggedIn) {
this.refreshOnce();
this.refreshAll(true);
}
}
mounted() {
this.startRefresh(false);
}
beforeDestroy() {
this.stopRefresh();
}
@State loggedIn;
@State autoRefresh!: boolean;
@ftbot.Getter [MultiBotStoreGetters.globalAutoRefresh]!: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@Action setAutoRefresh!: (newValue: boolean) => void;
@ftbot.Action setGlobalAutoRefresh!: (newValue: boolean) => void;
@Action refreshSlow;
@Action refreshFrequent;
@Action refreshAll;
@Action refreshOnce;
@ftbot.Action allRefreshFull;
get autoRefreshLoc() {
return this.autoRefresh;
return this.globalAutoRefresh;
}
set autoRefreshLoc(newValue: boolean) {
this.setAutoRefresh(newValue);
}
startRefresh(runNow: boolean) {
if (this.loggedIn !== true) {
console.log('Not logged in.');
return;
}
console.log('Starting automatic refresh.');
if (runNow) {
this.refreshFrequent(false);
}
if (this.autoRefresh) {
this.refreshInterval = window.setInterval(() => {
this.refreshFrequent();
}, 5000);
}
if (runNow) {
this.refreshSlow(true);
}
if (this.autoRefresh) {
this.refreshIntervalSlow = window.setInterval(() => {
this.refreshSlow(false);
}, 60000);
}
}
stopRefresh() {
console.log('Stopping automatic refresh.');
if (this.refreshInterval) {
window.clearInterval(this.refreshInterval);
}
if (this.refreshIntervalSlow) {
window.clearInterval(this.refreshIntervalSlow);
}
}
@Watch('autoRefresh')
watchAutoRefresh(val) {
if (val) {
this.startRefresh(true);
} else {
this.stopRefresh();
}
this.setGlobalAutoRefresh(newValue);
}
}
</script>

View File

@ -11,7 +11,7 @@
:empty-text="emptyText"
:per-page="perPage"
:current-page="currentPage"
primary-key="trade_id"
primary-key="botTradeId"
selectable
select-mode="single"
:filter="filterText"
@ -33,7 +33,6 @@
</b-button>
</template>
<template #cell(pair)="row">
<ProfitSymbol :trade="row.item" />
<span>
{{
`${row.item.pair}${
@ -43,10 +42,7 @@
</span>
</template>
<template #cell(profit)="row">
{{ formatPercent(row.item.profit_ratio, 2) }}
<small :title="row.item.stake_currency || stakeCurrency">
{{ `(${formatPriceWithDecimals(row.item.profit_abs)})` }}
</small>
<trade-profit :trade="row.item" />
</template>
<template #cell(open_timestamp)="row">
<DateTimeTZ :date="row.item.open_timestamp" />
@ -87,11 +83,12 @@ import ForceSellIcon from 'vue-material-design-icons/CloseBoxMultiple.vue';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import { BotStoreGetters } from '@/store/modules/ftbot';
import ProfitSymbol from './ProfitSymbol.vue';
import TradeProfit from './TradeProfit.vue';
const ftbot = namespace('ftbot');
@Component({
components: { ProfitSymbol, DeleteIcon, ForceSellIcon, DateTimeTZ },
components: { ProfitSymbol, DeleteIcon, ForceSellIcon, DateTimeTZ, TradeProfit },
})
export default class TradeList extends Vue {
$refs!: {
@ -112,6 +109,8 @@ export default class TradeList extends Vue {
@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;
@ -157,6 +156,7 @@ export default class TradeList extends Vue {
];
tableFields: Record<string, string | Function>[] = [
this.multiBotView ? { key: 'botName', label: 'Bot' } : {},
{ key: 'trade_id', label: 'ID' },
{ key: 'pair', label: 'Pair' },
{ key: 'amount', label: 'Amount' },

View File

@ -0,0 +1,34 @@
<template>
<profit-pill
:profit-ratio="trade.profit_ratio"
:profit-abs="trade.profit_abs"
:profit-desc="profitDesc"
stake-currency="USDT"
/>
</template>
<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;
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;
}
}
</script>
<style scoped></style>

View File

@ -0,0 +1,52 @@
<template>
<div
class="d-flex justify-content-center align-items-center profit-pill px-2"
:class="isProfitable ? 'profit-pill-profit' : ''"
:title="profitDesc"
>
{{ profitRatio ? formatPercent(profitRatio, 2) : '' }}
<span class="ml-1" :class="profitRatio ? 'small' : ''" :title="stakeCurrency">
{{ profitRatio ? '(' : '' }}{{ `${formatPrice(profitAbs, 3)}`
}}{{ profitRatio ? ')' : ` ${stakeCurrency}` }}
</span>
</div>
</template>
<script lang="ts">
import { formatPercent, formatPrice, timestampms } from '@/shared/formatters';
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({})
export default class ProfitPill extends Vue {
@Prop({ required: false, default: undefined, type: Number }) profitRatio?: number;
@Prop({ required: true, type: Number }) profitAbs!: number;
@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 > 0)
);
}
}
</script>
<style scoped lang="scss">
.profit-pill {
background: $color-loss;
border-radius: 6px;
}
.profit-pill-profit {
background: $color-profit;
}
</style>

View File

@ -6,49 +6,101 @@
<span class="navbar-brand-title d-sm-none d-md-inline">Freqtrade UI</span>
</router-link>
<!-- TODO: For XS breakpoint, this should be here... -->
<!-- <ReloadControl class="mr-3" /> -->
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<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 navbar-dark" to="/trade"
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/trade"
>Trade</router-link
>
<router-link
v-if="!canRunBacktest"
class="nav-link navbar-nav navbar-dark"
to="/dashboard"
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/dashboard"
>Dashboard</router-link
>
<router-link class="nav-link navbar-nav navbar-dark" to="/graph">Graph</router-link>
<router-link class="nav-link navbar-nav navbar-dark" to="/logs">Logs</router-link>
<router-link v-if="canRunBacktest" class="nav-link navbar-nav navbar-dark" to="/backtest"
<router-link class="nav-link navbar-nav" to="/graph">Graph</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"
>Backtest</router-link
>
<BootswatchThemeSelect />
</b-navbar-nav>
<!-- Right aligned nav items -->
<b-navbar-nav class="ml-auto">
<li class="nav-item text-secondary mr-2">
<b-navbar-nav class="ml-auto" menu-class="w-100">
<!-- TODO This should show outside of the dropdown in XS mode -->
<div class="d-flex justify-content-between">
<b-dropdown
v-if="botCount > 1"
size="sm"
class="m-1"
no-caret
variant="info"
toggle-class="d-flex align-items-center "
menu-class="my-0 py-0"
>
<template #button-content>
<BotEntry :bot="selectedBotObj" :no-buttons="true" />
</template>
<BotList :small="true" />
</b-dropdown>
<ReloadControl class="mr-3" />
</div>
<li class="d-none d-sm-block nav-item text-secondary mr-2">
<b-nav-text class="verticalCenter small mr-2">
{{ botName }}
{{ botName || 'No bot selected' }}
</b-nav-text>
<b-nav-text class="verticalCenter">
{{ isBotOnline ? 'Online' : 'Offline' }}
</b-nav-text>
</li>
<li v-if="loggedIn" class="nav-item">
<b-nav-item-dropdown right>
<b-dropdown-item>V: {{ getUiVersion }}</b-dropdown-item>
<li v-if="hasBots" class="nav-item">
<!-- Hide dropdown on xs, instead show below -->
<b-nav-item-dropdown 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>
<router-link class="dropdown-item" to="/settings">Settings</router-link>
<b-checkbox v-model="layoutLockedLocal" class="pl-5">Lock layout</b-checkbox>
<b-dropdown-item @click="resetDynamicLayout">Reset Layout</b-dropdown-item>
<router-link class="dropdown-item" to="/" @click.native="logout()"
<router-link
v-if="botCount === 1"
class="dropdown-item"
to="/"
@click.native="clickLogout()"
>Sign Out</router-link
>
</b-nav-item-dropdown>
<div class="d-block d-sm-none">
<!-- Visible only on XS -->
<li class="nav-item text-secondary ml-2 d-sm-none d-flex justify-content-between">
<span class="nav-link navbar-nav">V: {{ getUiVersion }}</span>
<div class="d-flex">
<b-nav-text class="verticalCenter small mr-2">
{{ botName || 'No bot selected' }}
</b-nav-text>
<b-nav-text class="verticalCenter">
{{ isBotOnline ? 'Online' : 'Offline' }}
</b-nav-text>
</div>
</li>
<router-link class="nav-link navbar-nav" to="/settings">Settings</router-link>
<div class="d-flex nav-link justify-content-end">
<b-checkbox v-model="layoutLockedLocal" class="ml-2"> Lock layout</b-checkbox>
</div>
<b-nav-item class="nav-link navbar-nav" @click="resetDynamicLayout"
>Reset Layout</b-nav-item
>
<router-link
v-if="botCount === 1"
class="nav-link navbar-nav"
to="/"
@click.native="clickLogout()"
>Sign Out</router-link
>
</div>
</li>
<li v-else>
<!-- should open Modal window! -->
@ -63,27 +115,29 @@
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import LoginModal from '@/views/LoginModal.vue';
import { State, Action, namespace, Getter } from 'vuex-class';
import userService from '@/shared/userService';
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';
const ftbot = namespace('ftbot');
const layoutNs = namespace('layout');
const uiSettingsNs = namespace('uiSettings');
@Component({
components: { LoginModal, BootswatchThemeSelect },
components: { LoginModal, BootswatchThemeSelect, ReloadControl, BotEntry, BotList },
})
export default class NavBar extends Vue {
pingInterval: number | null = null;
@State loggedIn!: boolean;
@State isBotOnline!: boolean;
botSelectOpen = false;
@Action setLoggedIn;
@ -91,9 +145,17 @@ export default class NavBar extends Vue {
@Getter getUiVersion!: string;
@ftbot.Action ping;
@ftbot.Action pingAll;
@ftbot.Action getState;
@ftbot.Action allGetState;
@ftbot.Action logout;
@ftbot.Getter [BotStoreGetters.isBotOnline]!: boolean;
@ftbot.Getter [MultiBotStoreGetters.hasBots]: boolean;
@ftbot.Getter [MultiBotStoreGetters.botCount]: number;
@ftbot.Getter [BotStoreGetters.botName]: string;
@ -101,6 +163,8 @@ export default class NavBar extends Vue {
@ftbot.Getter [BotStoreGetters.canRunBacktest]!: boolean;
@ftbot.Getter [MultiBotStoreGetters.selectedBotObj]!: BotDescriptor;
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
@layoutNs.Action [LayoutActions.resetDashboardLayout];
@ -114,13 +178,13 @@ export default class NavBar extends Vue {
favicon: Favico | undefined = undefined;
mounted() {
this.ping();
this.pingAll();
this.loadUIVersion();
this.pingInterval = window.setInterval(this.ping, 60000);
this.pingInterval = window.setInterval(this.pingAll, 60000);
if (this.loggedIn) {
if (this.hasBots) {
// Query botstate - this will enable / disable certain modes
this.getState();
this.allGetState();
}
}
@ -130,8 +194,9 @@ export default class NavBar extends Vue {
}
}
logout(): void {
userService.logout();
clickLogout(): void {
this.logout();
// TODO: This should be per bot
this.setLoggedIn(false);
}
@ -144,7 +209,6 @@ export default class NavBar extends Vue {
}
setOpenTradesAsPill(tradeCount: number) {
console.log('setPill', tradeCount);
if (!this.favicon) {
this.favicon = new Favico({
animation: 'none',

View File

@ -3,9 +3,9 @@ import './plugins/bootstrap-vue';
import App from './App.vue';
import store from './store';
import router from './router';
import { init } from './shared/apiService';
import { initApi } from './shared/apiService';
init(store);
initApi(store);
Vue.config.productionTip = false;

View File

@ -2,8 +2,7 @@ import Vue from 'vue';
import VueRouter, { RouteConfig } from 'vue-router';
import Home from '@/views/Home.vue';
import Error404 from '@/views/Error404.vue';
import userService from '@/shared/userService';
import store from '@/store';
Vue.use(VueRouter);
@ -68,11 +67,8 @@ const router = new VueRouter({
});
router.beforeEach((to, from, next) => {
if (to.name === 'Login' && userService.loggedIn()) {
// No login if already logged in
next({ path: '/' });
}
if (!to.meta?.allowAnonymous && !userService.loggedIn()) {
const hasBots = store.getters['ftbot/hasBots'];
if (!to.meta?.allowAnonymous && !hasBots) {
// Forward to login if login is required
next({
path: '/login',

View File

@ -1,17 +1,18 @@
import axios from 'axios';
import userService from './userService';
export const api = axios.create({
baseURL: userService.apiBase,
timeout: 10000,
withCredentials: true,
});
import { UserService } from './userService';
/**
* Initialize api so store is accessible.
* @param store Vuex store
* Global store variable - keep a reference here to be able to emmit alerts
*/
export function init(store) {
let globalStore;
export function useApi(userService: UserService, botId: string) {
const api = axios.create({
baseURL: userService.getBaseUrl(),
timeout: 10000,
withCredentials: true,
});
// Sent auth headers interceptor
api.interceptors.request.use(
(config) => {
const custconfig = config;
@ -59,7 +60,7 @@ export function init(store) {
}
if ((err.response && err.response.status === 500) || err.message === 'Network Error') {
console.log('Bot not running...');
store.dispatch('setIsBotOnline', false);
globalStore.dispatch(`ftbot/${botId}/setIsBotOnline`, false);
}
return new Promise((resolve, reject) => {
@ -67,17 +68,17 @@ export function init(store) {
});
},
);
return {
api,
};
}
export function setBaseUrl(baseURL: string) {
if (baseURL === null) {
// Reset to "local" baseurl
api.defaults.baseURL = userService.apiBase;
} else if (!baseURL.endsWith(userService.apiBase)) {
api.defaults.baseURL = `${baseURL}${userService.apiBase}`;
} else {
api.defaults.baseURL = `${baseURL}${userService.apiBase}`;
}
/**
* Initialize api so store is accessible.
* @param store Vuex store
*/
export function initApi(store) {
globalStore = store;
//
}
setBaseUrl(userService.getAPIUrl());

View File

@ -1,60 +1,151 @@
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { AuthPayload } from '@/types';
import { AuthPayload, AuthResponse, BotDescriptors, AuthStorage, AuthStorageMulti } from '@/types';
const AUTH_REFRESH_TOKEN = 'auth_ref_token';
const AUTH_ACCESS_TOKEN = 'auth_access_token';
const AUTH_API_URL = 'auth_api_url';
const apiBase = '/api/v1';
const AUTH_LOGIN_INFO = 'ftAuthLoginInfo';
const APIBASE = '/api/v1';
export default {
apiBase,
AUTH_API_URL,
setAPIUrl(apiurl: string): void {
localStorage.setItem(AUTH_API_URL, JSON.stringify(apiurl));
},
export class UserService {
private botId: string;
setAccessToken(token: string): void {
localStorage.setItem(AUTH_ACCESS_TOKEN, JSON.stringify(token));
},
constructor(botId: string) {
console.log('botId', botId);
this.botId = botId;
}
setRefreshTokens(refreshToken: string): void {
localStorage.setItem(AUTH_REFRESH_TOKEN, JSON.stringify(refreshToken));
},
/**
* Stores info for current botId in the object of all bots.
*/
private storeLoginInfo(loginInfo: AuthStorage): void {
const allInfo = UserService.getAllLoginInfos();
allInfo[this.botId] = loginInfo;
localStorage.setItem(AUTH_LOGIN_INFO, JSON.stringify(allInfo));
}
logout(): void {
/**
* Logout - removing info for this particular bot.
*/
private removeLoginInfo(): void {
const info = UserService.getAllLoginInfos();
delete info[this.botId];
localStorage.setItem(AUTH_LOGIN_INFO, JSON.stringify(info));
}
private setAccessToken(token: string): void {
const loginInfo = this.getLoginInfo();
loginInfo.accessToken = token;
this.storeLoginInfo(loginInfo);
}
/**
* Store autorefresh preference for this bot instance
* @param autoRefresh new autoRefresh value
*/
public setAutoRefresh(autoRefresh: boolean): void {
const loginInfo = this.getLoginInfo();
loginInfo.autoRefresh = autoRefresh;
this.storeLoginInfo(loginInfo);
}
/**
* Retrieve full logininfo object (for all registered bots)
* @returns
*/
private static getAllLoginInfos(): AuthStorageMulti {
const info = JSON.parse(localStorage.getItem(AUTH_LOGIN_INFO) || '{}');
return info;
}
/**
* Retrieve Login info object for the given bot
* @returns Login Info object
*/
private getLoginInfo(): AuthStorage {
const info = UserService.getAllLoginInfos();
if (this.botId in info && 'apiUrl' in info[this.botId] && 'refreshToken' in info[this.botId]) {
return info[this.botId];
}
return {
botName: '',
apiUrl: '',
refreshToken: '',
accessToken: '',
autoRefresh: false,
};
}
public static getAvailableBots(): BotDescriptors {
const allInfo = UserService.getAllLoginInfos();
const response: BotDescriptors = {};
Object.entries(allInfo).forEach(([k, v]) => {
response[k] = {
botId: k,
botName: v.botName,
botUrl: v.apiUrl,
};
});
return response;
}
public static getAvailableBotList(): string[] {
const allInfo = UserService.getAllLoginInfos();
return Object.keys(allInfo);
}
public getAutoRefresh(): boolean {
return this.getLoginInfo().autoRefresh;
}
public getAccessToken(): string {
return this.getLoginInfo().accessToken;
}
private getRefreshToken() {
return this.getLoginInfo().refreshToken;
}
public loggedIn() {
return this.getLoginInfo().refreshToken !== '';
}
private getAPIUrl(): string {
return this.getLoginInfo().apiUrl;
}
public logout(): void {
console.log('Logging out');
localStorage.removeItem(AUTH_REFRESH_TOKEN);
localStorage.removeItem(AUTH_ACCESS_TOKEN);
localStorage.removeItem(AUTH_API_URL);
},
async login(auth: AuthPayload) {
this.removeLoginInfo();
}
public async login(auth: AuthPayload) {
// Login using username / password
const result = await axios.post(
const { data } = await axios.post<{}, AxiosResponse<AuthResponse>>(
`${auth.url}/api/v1/token/login`,
{},
{
auth: { ...auth },
},
);
console.log(result.data);
this.setAPIUrl(auth.url);
if (result.data.access_token) {
this.setAccessToken(result.data.access_token);
if (data.access_token && data.refresh_token) {
const obj: AuthStorage = {
botName: auth.botName,
apiUrl: auth.url,
accessToken: data.access_token || '',
refreshToken: data.refresh_token || '',
autoRefresh: true,
};
this.storeLoginInfo(obj);
}
if (result.data.refresh_token) {
this.setRefreshTokens(result.data.refresh_token);
}
},
}
refreshToken(): Promise<string> {
public refreshToken(): Promise<string> {
console.log('Refreshing token...');
const token = JSON.parse(localStorage.getItem(AUTH_REFRESH_TOKEN) || '{}');
const token = this.getRefreshToken();
return new Promise((resolve, reject) => {
axios
.post(
`${this.getAPIUrl()}${apiBase}/token/refresh`,
.post<{}, AxiosResponse<AuthResponse>>(
`${this.getAPIUrl()}${APIBASE}/token/refresh`,
{},
{
headers: { Authorization: `Bearer ${token}` },
@ -78,22 +169,58 @@ export default {
}
});
});
},
}
loggedIn() {
return localStorage.getItem(AUTH_ACCESS_TOKEN) !== null;
},
public getBaseUrl(): string {
const baseURL = this.getAPIUrl();
if (baseURL === null) {
// Relative url
return APIBASE;
}
if (!baseURL.endsWith(APIBASE)) {
return `${baseURL}${APIBASE}`;
}
return `${baseURL}${APIBASE}`;
}
/**
* Call on startup to migrate old login info to new login
*/
public static migrateLogin() {
// TODO: this is actually never called!
const AUTH_REFRESH_TOKEN = 'auth_ref_token'; // Legacy key - do not use
const AUTH_ACCESS_TOKEN = 'auth_access_token';
const AUTH_API_URL = 'auth_api_url';
const AUTO_REFRESH = 'ft_auto_refresh';
getAPIUrl(): string {
const apiUrl = JSON.parse(localStorage.getItem(AUTH_API_URL) || '{}');
return typeof apiUrl === 'object' ? '' : apiUrl;
},
const refreshToken = JSON.parse(localStorage.getItem(AUTH_REFRESH_TOKEN) || '{}');
const accessToken = JSON.parse(localStorage.getItem(AUTH_ACCESS_TOKEN) || '{}');
const autoRefresh: boolean = JSON.parse(localStorage.getItem(AUTO_REFRESH) || '{}');
if (
typeof apiUrl === 'string' &&
typeof refreshToken === 'string' &&
typeof accessToken === 'string'
) {
const loginInfo: AuthStorage = {
botName: '',
apiUrl,
refreshToken,
accessToken,
autoRefresh,
};
const x = new UserService('ftbot.0');
x.storeLoginInfo(loginInfo);
}
getAccessToken(): string {
return JSON.parse(localStorage.getItem(AUTH_ACCESS_TOKEN) || '{}');
},
localStorage.removeItem(AUTH_REFRESH_TOKEN);
localStorage.removeItem(AUTH_ACCESS_TOKEN);
localStorage.removeItem(AUTH_API_URL);
localStorage.removeItem(AUTO_REFRESH);
}
}
getRefreshToken() {
return JSON.parse(localStorage.getItem(AUTH_REFRESH_TOKEN) || '{}');
},
};
export function useUserService(botId: string) {
const userservice = new UserService(botId);
return userservice;
}

View File

@ -1,26 +1,24 @@
import Vue from 'vue';
import Vuex from 'vuex';
import userService from '@/shared/userService';
import { getCurrentTheme, getTheme, storeCurrentTheme } from '@/shared/themes';
import axios, { AxiosInstance } from 'axios';
import ftbotModule, { BotStoreGetters } from './modules/ftbot';
import axios from 'axios';
import { UserService } from '@/shared/userService';
import createBotStore from './modules/botStoreWrapper';
import alertsModule from './modules/alerts';
import layoutModule from './modules/layout';
import settingsModule from './modules/settings';
const AUTO_REFRESH = 'ft_auto_refresh';
Vue.use(Vuex);
const initCurrentTheme = getCurrentTheme();
export default new Vuex.Store({
const store = new Vuex.Store({
modules: {
alerts: alertsModule,
layout: layoutModule,
uiSettings: settingsModule,
},
state: {
ping: '',
loggedIn: userService.loggedIn(),
refreshing: false,
autoRefresh: JSON.parse(localStorage.getItem(AUTO_REFRESH) || '{}'),
isBotOnline: false,
currentTheme: initCurrentTheme,
uiVersion: 'dev',
},
@ -38,31 +36,11 @@ export default new Vuex.Store({
getUiVersion(state) {
return state.uiVersion;
},
},
modules: {
ftbot: ftbotModule,
alerts: alertsModule,
layout: layoutModule,
uiSettings: settingsModule,
loggedIn(state, getters) {
return getters['ftbot/hasBots'];
},
},
mutations: {
setPing(state, ping) {
// console.log(ping);
const now = Date.now();
state.ping = `${ping.status} ${now.toString()}`;
},
setLoggedIn(state, loggedin: boolean) {
state.loggedIn = loggedin;
},
setAutoRefresh(state, newRefreshValue: boolean) {
state.autoRefresh = newRefreshValue;
},
setRefreshing(state, refreshing: boolean) {
state.refreshing = refreshing;
},
setIsBotOnline(state, isBotOnline: boolean) {
state.isBotOnline = isBotOnline;
},
mutateCurrentTheme(state, newTheme: string) {
storeCurrentTheme(newTheme);
state.currentTheme = newTheme;
@ -75,23 +53,10 @@ export default new Vuex.Store({
setCurrentTheme({ commit }, newTheme: string) {
commit('mutateCurrentTheme', newTheme);
},
setAutoRefresh({ commit }, newRefreshValue) {
commit('setAutoRefresh', newRefreshValue);
localStorage.setItem(AUTO_REFRESH, JSON.stringify(newRefreshValue));
},
setLoggedIn({ commit }, loggedin: boolean) {
commit('setLoggedIn', loggedin);
},
setIsBotOnline({ commit, dispatch }, isOnline) {
commit('setIsBotOnline', isOnline);
if (isOnline === false) {
console.log('disabling autorun');
dispatch('setAutoRefresh', false);
}
},
refreshOnce({ dispatch }) {
dispatch('ftbot/getVersion');
},
async loadUIVersion({ commit }) {
if (process.env.NODE_ENV !== 'development') {
try {
@ -104,50 +69,15 @@ export default new Vuex.Store({
}
}
},
async refreshAll({ dispatch, state, commit }, forceUpdate = false) {
if (state.refreshing) {
return;
}
commit('setRefreshing', true);
try {
const updates: Promise<AxiosInstance>[] = [];
updates.push(dispatch('refreshFrequent', false));
updates.push(dispatch('refreshSlow', forceUpdate));
updates.push(dispatch('ftbot/getDaily'));
updates.push(dispatch('ftbot/getBalance'));
await Promise.all(updates);
console.log('refreshing_end');
} finally {
commit('setRefreshing', false);
}
},
async refreshSlow({ dispatch, commit, getters, state }, forceUpdate = false) {
if (state.refreshing && !forceUpdate) {
return;
}
// Refresh data only when needed
if (forceUpdate || getters[`ftbot/${BotStoreGetters.refreshRequired}`]) {
const updates: Promise<AxiosInstance>[] = [];
updates.push(dispatch('ftbot/getPerformance'));
updates.push(dispatch('ftbot/getProfit'));
updates.push(dispatch('ftbot/getTrades'));
/* white/blacklist might be refreshed more often as they are not expensive on the backend */
updates.push(dispatch('ftbot/getWhitelist'));
updates.push(dispatch('ftbot/getBlacklist'));
await Promise.all(updates);
commit('ftbot/updateRefreshRequired', false);
}
},
refreshFrequent({ dispatch }, slow = true) {
if (slow) {
dispatch('refreshSlow', false);
}
// Refresh data that's needed in near realtime
dispatch('ftbot/getOpenTrades');
dispatch('ftbot/getState');
dispatch('ftbot/getLocks');
},
},
});
UserService.migrateLogin();
store.registerModule('ftbot', createBotStore(store));
Object.entries(UserService.getAvailableBots()).forEach(([k, v]) => {
store.dispatch('ftbot/addBot', v);
});
store.dispatch('ftbot/selectFirstBot');
store.dispatch('ftbot/startRefresh');
export default store;

View File

@ -0,0 +1,350 @@
import {
BotDescriptor,
BotDescriptors,
DailyPayload,
DailyRecord,
DailyReturnValue,
Trade,
} from '@/types';
import { AxiosInstance } from 'axios';
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) => {
const x = 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) {
state.availableBots[bot.botId] = bot;
},
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(['ftbot', bot.botId], createBotSubStore(bot.botId, bot.botName));
dispatch(`${bot.botId}/botAdded`);
commit('addBot', bot);
},
removeBot({ commit, getters, dispatch }, botId: string) {
if (Object.keys(getters.allAvailableBots).includes(botId)) {
dispatch(`${botId}/logout`);
store.unregisterModule([`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 }) {
if (state.refreshing) {
return;
}
commit('setRefreshing', true);
try {
// Ensure all bots status is correct.
await dispatch('pingAll');
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) => {
await dispatch(`${e}/ping`);
}),
);
},
allGetState({ getters, dispatch }) {
getters.allAvailableBotsList.forEach((e) => {
dispatch(`${e}/getState`);
});
},
allGetDaily({ getters, dispatch }, payload: DailyPayload) {
getters.allAvailableBotsList.forEach((e) => {
dispatch(`${e}/getDaily`, payload);
});
},
};
// 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

@ -17,6 +17,10 @@ import {
} from '@/types';
export interface FtbotStateType {
ping: string;
isBotOnline: boolean;
autoRefresh: boolean;
refreshing: boolean;
version: string;
lastLogs: LogLine[];
refreshRequired: boolean;
@ -54,41 +58,46 @@ export interface FtbotStateType {
backtestHistory: Record<string, StrategyBacktestResult>;
}
const state: FtbotStateType = {
version: '',
lastLogs: [],
refreshRequired: true,
trades: [],
openTrades: [],
tradeCount: 0,
performanceStats: [],
whitelist: [],
blacklist: [],
profit: {},
botState: undefined,
balance: {},
dailyStats: {},
pairlistMethods: [],
detailTradeId: undefined,
selectedPair: '',
candleData: {},
history: {},
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: {},
const state = (): FtbotStateType => {
return {
ping: '',
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: {},
history: {},
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: {},
};
};
export default state;

View File

@ -1,19 +1,17 @@
import { GridItemData } from 'vue-grid-layout';
export enum TradeLayout {
botControls = 'g-botControls',
multiPane = 'g-multiPane',
openTrades = 'g-openTrades',
tradeHistory = 'g-tradeHistory',
tradeDetail = 'g-tradeDetail',
logView = 'g-logView',
chartView = 'g-chartView',
}
export enum DashboardLayout {
KPI = 'g-kpi',
dailyChart = 'g-dailyChart',
hourlyChart = 'g-hourlyChart',
botComparison = 'g-botComparison',
allOpenTrades = 'g-allOpenTrades',
cumChartChart = 'g-cumChartChart',
tradesLogChart = 'g-TradesLogChart',
}
@ -40,29 +38,27 @@ export enum LayoutMutations {
}
// Define default layouts
const DEFAULT_TRADING_LAYOUT: GridItemData[] = [
{ i: TradeLayout.botControls, x: 0, y: 0, w: 3, h: 3 },
{ i: TradeLayout.multiPane, x: 0, y: 3, w: 3, h: 32 },
{ 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 },
{ i: TradeLayout.logView, x: 0, y: 35, w: 12, h: 13 },
];
const DEFAULT_DASHBOARD_LAYOUT: GridItemData[] = [
{ i: DashboardLayout.KPI, x: 0, y: 0, w: 4, h: 6 },
{ i: DashboardLayout.dailyChart, x: 4, y: 0, w: 4, h: 6 },
{ i: DashboardLayout.hourlyChart, x: 4, y: 6, w: 4, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 0, y: 6, w: 4, h: 6 },
{ 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.KPI, x: 0, y: 0, w: 12, h: 6 },
{ i: DashboardLayout.dailyChart, x: 0, y: 6, w: 12, h: 6 },
{ i: DashboardLayout.hourlyChart, x: 0, y: 12, w: 12, h: 6 },
{ i: DashboardLayout.cumChartChart, x: 0, y: 18, w: 12, h: 6 },
{ i: DashboardLayout.tradesLogChart, x: 0, y: 24, w: 12, h: 4 },
{ 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';

View File

@ -11,21 +11,25 @@ export enum OpenTradeVizOptions {
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() {
@ -37,7 +41,7 @@ function getSettings() {
}
const storedSettings = getSettings();
function updateSetting(key: string, value: string) {
function updateSetting(key: string, value: string | boolean) {
const settings = getSettings() || {};
settings[key] = value;
localStorage.setItem(STORE_UI_SETTINGS, JSON.stringify(settings));
@ -46,18 +50,22 @@ function updateSetting(key: string, value: string) {
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) {
[SettingsGetters.openTradesInTitle](state): string {
return state.openTradesInTitle;
},
[SettingsGetters.timezone](state) {
[SettingsGetters.timezone](state): string {
return state.timezone;
},
[SettingsGetters.backgroundSync](state): boolean {
return state.backgroundSync;
},
},
mutations: {
[SettingsMutations.setOpenTrades](state, value: string) {
@ -68,6 +76,10 @@ export default {
state.timezone = timezone;
updateSetting('timezone', timezone);
},
[SettingsMutations.setBackgroundSync](state, backgroundSync: boolean) {
state.backgroundSync = backgroundSync;
updateSetting('backgroundSync', backgroundSync);
},
},
actions: {
[SettingsActions.setOpenTradesInTitle]({ commit }, locked: boolean) {
@ -77,5 +89,8 @@ export default {
setTimezone(timezone);
commit(SettingsMutations.setTimeZone, timezone);
},
[SettingsActions.setBackgroundSync]({ commit }, timezone: string) {
commit(SettingsMutations.setBackgroundSync, timezone);
},
},
};

View File

@ -4,6 +4,13 @@
background-color: #000000;
}
.text-profit {
color: $color-profit;
}
.text-loss {
color: $color-loss;
}
[data-theme="dark"] {
$bg-dark: #3c3c3c;
$bg-darker: darken($bg-dark, 5%);

View File

@ -1,2 +1,5 @@
// variables created for the project and not overwrite of bootstrap
$fontsize-small: 0.9rem;
$fontsize-small: 0.8rem;
$color-profit: #12bb7b;
$color-loss: #ef5350;

View File

@ -1,5 +1,33 @@
export interface AuthPayload {
botName: string;
url: string;
username: string;
password: string;
}
export interface AuthResponse {
access_token?: string;
refresh_token?: string;
}
export interface AuthStorage {
botName: string;
apiUrl: string;
refreshToken: string;
accessToken: string;
autoRefresh: boolean;
}
export interface AuthStorageMulti {
[key: string]: AuthStorage;
}
export interface BotDescriptor {
botName: string;
botId: string;
botUrl: string;
}
export interface BotDescriptors {
[key: string]: BotDescriptor;
}

View File

@ -18,4 +18,14 @@ export interface BalanceInterface {
total: number;
/** Balance in FIAT currency */
value: number;
/** Assumed starting capital */
starting_capital: number;
/** Change between starting capital and current value */
starting_capital_ratio: number;
starting_capital_pct: number;
/** Assumed starting capital in FIAT currency */
starting_capital_fiat: number;
/** Change between starting capital and current value in fiat */
starting_capital_fiat_ratio: number;
starting_capital_fiat_pct: number;
}

View File

@ -2,3 +2,7 @@ export interface CumProfitData {
[date: string]: number;
profit: number;
}
export interface CumProfitDataPerDate {
[key: number]: CumProfitData;
}

View File

@ -1,4 +1,16 @@
export interface Trade {
/**
* corresponds to the UI (ftbot.1) - does NOT relate to the backend!
*/
botId: string;
/**
* Corresponds to the UI botID + tradeid. Does not relate to backend!
*/
botTradeId: string;
/**
* Given bot Name (in the UI). Does not relate to backend!
*/
botName: string;
trade_id: number;
pair: string;
is_open: boolean;

View File

@ -13,53 +13,6 @@
@layout-updated="layoutUpdated"
@breakpoint-changed="breakpointChanged"
>
<GridItem
:i="gridLayoutKPI.i"
:x="gridLayoutKPI.x"
:y="gridLayoutKPI.y"
:w="gridLayoutKPI.w"
:h="gridLayoutKPI.h"
:min-w="3"
:min-h="4"
drag-allow-from=".drag-header"
>
<DraggableContainer header="Bot KPI">
<b-card-group deck>
<b-card header="Open / Total trades">
<b-card-text>
<span class="text-primary">{{ openTrades.length }}</span> /
<span class="text">{{ profit.trade_count }}</span>
</b-card-text>
</b-card>
<b-card header="Won / lost trades">
<b-card-text>
<span class="text-success">{{ profit.winning_trades }}</span> /
<span class="text-danger">{{ profit.losing_trades }}</span>
</b-card-text>
</b-card>
<b-card header="Last trade">
<b-card-text>{{ profit.latest_trade_date }}</b-card-text>
</b-card>
</b-card-group>
<b-card-group deck class="mt-2">
<b-card header="Best performing">
<b-card-text>{{ profit.best_pair }}</b-card-text>
</b-card>
<b-card header="Total Balance">
<b-card-text
>{{ formatPrice(balance.total, botState.stake_currency_decimals || 8) }}
{{ dailyStats.stake_currency }}</b-card-text
>
</b-card>
<b-card v-if="profit.profit_closed_fiat" header="Total profit">
<b-card-text
>{{ formatPrice(profit.profit_closed_fiat, 2) }}
{{ dailyStats.fiat_display_currency }}</b-card-text
>
</b-card>
</b-card-group>
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutDaily.i"
:x="gridLayoutDaily.x"
@ -70,22 +23,40 @@
:min-h="4"
drag-allow-from=".drag-header"
>
<DraggableContainer header="Daily Profit">
<DailyChart v-if="dailyStats.data" :daily-stats="dailyStats" :show-title="false" />
<DraggableContainer :header="`Daily Profit ${botCount > 1 ? 'combined' : ''}`">
<DailyChart
v-if="allDailyStatsAllBots"
:daily-stats="allDailyStatsAllBots"
:show-title="false"
/>
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutHourly.i"
:x="gridLayoutHourly.x"
:y="gridLayoutHourly.y"
:w="gridLayoutHourly.w"
:h="gridLayoutHourly.h"
:i="gridLayoutBotComparison.i"
:x="gridLayoutBotComparison.x"
:y="gridLayoutBotComparison.y"
:w="gridLayoutBotComparison.w"
:h="gridLayoutBotComparison.h"
:min-w="3"
:min-h="4"
drag-allow-from=".drag-header"
>
<DraggableContainer header="Hourly Profit">
<HourlyChart :trades="closedTrades" :show-title="false" />
<DraggableContainer header="Bot comparison">
<bot-comparison-list />
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutAllOpenTrades.i"
:x="gridLayoutAllOpenTrades.x"
:y="gridLayoutAllOpenTrades.y"
:w="gridLayoutAllOpenTrades.w"
:h="gridLayoutAllOpenTrades.h"
:min-w="3"
:min-h="4"
drag-allow-from=".drag-header"
>
<DraggableContainer header="Open Trades">
<trade-list :active-trades="true" :trades="allOpenTradesAllBots" multi-bot-view />
</DraggableContainer>
</GridItem>
<GridItem
@ -99,7 +70,7 @@
drag-allow-from=".drag-header"
>
<DraggableContainer header="Cumulative Profit">
<CumProfitChart :trades="closedTrades" :show-title="false" />
<CumProfitChart :trades="allTradesAllBots" :show-title="false" />
</DraggableContainer>
</GridItem>
<GridItem
@ -113,7 +84,7 @@
drag-allow-from=".drag-header"
>
<DraggableContainer header="Trades Log">
<TradesLogChart :trades="closedTrades" :show-title="false" />
<TradesLogChart :trades="allTradesAllBots" :show-title="false" />
</DraggableContainer>
</GridItem>
</GridLayout>
@ -127,9 +98,10 @@ import { namespace } from 'vuex-class';
import { GridLayout, GridItem, GridItemData } from 'vue-grid-layout';
import DailyChart from '@/components/charts/DailyChart.vue';
import HourlyChart from '@/components/charts/HourlyChart.vue';
import CumProfitChart from '@/components/charts/CumProfitChart.vue';
import TradesLogChart from '@/components/charts/TradesLog.vue';
import BotComparisonList from '@/components/ftbot/BotComarisonList.vue';
import TradeList from '@/components/ftbot/TradeList.vue';
import DraggableContainer from '@/components/layout/DraggableContainer.vue';
import {
@ -138,15 +110,9 @@ import {
LayoutActions,
LayoutGetters,
} from '@/store/modules/layout';
import {
Trade,
DailyReturnValue,
BalanceInterface,
ProfitInterface,
DailyPayload,
BotState,
} from '@/types';
import { Trade, DailyReturnValue, DailyPayload, ClosedTrade } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
import { MultiBotStoreGetters } from '@/store/modules/botStoreWrapper';
const ftbot = namespace('ftbot');
const layoutNs = namespace('layout');
@ -156,34 +122,35 @@ const layoutNs = namespace('layout');
GridLayout,
GridItem,
DailyChart,
HourlyChart,
CumProfitChart,
TradesLogChart,
BotComparisonList,
TradeList,
DraggableContainer,
},
})
export default class Dashboard extends Vue {
@ftbot.Getter closedTrades!: Trade[];
@ftbot.Getter [MultiBotStoreGetters.botCount]!: number;
@ftbot.Getter [BotStoreGetters.dailyStats]!: DailyReturnValue;
@ftbot.Getter [MultiBotStoreGetters.allOpenTradesAllBots]!: Trade[];
@ftbot.Getter [BotStoreGetters.openTrades]!: Array<Trade>;
@ftbot.Getter [MultiBotStoreGetters.allTradesAllBots]!: ClosedTrade[];
@ftbot.Getter [BotStoreGetters.balance]!: BalanceInterface;
@ftbot.Getter [BotStoreGetters.botState]?: BotState;
@ftbot.Getter [BotStoreGetters.profit]!: ProfitInterface;
@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 getDaily!: (payload?: DailyPayload) => void;
@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[];
@ -192,12 +159,6 @@ export default class Dashboard extends Vue {
@layoutNs.Getter [LayoutGetters.getLayoutLocked]: boolean;
@ftbot.Action getOpenTrades;
@ftbot.Action getBalance;
@ftbot.Action getProfit;
formatPrice = formatPrice;
localGridLayout: GridItemData[] = [];
@ -232,16 +193,16 @@ export default class Dashboard extends Vue {
}
}
get gridLayoutKPI(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.KPI);
}
get gridLayoutDaily(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.dailyChart);
}
get gridLayoutHourly(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.hourlyChart);
get gridLayoutBotComparison(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.botComparison);
}
get gridLayoutAllOpenTrades(): GridItemData {
return findGridLayout(this.gridLayout, DashboardLayout.allOpenTrades);
}
get gridLayoutCumChart(): GridItemData {
@ -259,10 +220,9 @@ export default class Dashboard extends Vue {
}
mounted() {
this.getDaily({ timescale: 30 });
this.allGetDaily({ timescale: 30 });
this.getTrades();
this.getOpenTrades();
this.getBalance();
this.getPerformance();
this.getProfit();
this.localGridLayout = [...this.getDashboardLayoutSm];

View File

@ -1,5 +1,9 @@
<template>
<div class="home">
<div class="container col-12 col-sm-6 col-lg-4">
<bot-list />
</div>
<hr />
<!-- <img alt="Freqtrade logo" src="../assets/freqtrade-logo.png" width="450px" class="my-5" /> -->
<div alt="Freqtrade logo" class="logo-svg my-5 mx-auto" />
<div>
@ -19,7 +23,11 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
import BotList from '@/components/BotList.vue';
@Component({
components: { BotList },
})
export default class Home extends Vue {}
</script>

View File

@ -1,14 +1,14 @@
<template>
<div>
<b-button v-b-modal.modal-prevent-closing>Login</b-button>
<b-modal id="modal-prevent-closing" ref="modal" title="Submit Your Name" @ok="handleOk">
<Login id="loginForm" ref="loginForm" in-modal />
<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">
<Login id="loginForm" ref="loginForm" in-modal @loginResult="handleLoginResult" />
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from 'vue-property-decorator';
import Login from '@/components/Login.vue';
@ -18,12 +18,21 @@ import Login from '@/components/Login.vue';
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();

View File

@ -22,6 +22,9 @@
>
<b-form-select v-model="timezoneLoc" :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-form-group>
</div>
</b-card>
</div>
@ -46,10 +49,14 @@ export default class Template extends Vue {
@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' },
@ -82,6 +89,14 @@ export default class Template extends Vue {
set layoutLockedLocal(value: boolean) {
this.setLayoutLocked(value);
}
get backgroundSyncLocal(): boolean {
return this.backgroundSync;
}
set backgroundSyncLocal(value: boolean) {
this.setBackgroundSync(value);
}
}
</script>

View File

@ -9,19 +9,6 @@
:is-draggable="!getLayoutLocked"
@layout-updated="layoutUpdatedEvent"
>
<GridItem
:i="gridLayoutBotControls.i"
:x="gridLayoutBotControls.x"
:y="gridLayoutBotControls.y"
:w="gridLayoutBotControls.w"
:h="gridLayoutBotControls.h"
drag-allow-from=".card-header"
>
<DraggableContainer header="Bot Controls">
<ReloadControl class="mt-2" />
<BotControls />
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutMultiPane.i"
:x="gridLayoutMultiPane.x"
@ -31,11 +18,14 @@
drag-allow-from=".card-header"
>
<DraggableContainer header="Multi Pane">
<b-tabs content-class="mt-3" class="mt-3">
<b-tabs content-class="mt-3" class="mt-1">
<b-tab title="Pairs combined" active>
<PairSummary :pairlist="whitelist" :current-locks="currentLocks" :trades="openTrades" />
</b-tab>
<b-tab title="Status">
<b-tab title="General">
<div class="d-flex justify-content-center">
<BotControls class="mt-1 mb-2" />
</div>
<BotStatus />
</b-tab>
<b-tab title="Performance">
@ -106,18 +96,6 @@
<TradeDetail :trade="tradeDetail"> </TradeDetail>
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutLogView.i"
:x="gridLayoutLogView.x"
:y="gridLayoutLogView.y"
:w="gridLayoutLogView.w"
:h="gridLayoutLogView.h"
drag-allow-from=".card-header"
>
<DraggableContainer header="Logs">
<LogViewer />
</DraggableContainer>
</GridItem>
<GridItem
:i="gridLayoutChartView.i"
:x="gridLayoutChartView.x"
@ -145,20 +123,18 @@ import { Component, Vue } from 'vue-property-decorator';
import { namespace } from 'vuex-class';
import { GridLayout, GridItem, GridItemData } from 'vue-grid-layout';
import TradeList from '@/components/ftbot/TradeList.vue';
import Performance from '@/components/ftbot/Performance.vue';
import Balance from '@/components/ftbot/Balance.vue';
import BotControls from '@/components/ftbot/BotControls.vue';
import BotStatus from '@/components/ftbot/BotStatus.vue';
import Balance from '@/components/ftbot/Balance.vue';
import CandleChartContainer from '@/components/charts/CandleChartContainer.vue';
import DailyStats from '@/components/ftbot/DailyStats.vue';
import DraggableContainer from '@/components/layout/DraggableContainer.vue';
import FTBotAPIPairList from '@/components/ftbot/FTBotAPIPairList.vue';
import PairLockList from '@/components/ftbot/PairLockList.vue';
import PairSummary from '@/components/ftbot/PairSummary.vue';
import Performance from '@/components/ftbot/Performance.vue';
import TradeDetail from '@/components/ftbot/TradeDetail.vue';
import ReloadControl from '@/components/ftbot/ReloadControl.vue';
import LogViewer from '@/components/ftbot/LogViewer.vue';
import DraggableContainer from '@/components/layout/DraggableContainer.vue';
import CandleChartContainer from '@/components/charts/CandleChartContainer.vue';
import TradeList from '@/components/ftbot/TradeList.vue';
import { Lock, Trade } from '@/types';
import { BotStoreGetters } from '@/store/modules/ftbot';
@ -169,22 +145,20 @@ const layoutNs = namespace('layout');
@Component({
components: {
GridLayout,
GridItem,
DraggableContainer,
TradeList,
Performance,
Balance,
BotControls,
BotStatus,
Balance,
CandleChartContainer,
DailyStats,
DraggableContainer,
FTBotAPIPairList,
GridItem,
GridLayout,
PairLockList,
PairSummary,
Performance,
TradeDetail,
ReloadControl,
LogViewer,
CandleChartContainer,
TradeList,
},
})
export default class Trading extends Vue {
@ -214,10 +188,6 @@ export default class Trading extends Vue {
return this.getTradingLayout;
}
get gridLayoutBotControls(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.botControls);
}
get gridLayoutMultiPane(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.multiPane);
}
@ -234,10 +204,6 @@ export default class Trading extends Vue {
return findGridLayout(this.gridLayout, TradeLayout.tradeDetail);
}
get gridLayoutLogView(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.logView);
}
get gridLayoutChartView(): GridItemData {
return findGridLayout(this.gridLayout, TradeLayout.chartView);
}