Multi-strategy UI

Adds a "Strategies (multi)" panel: add/remove strategy rows (strategy, symbol,
timeframe, per-strategy params and weight), start all, stop all and kill all,
plus a per-strategy status table (allocation, equity, P&L, halted). Polls
/api/bot/status alongside the existing status. Includes component tests.
This commit is contained in:
matitos committed 2026-09-22 11:59:21 +00:00
1 parent 3229a0e2aa
commit ce2b11a803
6 files changed
+499 -1

No files matched your search

+58 -1
View File
@@ -4,6 +4,7 @@ import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue'
@@ -31,6 +32,7 @@ const status = ref({
const strategies = ref([])
const strategiesLoading = ref(true)
const presets = ref([])
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
const positions = ref([])
const orders = ref([])
const events = ref([])
@@ -106,10 +108,51 @@ async function handleStart(payload) {
}
}
async function refreshMulti() {
multiStatus.value = await api.getMultiStatus()
}
async function handleStartMany(payload) {
busy.value = true
try {
multiStatus.value = await api.startMany(payload)
status.value = await api.getStatus()
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function handleStopMany() {
busy.value = true
try {
await api.stopBot()
status.value = await api.getStatus()
await refreshMulti()
await refreshAll()
} finally {
busy.value = false
}
}
async function handleKillMany() {
busy.value = true
try {
status.value = await api.killBot('manual kill switch (all)')
await refreshMulti()
await refreshAll()
} finally {
busy.value = false
}
}
async function handleStop() {
busy.value = true
try {
status.value = await api.stopBot()
await refreshMulti()
await refreshAll()
} finally {
busy.value = false
@@ -193,6 +236,7 @@ onMounted(() => {
// Initial loads are best-effort.
api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshAll().catch(() => {})
refreshMulti().catch(() => {})
// The strategy list and presets rarely change, so load them once but keep
// retrying until they arrive: a single failed first request used to leave the
@@ -203,7 +247,10 @@ onMounted(() => {
// Poll fast enough that the UI feels live even when it is the only channel.
pollTimer = setInterval(() => refreshAll().catch(() => {}), 3000)
statusTimer = setInterval(
() => api.getStatus().then((s) => (status.value = s)).catch(() => {}),
() => {
api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshMulti().catch(() => {})
},
3000,
)
// Retry the static lists until non-empty (covers a failed first load and a
@@ -279,6 +326,16 @@ onUnmounted(() => {
@delete-preset="handleDeletePreset"
/>
<MultiStrategyPanel
:strategies="strategies"
:multi-status="multiStatus"
:busy="busy"
:loading="strategiesLoading"
@start-many="handleStartMany"
@stop="handleStopMany"
@kill="handleKillMany"
/>
<BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
<SweepPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
+2
View File
@@ -4,6 +4,8 @@ const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getVersion = () => http.get('/version').then((r) => r.data)
export const getStatus = () => http.get('/status').then((r) => r.data)
export const getMultiStatus = () => http.get('/bot/status').then((r) => r.data)
export const startMany = (payload) => http.post('/bot/start-many', payload).then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data)
@@ -0,0 +1,186 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { fmtMoney, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
import StrategyRowEditor from './StrategyRowEditor.vue'
const props = defineProps({
strategies: { type: Array, required: true },
multiStatus: { type: Object, required: true },
busy: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['start-many', 'stop', 'kill'])
const startingBalance = ref(10000)
// Each row: { strategy_key, symbol, timeframe, weight, params }
const rows = ref([])
function defaultsFor(key) {
const strategy = props.strategies.find((s) => s.key === key)
return Object.fromEntries((strategy?.params || []).map((p) => [p.name, p.default]))
}
function newRow(key, symbol, timeframe) {
return {
strategy_key: key,
symbol: symbol || 'BTC/USDT',
timeframe: timeframe || '5m',
weight: 1,
params: defaultsFor(key),
}
}
// Seed with one row once strategies load.
watch(
() => props.strategies,
(list) => {
if (!rows.value.length && list.length) {
rows.value = [newRow(list[0].key, props.multiStatus.symbol, props.multiStatus.timeframe)]
}
},
{ immediate: true },
)
const running = computed(() => props.multiStatus.running)
const allocations = computed(() => {
const total = rows.value.reduce((sum, r) => sum + (Number(r.weight) || 0), 0)
if (total <= 0) return rows.value.map(() => 0)
return rows.value.map((r) => (Number(r.weight) || 0) / total)
})
const summary = computed(() => {
const strategies = props.multiStatus.strategies || []
if (!strategies.length) return 'no strategies'
return `${strategies.length} strategies · ${signed(props.multiStatus.total_pnl)}`
})
function addRow() {
const key = props.strategies[0]?.key
if (key) rows.value.push(newRow(key, props.multiStatus.symbol, props.multiStatus.timeframe))
}
function removeRow(index) {
rows.value.splice(index, 1)
}
function start() {
emit('start-many', {
strategies: rows.value.map((r) => ({
strategy_key: r.strategy_key,
params: r.params,
symbol: r.symbol,
timeframe: r.timeframe,
weight: Number(r.weight) || 1,
})),
starting_balance: Number(startingBalance.value),
})
}
function confirmKill() {
if (window.confirm('Kill switch: flatten all positions and halt every strategy?')) {
emit('kill')
}
}
</script>
<template>
<CollapsiblePanel title="Strategies (multi)" storage-key="multi" :summary="summary">
<div class="space-y-3">
<div class="flex flex-wrap items-end gap-3">
<label class="space-y-1">
<span class="label">Starting balance</span>
<input
v-model="startingBalance"
type="number"
min="0"
step="any"
class="input w-40"
:disabled="running"
/>
</label>
<button class="btn-muted" :disabled="running || loading" @click="addRow">
Add strategy
</button>
<button
class="btn-primary"
:disabled="busy || running || loading || !rows.length"
@click="start"
>
Start all
</button>
<button class="btn-danger" :disabled="busy || !running" @click="emit('stop')">
Stop all
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!running && !(multiStatus.strategies || []).length)"
@click="confirmKill"
>
Kill all
</button>
</div>
<div v-if="rows.length" class="space-y-2">
<StrategyRowEditor
v-for="(row, i) in rows"
:key="i"
v-model="rows[i]"
:strategies="strategies"
:default-symbol="multiStatus.symbol"
:default-timeframe="multiStatus.timeframe"
:disabled="running"
:allocation="allocations[i]"
@remove="removeRow(i)"
/>
</div>
<p v-else class="text-sm text-slate-400">
{{ loading ? 'Loading strategies…' : 'No strategies available' }}
</p>
<div v-if="(multiStatus.strategies || []).length" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">Strategy</th>
<th class="pb-2">Symbol</th>
<th class="pb-2 text-right">Alloc</th>
<th class="pb-2 text-right">Equity</th>
<th class="pb-2 text-right">Total P&amp;L</th>
<th class="pb-2">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(s, i) in multiStatus.strategies" :key="i" class="border-t border-edge/60">
<td class="py-1.5">{{ s.strategy_name || s.strategy_key }}</td>
<td class="py-1.5">{{ s.symbol }} {{ s.timeframe }}</td>
<td class="py-1.5 text-right tabular-nums">
{{ (s.allocation * 100).toFixed(1) }}%
</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtMoney(s.equity) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(s.total_pnl)">
{{ signed(s.total_pnl) }}
</td>
<td class="py-1.5">
<span
v-if="s.halted"
class="rounded px-1.5 py-0.5 text-xs bg-rose-500/20 text-rose-300"
:title="s.halt_reason || ''"
>
halted
</span>
<span
v-else
class="rounded px-1.5 py-0.5 text-xs"
:class="running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
>
{{ running ? 'running' : 'stopped' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CollapsiblePanel>
</template>
@@ -0,0 +1,122 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true },
strategies: { type: Array, required: true },
defaultSymbol: { type: String, default: 'BTC/USDT' },
defaultTimeframe: { type: String, default: '5m' },
disabled: { type: Boolean, default: false },
// Share of the total balance this row will receive (0..1), for display.
allocation: { type: Number, default: 0 },
})
const emit = defineEmits(['update:modelValue', 'remove'])
const row = computed(() => props.modelValue)
const selected = computed(() => props.strategies.find((s) => s.key === row.value.strategy_key))
function update(patch) {
emit('update:modelValue', { ...row.value, ...patch })
}
function coerce(param, raw) {
if (param.type === 'int') return parseInt(raw, 10)
if (param.type === 'float') return parseFloat(raw)
if (param.type === 'bool') return raw === true || raw === 'true'
return raw
}
function setParam(name, raw) {
const param = selected.value?.params.find((p) => p.name === name)
update({ params: { ...row.value.params, [name]: param ? coerce(param, raw) : raw } })
}
// When the strategy changes, reset params to its defaults.
watch(
() => row.value.strategy_key,
(key) => {
const strategy = props.strategies.find((s) => s.key === key)
if (strategy) {
const defaults = Object.fromEntries(strategy.params.map((p) => [p.name, p.default]))
update({ params: defaults })
}
},
)
</script>
<template>
<div class="rounded-lg border border-edge/60 p-3">
<div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1">
<span class="label">Strategy</span>
<select
class="input"
:value="row.strategy_key"
:disabled="disabled"
@change="update({ strategy_key: $event.target.value })"
>
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Symbol</span>
<input
class="input"
:value="row.symbol"
:disabled="disabled"
@input="update({ symbol: $event.target.value })"
/>
</label>
<label class="space-y-1">
<span class="label">Timeframe</span>
<input
class="input"
:value="row.timeframe"
:disabled="disabled"
@input="update({ timeframe: $event.target.value })"
/>
</label>
<label class="space-y-1">
<span class="label">Weight ({{ (allocation * 100).toFixed(1) }}%)</span>
<input
class="input"
type="number"
min="0.01"
step="any"
:value="row.weight"
:disabled="disabled"
@input="update({ weight: Number($event.target.value) })"
/>
</label>
</div>
<div v-if="selected && selected.params.length" class="mt-2 grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
<label v-for="param in selected.params" :key="param.name" class="space-y-1">
<span class="label" :title="param.description">{{ param.name }}</span>
<select
v-if="param.type === 'bool'"
class="input"
:value="row.params[param.name]"
:disabled="disabled"
@change="setParam(param.name, $event.target.value)"
>
<option :value="true">true</option>
<option :value="false">false</option>
</select>
<input
v-else
class="input"
type="number"
step="any"
:value="row.params[param.name]"
:disabled="disabled"
@input="setParam(param.name, $event.target.value)"
/>
</label>
</div>
<div class="mt-2 flex justify-end">
<button class="btn-muted" :disabled="disabled" @click="emit('remove')">Remove</button>
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
const STRATEGIES = [
{
key: 'sma_cross',
name: 'SMA Crossover',
description: '',
params: [{ name: 'fast', type: 'int', default: 10, description: '' }],
},
{
key: 'ema_cross',
name: 'EMA Crossover',
description: '',
params: [{ name: 'fast', type: 'int', default: 9, description: '' }],
},
]
const EMPTY_STATUS = { running: false, strategies: [], total_pnl: 0, equity: 0 }
function mountPanel(multiStatus = EMPTY_STATUS) {
return mount(MultiStrategyPanel, {
props: { strategies: STRATEGIES, multiStatus, busy: false, loading: false },
})
}
describe('MultiStrategyPanel', () => {
it('seeds one row by default', () => {
const wrapper = mountPanel()
expect(wrapper.findAll('select')).toHaveLength(1)
})
it('adds and removes rows', async () => {
const wrapper = mountPanel()
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('Add strategy'))
await addBtn.trigger('click')
expect(wrapper.findAll('select')).toHaveLength(2)
const removeBtn = wrapper.findAll('button').find((b) => b.text() === 'Remove')
await removeBtn.trigger('click')
expect(wrapper.findAll('select')).toHaveLength(1)
})
it('emits start-many with normalised weights and params', async () => {
const wrapper = mountPanel()
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('Add strategy'))
await addBtn.trigger('click')
const startBtn = wrapper.findAll('button').find((b) => b.text().includes('Start all'))
await startBtn.trigger('click')
const payload = wrapper.emitted('start-many')[0][0]
expect(payload.strategies).toHaveLength(2)
expect(payload.strategies[0].weight).toBe(1)
expect(payload.strategies[0].params).toEqual({ fast: 10 })
expect(payload.starting_balance).toBe(10000)
})
it('shows the per-strategy status table', () => {
const wrapper = mountPanel({
running: true,
total_pnl: 42,
equity: 10042,
strategies: [
{
strategy_key: 'sma_cross',
strategy_name: 'SMA Crossover',
params: {},
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 0.4,
cash: 4000,
equity: 4200,
realized_pnl: 200,
unrealized_pnl: 0,
total_pnl: 200,
open_positions: 1,
halted: false,
halt_reason: null,
trades_today: 2,
error: null,
},
],
})
const text = wrapper.text()
expect(text).toContain('SMA Crossover')
expect(text).toContain('40.0%')
expect(text).toContain('running')
})
it('marks a halted strategy', () => {
const wrapper = mountPanel({
running: true,
total_pnl: -10,
equity: 9990,
strategies: [
{
strategy_key: 'sma_cross',
strategy_name: 'SMA Crossover',
params: {},
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 1,
cash: 0,
equity: 9990,
realized_pnl: -10,
unrealized_pnl: 0,
total_pnl: -10,
open_positions: 0,
halted: true,
halt_reason: 'daily loss limit hit',
trades_today: 1,
error: null,
},
],
})
expect(wrapper.text()).toContain('halted')
})
})