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.
145 lines
5.6 KiB
Vue
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 & 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&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>
|