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.
This commit is contained in:
1 parent
fedcd07ffd
commit
1b44e456c6
5 files changed
+190
-55
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
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'
|
||||
|
||||
@@ -15,6 +16,12 @@ 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
|
||||
@@ -30,15 +37,9 @@ async function run(payload) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Backtest</h2>
|
||||
<span v-if="result" class="text-xs text-slate-400">
|
||||
{{ result.symbol }} {{ result.timeframe }} · {{ result.equity_curve.length }} candles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategyForm
|
||||
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary">
|
||||
<div class="space-y-3">
|
||||
<StrategyForm
|
||||
:strategies="strategies"
|
||||
:busy="running"
|
||||
:symbol="status.symbol"
|
||||
@@ -136,5 +137,6 @@ async function run(payload) {
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</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>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import CollapsiblePanel from './CollapsiblePanel.vue'
|
||||
import StrategyForm from './StrategyForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -9,6 +11,11 @@ const props = defineProps({
|
||||
})
|
||||
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() {
|
||||
if (window.confirm('Kill switch: flatten the position and halt trading?')) {
|
||||
emit('kill')
|
||||
@@ -17,41 +24,33 @@ function confirmKill() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Strategy</h2>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="status.running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
|
||||
>
|
||||
{{ status.running ? 'running' : 'stopped' }}
|
||||
</span>
|
||||
</div>
|
||||
<CollapsiblePanel title="Strategy" storage-key="strategy" :summary="summary">
|
||||
<div class="space-y-3">
|
||||
<StrategyForm
|
||||
:strategies="strategies"
|
||||
:disabled="status.running"
|
||||
:busy="busy"
|
||||
submit-label="Start bot"
|
||||
:symbol="status.symbol"
|
||||
:timeframe="status.timeframe"
|
||||
:presets="presets"
|
||||
@submit="emit('start', $event)"
|
||||
@save-preset="emit('save-preset', $event)"
|
||||
@delete-preset="emit('delete-preset', $event)"
|
||||
/>
|
||||
|
||||
<StrategyForm
|
||||
:strategies="strategies"
|
||||
:disabled="status.running"
|
||||
:busy="busy"
|
||||
submit-label="Start bot"
|
||||
:symbol="status.symbol"
|
||||
:timeframe="status.timeframe"
|
||||
:presets="presets"
|
||||
@submit="emit('start', $event)"
|
||||
@save-preset="emit('save-preset', $event)"
|
||||
@delete-preset="emit('delete-preset', $event)"
|
||||
/>
|
||||
|
||||
<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 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>
|
||||
@@ -2,6 +2,7 @@
|
||||
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 },
|
||||
@@ -21,6 +22,13 @@ 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) => {
|
||||
@@ -84,14 +92,8 @@ async function run() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
<CollapsiblePanel title="Parameter sweep" storage-key="sweep" :summary="summary">
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-2 sm:grid-cols-4">
|
||||
<label class="space-y-1">
|
||||
<span class="label">Strategy</span>
|
||||
@@ -168,5 +170,6 @@ async function run() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</template>
|
||||
Reference in new issue
Block a user