Adds a reusable CollapsiblePanel (chevron header, optional summary shown when collapsed, state persisted in localStorage) and wraps the Strategy, Backtest and Parameter sweep panels with it, so any single part of the dashboard can be focused on quickly. Panels stay mounted (v-show) so their form/results state is preserved across collapse. Includes component tests.
143 lines
5.5 KiB
Vue
143 lines
5.5 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 },
|
|
})
|
|
|
|
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">
|
|
<div class="space-y-3">
|
|
<StrategyForm
|
|
:strategies="strategies"
|
|
:busy="running"
|
|
:symbol="status.symbol"
|
|
:timeframe="status.timeframe"
|
|
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>
|