Compare commits
2
Commits
fedcd07ffd
...
9eddd66a82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eddd66a82 | ||
|
|
1b44e456c6 |
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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user