Compare commits
2
Commits
| 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>
|
<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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user