Compare commits

..
2 Commits
Author SHA1 Message Date
matitos 9eddd66a82 Collapse the strategy, backtest and sweep panels
CI/CD / backend (push) Successful in 5m14s
CI/CD / frontend (push) Successful in 5m9s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 50s
2026-09-21 16:40:54 +00:00
matitos 1b44e456c6 Collapse the strategy, backtest and sweep panels
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.
2026-09-21 16:40:48 +00:00
5 changed files with 190 additions and 55 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import CollapsiblePanel from './components/CollapsiblePanel.vue'
describe('CollapsiblePanel', () => {
beforeEach(() => {
localStorage.clear()
})
it('renders the title and slot when open by default', () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy' },
slots: { default: '<p>content</p>' },
})
expect(wrapper.text()).toContain('Strategy')
expect(wrapper.text()).toContain('content')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
})
it('hides the slot after clicking the header', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy' },
slots: { default: '<p>content</p>' },
})
await wrapper.find('button').trigger('click')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
// v-show keeps the node but hides it.
expect(wrapper.find('div[style*="display: none"]').exists()).toBe(true)
})
it('shows the summary only when collapsed', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Backtest', summary: 'BTC/USDT 5m' },
slots: { default: '<p>content</p>' },
})
expect(wrapper.text()).not.toContain('BTC/USDT 5m')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('BTC/USDT 5m')
})
it('persists the collapsed state in localStorage', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Backtest', storageKey: 'backtest' },
slots: { default: '<p>content</p>' },
})
await wrapper.find('button').trigger('click')
expect(localStorage.getItem('panel:backtest')).toBe('0')
// A fresh mount with the same key starts collapsed.
const remounted = mount(CollapsiblePanel, {
props: { title: 'Backtest', storageKey: 'backtest' },
slots: { default: '<p>content</p>' },
})
expect(remounted.find('button').attributes('aria-expanded')).toBe('false')
})
it('honours defaultOpen when nothing is stored', () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy', defaultOpen: false },
slots: { default: '<p>content</p>' },
})
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
})
})
+13 -11
View File
@@ -1,7 +1,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import * as api from '../api' import * as api from '../api'
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format' import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
import EquityChart from './EquityChart.vue' import EquityChart from './EquityChart.vue'
import StrategyForm from './StrategyForm.vue' import StrategyForm from './StrategyForm.vue'
@@ -15,6 +16,12 @@ const error = ref(null)
const result = ref(null) const result = ref(null)
const limit = ref(500) 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) { async function run(payload) {
running.value = true running.value = true
error.value = null error.value = null
@@ -30,15 +37,9 @@ async function run(payload) {
</script> </script>
<template> <template>
<div class="card space-y-3"> <CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary">
<div class="flex items-center justify-between"> <div class="space-y-3">
<h2 class="font-semibold">Backtest</h2> <StrategyForm
<span v-if="result" class="text-xs text-slate-400">
{{ result.symbol }} {{ result.timeframe }} · {{ result.equity_curve.length }} candles
</span>
</div>
<StrategyForm
:strategies="strategies" :strategies="strategies"
:busy="running" :busy="running"
:symbol="status.symbol" :symbol="status.symbol"
@@ -136,5 +137,6 @@ async function run(payload) {
</table> </table>
</div> </div>
</template> </template>
</div> </div>
</CollapsiblePanel>
</template> </template>
@@ -0,0 +1,67 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
title: { type: String, required: true },
// Optional right-aligned summary shown in the header (visible when collapsed).
summary: { type: String, default: '' },
storageKey: { type: String, default: '' },
defaultOpen: { type: Boolean, default: true },
})
const emit = defineEmits(['toggle'])
function readInitial() {
if (!props.storageKey) return props.defaultOpen
try {
const stored = localStorage.getItem(`panel:${props.storageKey}`)
if (stored === '1') return true
if (stored === '0') return false
} catch {
// localStorage unavailable (private mode); fall back to the default
}
return props.defaultOpen
}
const open = ref(readInitial())
watch(open, (value) => {
emit('toggle', value)
if (!props.storageKey) return
try {
localStorage.setItem(`panel:${props.storageKey}`, value ? '1' : '0')
} catch {
// ignore
}
})
</script>
<template>
<section class="card">
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
:aria-expanded="open"
@click="open = !open"
>
<span class="flex items-center gap-2 font-semibold">
<svg
class="h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform"
:class="open ? 'rotate-90' : ''"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M6 3.5 11 8l-5 4.5z" />
</svg>
{{ title }}
</span>
<span v-if="!open && summary" class="truncate text-xs font-normal text-slate-400">
{{ summary }}
</span>
</button>
<div v-show="open" class="mt-3">
<slot />
</div>
</section>
</template>
+34 -35
View File
@@ -1,4 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue'
import CollapsiblePanel from './CollapsiblePanel.vue'
import StrategyForm from './StrategyForm.vue' import StrategyForm from './StrategyForm.vue'
const props = defineProps({ const props = defineProps({
@@ -9,6 +11,11 @@ const props = defineProps({
}) })
const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset']) const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset'])
const summary = computed(() => {
const name = props.status.strategy_name || 'none'
return `${name} · ${props.status.running ? 'running' : 'stopped'}`
})
function confirmKill() { function confirmKill() {
if (window.confirm('Kill switch: flatten the position and halt trading?')) { if (window.confirm('Kill switch: flatten the position and halt trading?')) {
emit('kill') emit('kill')
@@ -17,41 +24,33 @@ function confirmKill() {
</script> </script>
<template> <template>
<div class="card space-y-3"> <CollapsiblePanel title="Strategy" storage-key="strategy" :summary="summary">
<div class="flex items-center justify-between"> <div class="space-y-3">
<h2 class="font-semibold">Strategy</h2> <StrategyForm
<span :strategies="strategies"
class="rounded-full px-2 py-0.5 text-xs font-medium" :disabled="status.running"
:class="status.running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'" :busy="busy"
> submit-label="Start bot"
{{ status.running ? 'running' : 'stopped' }} :symbol="status.symbol"
</span> :timeframe="status.timeframe"
</div> :presets="presets"
@submit="emit('start', $event)"
@save-preset="emit('save-preset', $event)"
@delete-preset="emit('delete-preset', $event)"
/>
<StrategyForm <div class="flex flex-wrap gap-2">
:strategies="strategies" <button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
:disabled="status.running" Stop bot
:busy="busy" </button>
submit-label="Start bot" <button
:symbol="status.symbol" class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:timeframe="status.timeframe" :disabled="busy || (!status.running && status.open_positions === 0)"
:presets="presets" @click="confirmKill"
@submit="emit('start', $event)" >
@save-preset="emit('save-preset', $event)" Kill switch
@delete-preset="emit('delete-preset', $event)" </button>
/> </div>
<div class="flex flex-wrap gap-2">
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
Stop bot
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!status.running && status.open_positions === 0)"
@click="confirmKill"
>
Kill switch
</button>
</div> </div>
</div> </CollapsiblePanel>
</template> </template>
+12 -9
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import * as api from '../api' import * as api from '../api'
import { fmtMoney, pnlClass, signed } from '../format' import { fmtMoney, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const props = defineProps({ const props = defineProps({
strategies: { type: Array, required: true }, strategies: { type: Array, required: true },
@@ -21,6 +22,13 @@ const gridText = ref({})
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value)) 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( watch(
() => props.strategies, () => props.strategies,
(list) => { (list) => {
@@ -84,14 +92,8 @@ async function run() {
</script> </script>
<template> <template>
<div class="card space-y-3"> <CollapsiblePanel title="Parameter sweep" storage-key="sweep" :summary="summary">
<div class="flex items-center justify-between"> <div class="space-y-3">
<h2 class="font-semibold">Parameter sweep</h2>
<span v-if="result" class="text-xs text-slate-400">
{{ result.combinations }} combinations · top {{ result.rows.length }}
</span>
</div>
<div class="grid gap-2 sm:grid-cols-4"> <div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1"> <label class="space-y-1">
<span class="label">Strategy</span> <span class="label">Strategy</span>
@@ -168,5 +170,6 @@ async function run() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</CollapsiblePanel>
</template> </template>