Files
crypto_trading/frontend/src/components/BacktestPanel.vue
T
matitos 5a8c7ecc58 Show a loading state in the strategy dropdown
While the strategy list is being fetched the dropdown now shows a disabled
"Loading strategies…" option instead of an empty select, and "No strategies
available" once loading finishes with no results. Applies to the strategy,
backtest and sweep panels via a loading prop. Adds tests.
2026-09-21 18:26:01 +00:00

145 lines
5.6 KiB
Vue

<script setup>
import { computed, ref } from 'vue'
import * as api from '../api'
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
import EquityChart from './EquityChart.vue'
import StrategyForm from './StrategyForm.vue'
defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
loading: { type: Boolean, default: false },
})
const running = ref(false)
const error = ref(null)
const result = ref(null)
const limit = ref(500)
const summary = computed(() => {
if (!result.value || result.value.error) return ''
const m = result.value.metrics
return `${result.value.symbol} ${result.value.timeframe} · ${signed(m.total_return_pct)}% · ${m.num_trades} trades`
})
async function run(payload) {
running.value = true
error.value = null
try {
result.value = await api.runBacktest({ ...payload, limit: Number(limit.value) })
if (result.value.error) error.value = result.value.error
} catch (e) {
error.value = e.response?.data?.detail || e.message
} finally {
running.value = false
}
}
</script>
<template>
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary" :default-open="false">
<div class="space-y-3">
<StrategyForm
:strategies="strategies"
:busy="running"
:symbol="status.symbol"
:timeframe="status.timeframe"
:loading="loading"
submit-label="Run backtest"
@submit="run"
/>
<label class="block max-w-[12rem] space-y-1">
<span class="label">Historical candles</span>
<input v-model="limit" type="number" min="20" max="1000" class="input" />
</label>
<p v-if="error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ error }}
</p>
<template v-if="result && !result.error">
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Total return</div>
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.total_return_pct)">
{{ signed(result.metrics.total_return_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Buy &amp; hold</div>
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.buy_hold_return_pct)">
{{ signed(result.metrics.buy_hold_return_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Max drawdown</div>
<div class="text-lg font-semibold tabular-nums text-amber-300">
-{{ fmtMoney(result.metrics.max_drawdown_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Final equity</div>
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.final_equity) }}</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Trades</div>
<div class="text-lg font-semibold tabular-nums">{{ result.metrics.num_trades }}</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Win rate</div>
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.win_rate_pct) }}%</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Profit factor</div>
<div class="text-lg font-semibold tabular-nums">
{{ result.metrics.profit_factor === null ? '—' : fmtMoney(result.metrics.profit_factor) }}
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Sharpe</div>
<div class="text-lg font-semibold tabular-nums">
{{ result.metrics.sharpe === null ? '—' : fmtMoney(result.metrics.sharpe) }}
</div>
</div>
</div>
<EquityChart :points="result.equity_curve" title="Backtest equity" />
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">Side</th>
<th class="pb-2 text-right">Entry</th>
<th class="pb-2 text-right">Exit</th>
<th class="pb-2 text-right">P&amp;L</th>
<th class="pb-2 text-right">Return</th>
<th class="pb-2">Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in result.trades" :key="i" class="border-t border-edge/60">
<td class="py-1.5" :class="t.side === 'long' ? 'text-emerald-400' : 'text-rose-400'">
{{ t.side }}
</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.entry_price) }}</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.exit_price) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.pnl)">{{ signed(t.pnl) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.return_pct)">
{{ signed(t.return_pct) }}%
</td>
<td class="py-1.5 text-slate-400">{{ t.reason }}</td>
</tr>
<tr v-if="!result.trades.length">
<td colspan="6" class="py-6 text-center text-slate-500">No trades</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</CollapsiblePanel>
</template>