Files
crypto_trading/frontend/src/components/SweepPanel.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

180 lines
6.1 KiB
Vue

<script setup>
import { computed, ref, watch } from 'vue'
import * as api from '../api'
import { fmtMoney, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const props = 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 selectedKey = ref('')
const limit = ref(500)
const topN = ref(20)
const symbol = ref(props.status.symbol || 'BTC/USDT')
const timeframe = ref(props.status.timeframe || '5m')
// param name -> comma-separated candidate values
const gridText = ref({})
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
const summary = computed(() => {
if (!result.value) return ''
const top = result.value.rows[0]
const best = top ? ` · best ${signed(top.metrics.total_return_pct)}%` : ''
return `${result.value.combinations} combinations${best}`
})
watch(
() => props.strategies,
(list) => {
if (!selectedKey.value && list.length) selectedKey.value = list[0].key
},
{ immediate: true },
)
watch(selected, (strategy) => {
gridText.value = {}
if (strategy) {
// Seed each numeric param with a sensible two-value grid.
for (const p of strategy.params) {
if (p.type === 'int' || p.type === 'float') {
const v = Number(p.default)
gridText.value[p.name] = `${v},${p.type === 'int' ? v * 2 : (v * 2).toFixed(4)}`
}
}
}
})
function buildGrid() {
const grid = {}
for (const [name, raw] of Object.entries(gridText.value)) {
const values = String(raw)
.split(',')
.map((x) => x.trim())
.filter((x) => x !== '')
.map((x) => Number(x))
.filter((x) => !Number.isNaN(x))
if (values.length) grid[name] = values
}
return grid
}
async function run() {
running.value = true
error.value = null
try {
const grid = buildGrid()
if (!Object.keys(grid).length) {
error.value = 'Provide at least one parameter with candidate values.'
return
}
result.value = await api.runSweep({
strategy_key: selectedKey.value,
base_params: {},
grid,
symbol: symbol.value,
timeframe: timeframe.value,
limit: Number(limit.value),
top_n: Number(topN.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="Parameter sweep" storage-key="sweep" :summary="summary" :default-open="false">
<div class="space-y-3">
<div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1">
<span class="label">Strategy</span>
<select v-model="selectedKey" class="input" :disabled="running || !strategies.length">
<option v-if="!strategies.length" value="" disabled>
{{ loading ? 'Loading strategies…' : 'No strategies available' }}
</option>
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Symbol</span>
<input v-model="symbol" class="input" :disabled="running" />
</label>
<label class="space-y-1">
<span class="label">Timeframe</span>
<input v-model="timeframe" class="input" :disabled="running" />
</label>
<label class="space-y-1">
<span class="label">Candles</span>
<input v-model="limit" type="number" min="20" max="1000" class="input" :disabled="running" />
</label>
</div>
<div v-if="selected" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label v-for="(raw, name) in gridText" :key="name" class="space-y-1">
<span class="label">{{ name }} (comma-separated)</span>
<input v-model="gridText[name]" class="input" :disabled="running" />
</label>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm text-slate-300">
Top N
<input v-model="topN" type="number" min="1" max="100" class="input w-20" :disabled="running" />
</label>
<button class="btn-primary" :disabled="running || !selectedKey" @click="run">
{{ running ? 'Running…' : 'Run sweep' }}
</button>
</div>
<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>
<div v-if="result && result.rows.length" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">#</th>
<th class="pb-2">Params</th>
<th class="pb-2 text-right">Return</th>
<th class="pb-2 text-right">Max DD</th>
<th class="pb-2 text-right">Trades</th>
<th class="pb-2 text-right">Win rate</th>
<th class="pb-2 text-right">Sharpe</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in result.rows" :key="i" class="border-t border-edge/60">
<td class="py-1.5 text-slate-400">{{ i + 1 }}</td>
<td class="py-1.5 font-mono text-xs">
<span v-for="(v, k) in row.params" :key="k" class="mr-2">{{ k }}={{ v }}</span>
</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(row.metrics.total_return_pct)">
{{ signed(row.metrics.total_return_pct) }}%
</td>
<td class="py-1.5 text-right tabular-nums text-amber-300">
-{{ fmtMoney(row.metrics.max_drawdown_pct) }}%
</td>
<td class="py-1.5 text-right tabular-nums">{{ row.metrics.num_trades }}</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtMoney(row.metrics.win_rate_pct) }}%</td>
<td class="py-1.5 text-right tabular-nums">
{{ row.metrics.sharpe === null ? '—' : fmtMoney(row.metrics.sharpe) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CollapsiblePanel>
</template>