From ce2b11a8032cffda49ffa4cdb51cd41bbf95e523 Mon Sep 17 00:00:00 2001 From: matitos Date: Tue, 22 Sep 2026 11:59:21 +0000 Subject: [PATCH] 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. --- README.md | 11 ++ frontend/src/App.vue | 59 +++++- frontend/src/api.js | 2 + .../src/components/MultiStrategyPanel.vue | 186 ++++++++++++++++++ frontend/src/components/StrategyRowEditor.vue | 122 ++++++++++++ frontend/src/multi-strategy.test.js | 120 +++++++++++ 6 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/MultiStrategyPanel.vue create mode 100644 frontend/src/components/StrategyRowEditor.vue create mode 100644 frontend/src/multi-strategy.test.js diff --git a/README.md b/README.md index 6a3b082..c5810df 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,15 @@ injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as the rest of `/api`, which is fine for a trusted/private deployment but is the reason to set a token if the host is exposed. +## Multi-strategy + +The **Strategies (multi)** panel runs several strategies at once, each with its +own capital slice. Set a **weight** per strategy; weights are normalised across +the run (`[1, 3]` gives 25% / 75%) and each strategy starts with +`starting_balance × allocation` as its own cash, so one strategy's losses cannot +drain another's. Risk limits apply per strategy, and **Kill all** flattens and +halts every one. + ## Risk controls The engine enforces limits on every tick and **halts + flattens** when one trips: @@ -182,6 +191,8 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`, | `GET` | `/api/status` | Bot status, equity, P&L | | `GET` | `/api/strategies` | Available strategies + parameter specs | | `POST` | `/api/bot/start` | Start a strategy | +| `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations | +| `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run | | `POST` | `/api/bot/stop` | Stop the bot | | `POST` | `/api/bot/kill` | Kill switch: flatten + halt | | `POST` | `/api/backtest` | Backtest a strategy on historical candles | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 995cb79..89beb9f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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" /> + + diff --git a/frontend/src/api.js b/frontend/src/api.js index 80f680e..54fbb38 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) diff --git a/frontend/src/components/MultiStrategyPanel.vue b/frontend/src/components/MultiStrategyPanel.vue new file mode 100644 index 0000000..caf3459 --- /dev/null +++ b/frontend/src/components/MultiStrategyPanel.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/components/StrategyRowEditor.vue b/frontend/src/components/StrategyRowEditor.vue new file mode 100644 index 0000000..8243aa9 --- /dev/null +++ b/frontend/src/components/StrategyRowEditor.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/multi-strategy.test.js b/frontend/src/multi-strategy.test.js new file mode 100644 index 0000000..8300561 --- /dev/null +++ b/frontend/src/multi-strategy.test.js @@ -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') + }) +})