Merge pull request #630 from freqtrade/mobile_view

Mobile view
This commit is contained in:
Matthias 2022-01-01 17:29:34 +01:00 committed by GitHub
commit 07bae3ae6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 660 additions and 109 deletions

View File

@ -13,8 +13,12 @@
"cy:open-ct": "cypress open-ct",
"cy:run-ct": "cypress run-ct"
},
"resolutions": {
"vue-demi": "0.12.1"
},
"dependencies": {
"@cypress/vue": "^2.2.3",
"@vue/composition-api": "^1.4.3",
"axios": "^0.24.0",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.21.2",
@ -27,6 +31,7 @@
"humanize-duration": "^3.27.1",
"vue": "^2.6.14",
"vue-class-component": "^7.2.5",
"vue-demi": "0.12.1",
"vue-echarts": "^6.0.0",
"vue-grid-layout": "^2.3.12",
"vue-material-design-icons": "^5.0.0",
@ -34,7 +39,8 @@
"vue-router": "^3.5.3",
"vue-select": "^3.16.0",
"vuex": "^3.6.2",
"vuex-class": "^0.3.2"
"vuex-class": "^0.3.2",
"vuex-composition-helpers": "^1.1.0"
},
"devDependencies": {
"@cypress/webpack-dev-server": "^1.8.0",
@ -49,7 +55,6 @@
"@vue/cli-plugin-unit-jest": "~4.5.15",
"@vue/cli-plugin-vuex": "~4.5.15",
"@vue/cli-service": "~4.5.15",
"@vue/composition-api": "^1.4.3",
"@vue/eslint-config-airbnb": "^5.1.0",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.1.0",

View File

@ -2,12 +2,14 @@
<div id="app" class="d-flex flex-column vh-100">
<NavBar />
<Body class="flex-fill overflow-auto" />
<NavFooter />
</div>
</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';
@ -17,7 +19,7 @@ import StoreModules from './store/storeSubModules';
const uiSettingsNs = namespace(StoreModules.uiSettings);
@Component({
components: { NavBar, Body },
components: { NavBar, Body, NavFooter },
})
export default class App extends Vue {
@uiSettingsNs.Getter [SettingsGetters.timezone]: string;

View File

@ -81,7 +81,7 @@ export default class CumProfitChart extends Vue {
resD[trade.close_timestamp][trade.botId] = profit;
}
}
console.log(trade.close_date, profit);
// 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);

View File

@ -3,10 +3,10 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { Getter } from 'vuex-class';
import { useGetters } from 'vuex-composition-helpers';
import { ref, defineComponent, computed, ComputedRef } from '@vue/composition-api';
import ECharts from 'vue-echarts';
import { EChartsOption } from 'echarts';
// import { EChartsOption } from 'echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
@ -38,46 +38,46 @@ use([
const CHART_ABS_PROFIT = 'Absolute profit';
const CHART_TRADE_COUNT = 'Trade Count';
@Component({
export default defineComponent({
components: {
'v-chart': ECharts,
},
})
export default class DailyChart extends Vue {
@Prop({ required: true }) dailyStats!: DailyReturnValue;
props: {
dailyStats: {
type: Object as () => DailyReturnValue,
required: true,
},
showTitle: {
type: Boolean,
default: true,
},
},
@Prop({ default: true, type: Boolean }) showTitle!: boolean;
@Getter getChartTheme!: string;
get absoluteMin() {
return Number(
this.dailyStats.data.reduce(
setup(props) {
const { getChartTheme } = useGetters(['getChartTheme']);
const absoluteMin: ComputedRef<number> = computed(() =>
props.dailyStats.data.reduce(
(min, p) => (p.abs_profit < min ? p.abs_profit : min),
this.dailyStats.data[0]?.abs_profit,
props.dailyStats.data[0]?.abs_profit,
),
);
}
get absoluteMax() {
return Number(
this.dailyStats.data.reduce(
const absoluteMax: ComputedRef<number> = computed(() =>
props.dailyStats.data.reduce(
(max, p) => (p.abs_profit > max ? p.abs_profit : max),
this.dailyStats.data[0]?.abs_profit,
props.dailyStats.data[0]?.abs_profit,
),
);
}
get dailyChartOptions(): EChartsOption {
return {
// : Ref<EChartsOption>
const dailyChartOptions = ref({
title: {
text: 'Daily profit',
show: this.showTitle,
show: props.showTitle,
},
backgroundColor: 'rgba(0, 0, 0, 0)',
dataset: {
dimensions: ['date', 'abs_profit', 'trade_count'],
source: this.dailyStats.data,
source: props.dailyStats.data,
},
tooltip: {
trigger: 'axis',
@ -92,10 +92,12 @@ export default class DailyChart extends Vue {
data: [CHART_ABS_PROFIT, CHART_TRADE_COUNT],
right: '5%',
},
xAxis: {
xAxis: [
{
type: 'category',
inverse: true,
},
],
visualMap: [
{
dimension: 1,
@ -104,12 +106,12 @@ export default class DailyChart extends Vue {
pieces: [
{
max: 0.0,
min: this.absoluteMin,
min: absoluteMin.value,
color: 'red',
},
{
min: 0.0,
max: this.absoluteMax,
max: absoluteMax.value,
color: 'green',
},
],
@ -149,9 +151,14 @@ export default class DailyChart extends Vue {
yAxisIndex: 1,
},
],
});
return {
dailyChartOptions,
getChartTheme,
};
}
}
},
});
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,174 @@
<template>
<div class="h-100 overflow-auto p-1">
<b-list-group id="tradeList">
<b-list-group-item
v-for="trade in filteredTrades"
:key="trade.trade_id"
class="border border-secondary rounded my-05 px-1"
@click="tradeClick(trade)"
>
<CustomTradeListEntry :trade="trade" :stake-currency-decimals="stakeCurrencyDecimals" />
</b-list-group-item>
</b-list-group>
<span v-if="trades.length == 0" class="mt-5">{{ emptyText }}</span>
<div class="w-100 d-flex justify-content-between mt-1">
<b-pagination
v-if="!activeTrades"
v-model="currentPage"
:total-rows="rows"
:per-page="perPage"
aria-controls="tradeList"
></b-pagination>
<b-input
v-if="showFilter"
v-model="filterText"
type="text"
placeholder="Filter"
size="sm"
style="width: unset"
/>
</div>
</div>
</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 { 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';
const ftbot = namespace(StoreModules.ftbot);
@Component({
components: {
DeleteIcon,
ForceSellIcon,
ActionIcon,
DateTimeTZ,
TradeProfit,
CustomTradeListEntry,
},
})
export default class CustomTradeList extends Vue {
$refs!: {
tradesTable: 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;
@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;
}
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));
}
});
}
tradeClick(trade) {
this.setDetailTrade(trade);
}
}
</script>
<style lang="scss" scoped>
.my-05 {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<div class="d-flex">
<div
class="px-1 d-flex flex-row flex-fill text-left justify-content-between align-items-center"
>
<span>
<span class="mr-1 font-weight-bold">{{ trade.pair }}</span>
<small class="text-secondary">(#{{ trade.trade_id }})</small>
</span>
<small>
<DateTimeTZ :date="trade.open_timestamp" :date-only="true" title="open Date" />
</small>
</div>
<trade-profit class="col-5" :trade="trade" />
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { formatPercent, formatPrice } from '@/shared/formatters';
import { Trade } from '@/types';
import DateTimeTZ from '@/components/general/DateTimeTZ.vue';
import TradeProfit from './TradeProfit.vue';
export default defineComponent({
components: {
DateTimeTZ,
TradeProfit,
},
props: {
trade: {
type: Object as () => Trade,
required: true,
},
stakeCurrencyDecimals: {
type: Number,
required: true,
},
showDetails: {
type: Boolean,
default: false,
},
},
setup() {
return {
formatPrice,
formatPercent,
};
},
});
</script>
<style lang="scss" scoped>
.card-body {
padding: 0 0.2em;
}
.table-sm {
font-size: $fontsize-small;
}
.btn-xs {
padding: 0.1rem 0.25rem;
font-size: 0.75rem;
}
</style>

View File

@ -17,7 +17,11 @@
</div>
<TradeProfit v-if="comb.trade && !backtestMode" :trade="comb.trade" />
<ProfitPill v-if="backtestMode && comb.tradeCount > 0" :profit-ratio="comb.profit" />
<ProfitPill
v-if="backtestMode && comb.tradeCount > 0"
:profit-ratio="comb.profit"
:stake-currency="stakeCurrency"
/>
</b-list-group-item>
</b-list-group>
</template>
@ -63,6 +67,8 @@ export default class PairSummary extends Vue {
@ftbot.Getter [BotStoreGetters.selectedPair]!: string;
@ftbot.Getter [BotStoreGetters.stakeCurrency]!: string;
timestampms = timestampms;
formatPercent = formatPercent;

View File

@ -0,0 +1,69 @@
<template>
<div class="d-flex flex-column">
<b-button
v-if="botApiVersion <= 1.1"
class="btn-xs text-left"
size="sm"
title="Forcesell"
@click="$emit('forceSell', trade)"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell
</b-button>
<b-button
v-if="botApiVersion > 1.1"
class="btn-xs text-left"
size="sm"
title="Forcesell limit"
@click="$emit('forceSell', trade, 'limit')"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell limit
</b-button>
<b-button
v-if="botApiVersion > 1.1"
class="btn-xs text-left mt-1"
size="sm"
title="Forcesell market"
@click="$emit('forceSell', trade, 'market')"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell market
</b-button>
<b-button
class="btn-xs text-left mt-1"
size="sm"
title="Delete trade"
@click="$emit('deleteTrade', trade)"
>
<DeleteIcon :size="16" title="Delete trade" class="mr-1" />
Delete
</b-button>
</div>
</template>
<script lang="ts">
import { Trade } from '@/types';
import { defineComponent } from '@vue/composition-api';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import ForceSellIcon from 'vue-material-design-icons/CloseBoxMultiple.vue';
export default defineComponent({
name: 'TradeActions',
components: { DeleteIcon, ForceSellIcon },
props: {
botApiVersion: {
type: Number,
default: 1.0,
},
trade: {
type: Object as () => Trade,
required: true,
},
},
emits: ['forceSell', 'deleteTrade'],
setup() {
return {};
},
});
</script>
<style scoped lang="scss"></style>

View File

@ -5,6 +5,9 @@
<h5 class="detail-header">General</h5>
<ValuePair description="TradeId">{{ trade.trade_id }}</ValuePair>
<ValuePair description="Pair">{{ trade.pair }}</ValuePair>
<ValuePair description="Stake">{{
formatPriceCurrency(trade.stake_amount, stakeCurrency)
}}</ValuePair>
<ValuePair description="Open date">{{ timestampms(trade.open_timestamp) }}</ValuePair>
<ValuePair v-if="trade.buy_tag" description="Buy tag">{{ trade.buy_tag }}</ValuePair>
<ValuePair description="Open Rate">{{ formatPrice(trade.open_rate) }}</ValuePair>
@ -24,10 +27,10 @@
v-if="trade.profit_ratio && trade.profit_abs"
:description="`${trade.is_open ? 'Current Profit' : 'Close Profit'}`"
>
{{ formatPercent(trade.profit_ratio) }} | {{ trade.profit_abs }}
<trade-profit class="ml-2" :trade="trade" />
</ValuePair>
</div>
<div class="col-lg-7">
<div class="mt-2 mt-lg-0 col-lg-7">
<h5 class="detail-header">Stoploss</h5>
<ValuePair description="Stoploss">
{{ formatPercent(trade.stop_loss_pct / 100) }} |
@ -57,21 +60,26 @@
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { formatPercent, formatPrice, timestampms } from '@/shared/formatters';
import { formatPercent, formatPriceCurrency, formatPrice, timestampms } from '@/shared/formatters';
import ValuePair from '@/components/general/ValuePair.vue';
import TradeProfit from '@/components/ftbot/TradeProfit.vue';
import { Trade } from '@/types';
@Component({
components: { ValuePair },
components: { ValuePair, TradeProfit },
})
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;
}
</script>
<style scoped>

View File

@ -24,45 +24,12 @@
<ActionIcon :size="16" title="Actions" />
</b-button>
<b-popover :target="`btn-actions_${row.index}`" triggers="focus" placement="left">
<div class="d-flex flex-column">
<b-button
v-if="botApiVersion <= 1.1"
class="btn-xs text-left"
size="sm"
title="Forcesell"
@click="forcesellHandler(row.item)"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell
</b-button>
<b-button
v-if="botApiVersion > 1.1"
class="btn-xs text-left"
size="sm"
title="Forcesell limit"
@click="forcesellHandler(row.item, 'limit')"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell limit
</b-button>
<b-button
v-if="botApiVersion > 1.1"
class="btn-xs text-left mt-1"
size="sm"
title="Forcesell market"
@click="forcesellHandler(row.item, 'market')"
>
<ForceSellIcon :size="16" title="Forcesell" class="mr-1" />Forcesell market
</b-button>
<b-button
class="btn-xs text-left mt-1"
size="sm"
title="Delete trade"
@click="removeTradeHandler(row.item)"
>
<DeleteIcon :size="16" title="Delete trade" class="mr-1" />
Delete
</b-button>
</div>
<trade-actions
:trade="row.item"
:bot-api-version="botApiVersion"
@deleteTrade="removeTradeHandler"
@forceSell="forcesellHandler"
/>
</b-popover>
</template>
<template #cell(pair)="row">
@ -118,11 +85,12 @@ 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);
@Component({
components: { DeleteIcon, ForceSellIcon, ActionIcon, DateTimeTZ, TradeProfit },
components: { DeleteIcon, ForceSellIcon, ActionIcon, DateTimeTZ, TradeProfit, TradeActions },
})
export default class TradeList extends Vue {
$refs!: {

View File

@ -3,7 +3,7 @@
</template>
<script lang="ts">
import { timestampms, timestampmsWithTimezone } from '@/shared/formatters';
import { timestampms, timestampmsWithTimezone, timestampToDateString } from '@/shared/formatters';
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({})
@ -12,9 +12,14 @@ export default class DateTimeTZ extends Vue {
@Prop({ required: false, type: Boolean, default: false }) showTimezone!: boolean;
@Prop({ required: false, type: Boolean, default: false }) dateOnly!: boolean;
timestampms = timestampms;
get formattedDate(): string {
if (this.dateOnly) {
return timestampToDateString(this.date);
}
if (this.showTimezone) {
return timestampmsWithTimezone(this.date);
}

View File

@ -1,5 +1,5 @@
<template>
<div class="h-100 d-inline-block">
<div class="d-inline-block">
<div :class="isProfitable ? 'triangle-up' : 'triangle-down'"></div>
</div>
</template>

View File

@ -19,7 +19,7 @@ export default Vue.extend({
},
classLabel: {
type: String,
default: 'col-4 font-weight-bold',
default: 'col-4 font-weight-bold mb-0',
},
classValue: {
type: String,

View File

@ -1,5 +1,5 @@
<template>
<main class="container-fluid container-main">
<main>
<BotAlerts />
<router-view />
</main>

View File

@ -18,7 +18,7 @@
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/dashboard"
>Dashboard</router-link
>
<router-link class="nav-link navbar-nav" to="/graph">Graph</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"
>Backtest</router-link
@ -75,7 +75,6 @@
<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' }}
@ -86,13 +85,6 @@
</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"

View File

@ -0,0 +1,57 @@
<template>
<footer class="d-md-none">
<!-- 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">
<OpenTradesIcon />
Trades
</router-link>
<router-link v-if="!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">
<PairListIcon />
Pairlist
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/balance">
<BalanceIcon />
Balance
</router-link>
<router-link v-if="!canRunBacktest" class="nav-link navbar-nav" to="/dashboard">
<DashboardIcon />
Dashboard
</router-link>
</div>
</footer>
</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';
const ftbot = namespace('ftbot');
@Component({
components: { OpenTradesIcon, ClosedTradesIcon, BalanceIcon, PairListIcon, DashboardIcon },
})
export default class NavFooter extends Vue {
@ftbot.Getter [BotStoreGetters.canRunBacktest]!: boolean;
}
</script>
<style lang="scss" scoped>
[data-theme='dark'] {
.router-link-active,
.nav-link:active {
color: white !important;
}
}
</style>

View File

@ -1,5 +1,6 @@
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';

View File

@ -0,0 +1,4 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
Vue.use(VueCompositionAPI);

View File

@ -42,6 +42,26 @@ const routes: Array<RouteConfig> = [
name: 'Freqtrade Dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue'),
},
{
path: '/balance',
name: 'Freqtrade Balance',
component: () => import(/* webpackChunkName: "balance" */ '@/components/ftbot/Balance.vue'),
},
{
path: '/open_trades',
component: () => import(/* webpackChunkName: "trades" */ '@/views/TradesList.vue'),
},
{
path: '/trade_history',
component: () => import(/* webpackChunkName: "trades" */ '@/views/TradesList.vue'),
props: { history: true },
},
{
path: '/pairlist',
component: () =>
import(/* webpackChunkName: "pairlist" */ '@/components/ftbot/FTBotAPIPairList.vue'),
},
{
path: '/settings',
name: 'Freqtrade Settings',

View File

@ -20,6 +20,17 @@ export function formatPrice(value: number, decimals = 8): string {
return !isUndefined(value) ? parseFloat(value.toFixed(decimals)).toString() : '';
}
/**
* Formats price in the format "<price> <StakeCurrency>" using "deciaml" decimals
* @param price Price to format
* @param currency currency to use
* @param decimals Decimals
* @returns
*/
export function formatPriceCurrency(price, currency: string, decimals = 3) {
return `${formatPrice(price, decimals)} ${currency}`;
}
export function dateFromString(datestring: string, format: string): Date {
return parse(datestring, format, 0);
}

View File

@ -316,7 +316,11 @@ export default function createBotStore(store) {
async pingAll({ getters, dispatch }) {
await Promise.all(
getters.allAvailableBotsList.map(async (e) => {
try {
await dispatch(`${e}/ping`);
} catch {
// pass
}
}),
);
},

View File

@ -76,6 +76,7 @@ export enum BotStoreGetters {
selectedBacktestResult = 'selectedBacktestResult',
canRunBacktest = 'canRunBacktest',
stakeCurrencyDecimals = 'stakeCurrencyDecimals',
stakeCurrency = 'stakeCurrency',
strategyPlotConfig = 'strategyPlotConfig',
version = 'version',
botApiVersion = 'botApiVersion',
@ -251,6 +252,9 @@ export function createBotSubStore(botId: string, botName: string) {
[BotStoreGetters.stakeCurrencyDecimals](state: FtbotStateType): number {
return state.botState?.stake_currency_decimals || 3;
},
[BotStoreGetters.stakeCurrency](state: FtbotStateType): string {
return state.botState?.stake_currency || '';
},
[BotStoreGetters.strategyPlotConfig](state: FtbotStateType): PlotConfig | undefined {
return state.strategyPlotConfig;
},
@ -458,6 +462,7 @@ export function createBotSubStore(botId: string, botName: string) {
return Promise.resolve();
} catch (error) {
//
commit('setIsBotOnline', false);
return Promise.reject();
}
},

View File

@ -19,6 +19,7 @@ export enum DashboardLayout {
export enum LayoutGetters {
getDashboardLayoutSm = 'getDashboardLayoutSm',
getDashboardLayout = 'getDashboardLayout',
getTradingLayoutSm = 'getTradingLayoutSm',
getTradingLayout = 'getTradingLayout',
getLayoutLocked = 'getLayoutLocked',
}
@ -45,6 +46,15 @@ const DEFAULT_TRADING_LAYOUT: GridItemData[] = [
{ 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 },
@ -110,6 +120,9 @@ export default {
[LayoutGetters.getDashboardLayout](state) {
return state.dashboardLayout;
},
[LayoutGetters.getTradingLayoutSm]() {
return [...DEFAULT_TRADING_LAYOUT_SM];
},
[LayoutGetters.getTradingLayout](state) {
return state.tradingLayout;
},

View File

@ -10,6 +10,7 @@
:is-draggable="!isLayoutLocked"
:responsive="true"
:prevent-collision="true"
:cols="{ lg: 12, md: 12, sm: 12, xs: 4, xxs: 2 }"
@layout-updated="layoutUpdated"
@breakpoint-changed="breakpointChanged"
>
@ -171,7 +172,7 @@ export default class Dashboard extends Vue {
}
get isResizableLayout() {
return ['', 'md', 'lg', 'xl'].includes(this.currentBreakpoint);
return ['', 'sm', 'md', 'lg', 'xl'].includes(this.currentBreakpoint);
}
get gridLayout() {

73
src/views/TradesList.vue Normal file
View File

@ -0,0 +1,73 @@
<template>
<div>
<!-- <TradeList
class="open-trades"
:trades="openTrades"
title="Open trades"
:active-trades="true"
empty-text="Currently no open trades."
/> -->
<CustomTradeList
v-if="!history && !detailTradeId"
:trades="openTrades"
title="Open trades"
:active-trades="true"
:stake-currency-decimals="stakeCurrencyDecimals"
empty-text="No open Trades."
/>
<CustomTradeList
v-if="history && !detailTradeId"
:trades="closedTrades"
title="Trade history"
:stake-currency-decimals="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)"
><BackIcon /> Back</b-button
>
<TradeDetail :trade="tradeDetail" :stake-currency="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 { 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({
components: {
CustomTradeList,
TradeDetail,
BackIcon,
},
})
export default class TradesList extends Vue {
@Prop({ default: false }) history!: boolean;
@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;
}
</script>
<style scoped></style>

View File

@ -5,11 +5,16 @@
:layout="gridLayout"
:vertical-compact="false"
:margin="[5, 5]"
:is-resizable="!getLayoutLocked"
:is-draggable="!getLayoutLocked"
:responsive-layouts="responsiveGridLayouts"
:is-resizable="!isLayoutLocked"
:is-draggable="!isLayoutLocked"
:responsive="true"
:cols="{ lg: 12, md: 12, sm: 12, xs: 4, xxs: 2 }"
@layout-updated="layoutUpdatedEvent"
@breakpoint-changed="breakpointChanged"
>
<GridItem
v-if="gridLayoutMultiPane.h != 0"
:i="gridLayoutMultiPane.i"
:x="gridLayoutMultiPane.x"
:y="gridLayoutMultiPane.y"
@ -48,6 +53,7 @@
</DraggableContainer>
</GridItem>
<GridItem
v-if="gridLayoutOpenTrades.h != 0"
:i="gridLayoutOpenTrades.i"
:x="gridLayoutOpenTrades.x"
:y="gridLayoutOpenTrades.y"
@ -66,6 +72,7 @@
</DraggableContainer>
</GridItem>
<GridItem
v-if="gridLayoutTradeHistory.h != 0"
:i="gridLayoutTradeHistory.i"
:x="gridLayoutTradeHistory.x"
:y="gridLayoutTradeHistory.y"
@ -78,12 +85,13 @@
class="trade-history"
:trades="closedTrades"
title="Trade history"
:show-filter="true"
empty-text="No closed trades so far."
/>
</DraggableContainer>
</GridItem>
<GridItem
v-if="detailTradeId"
v-if="detailTradeId && gridLayoutTradeDetail.h != 0"
:i="gridLayoutTradeDetail.i"
:x="gridLayoutTradeDetail.x"
:y="gridLayoutTradeDetail.y"
@ -93,10 +101,11 @@
drag-allow-from=".card-header"
>
<DraggableContainer header="Trade Detail">
<TradeDetail :trade="tradeDetail"> </TradeDetail>
<TradeDetail :trade="tradeDetail" :stake-currency="stakeCurrency" />
</DraggableContainer>
</GridItem>
<GridItem
v-if="gridLayoutTradeDetail.h != 0"
:i="gridLayoutChartView.i"
:x="gridLayoutChartView.x"
:y="gridLayoutChartView.y"
@ -179,15 +188,38 @@ export default class Trading extends Vue {
@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);
@ -209,10 +241,27 @@ export default class Trading extends Vue {
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],
};
}
breakpointChanged(newBreakpoint) {
console.log('breakpoint:', newBreakpoint);
this.currentBreakpoint = newBreakpoint;
}
}
</script>
<style scoped></style>

View File

@ -1,7 +1,13 @@
import { formatPercent } from '@/shared/formatters';
import { formatPercent, formatPriceCurrency } from '@/shared/formatters';
describe('formatters.ts', () => {
it('Format percent correctly', () => {
expect(formatPercent(0.5)).toEqual('50.000%');
});
it('format price currency as expected', () => {
expect(formatPriceCurrency(5123.551123, 'USDT', 3)).toEqual('5123.551 USDT');
expect(formatPriceCurrency(5123.551123, 'USDT')).toEqual('5123.551 USDT');
expect(formatPriceCurrency(5123.551123, 'USDT', 5)).toEqual('5123.55112 USDT');
});
});

View File

@ -11370,10 +11370,10 @@ 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.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.11.2.tgz#faa06da53887c493a695b997f4fcb4784a667990"
integrity sha512-J+X8Au6BhQdcej6LY4O986634hZLu55L0ewU2j8my7WIKlu8cK0dqmdUxqVHHMd/cMrKKZ9SywB/id6aLhwCtA==
vue-demi@0.12.1, vue-demi@^0.11.2:
version "0.12.1"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.1.tgz#f7e18efbecffd11ab069d1472d7a06e319b4174c"
integrity sha512-QL3ny+wX8c6Xm1/EZylbgzdoDolye+VpCXRhI2hug9dJTP3OUJ3lmiKN3CsVV3mOJKwFi0nsstbgob0vG7aoIw==
vue-echarts@^6.0.0:
version "6.0.0"
@ -11506,6 +11506,11 @@ vuex-class@^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"