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

This commit is contained in:
matitos committed 2026-09-21 16:40:54 +00:00
commit 9eddd66a82
5 files changed
+163 -28

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')
})
})
+11 -9
View File
@@ -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,14 +37,8 @@ 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>
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary">
<div class="space-y-3">
<StrategyForm
:strategies="strategies"
:busy="running"
@@ -137,4 +138,5 @@ async function run(payload) {
</div>
</template>
</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>
+10 -11
View File
@@ -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,17 +24,8 @@ 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"
@@ -54,4 +52,5 @@ function confirmKill() {
</button>
</div>
</div>
</CollapsiblePanel>
</template>
+11 -8
View File
@@ -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>
@@ -169,4 +171,5 @@ async function run() {
</table>
</div>
</div>
</CollapsiblePanel>
</template>