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:
1 parent
3229a0e2aa
commit
ce2b11a803
6 files changed
+499
-1
No files matched your search
+58
-1
@@ -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" />
|
||||
|
||||
|
||||
@@ -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&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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user