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
@@ -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
|
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.
|
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
|
## Risk controls
|
||||||
|
|
||||||
The engine enforces limits on every tick and **halts + flattens** when one trips:
|
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/status` | Bot status, equity, P&L |
|
||||||
| `GET` | `/api/strategies` | Available strategies + parameter specs |
|
| `GET` | `/api/strategies` | Available strategies + parameter specs |
|
||||||
| `POST` | `/api/bot/start` | Start a strategy |
|
| `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/stop` | Stop the bot |
|
||||||
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
|
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
|
||||||
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
|
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
|
||||||
|
|||||||
+58
-1
@@ -4,6 +4,7 @@ import * as api from './api'
|
|||||||
import BacktestPanel from './components/BacktestPanel.vue'
|
import BacktestPanel from './components/BacktestPanel.vue'
|
||||||
import EquityChart from './components/EquityChart.vue'
|
import EquityChart from './components/EquityChart.vue'
|
||||||
import EventLog from './components/EventLog.vue'
|
import EventLog from './components/EventLog.vue'
|
||||||
|
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
|
||||||
import OrdersTable from './components/OrdersTable.vue'
|
import OrdersTable from './components/OrdersTable.vue'
|
||||||
import PositionsTable from './components/PositionsTable.vue'
|
import PositionsTable from './components/PositionsTable.vue'
|
||||||
import StatusCards from './components/StatusCards.vue'
|
import StatusCards from './components/StatusCards.vue'
|
||||||
@@ -31,6 +32,7 @@ const status = ref({
|
|||||||
const strategies = ref([])
|
const strategies = ref([])
|
||||||
const strategiesLoading = ref(true)
|
const strategiesLoading = ref(true)
|
||||||
const presets = ref([])
|
const presets = ref([])
|
||||||
|
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
|
||||||
const positions = ref([])
|
const positions = ref([])
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
const events = 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() {
|
async function handleStop() {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
status.value = await api.stopBot()
|
status.value = await api.stopBot()
|
||||||
|
await refreshMulti()
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
@@ -193,6 +236,7 @@ onMounted(() => {
|
|||||||
// Initial loads are best-effort.
|
// Initial loads are best-effort.
|
||||||
api.getStatus().then((s) => (status.value = s)).catch(() => {})
|
api.getStatus().then((s) => (status.value = s)).catch(() => {})
|
||||||
refreshAll().catch(() => {})
|
refreshAll().catch(() => {})
|
||||||
|
refreshMulti().catch(() => {})
|
||||||
|
|
||||||
// The strategy list and presets rarely change, so load them once but keep
|
// 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
|
// 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.
|
// Poll fast enough that the UI feels live even when it is the only channel.
|
||||||
pollTimer = setInterval(() => refreshAll().catch(() => {}), 3000)
|
pollTimer = setInterval(() => refreshAll().catch(() => {}), 3000)
|
||||||
statusTimer = setInterval(
|
statusTimer = setInterval(
|
||||||
() => api.getStatus().then((s) => (status.value = s)).catch(() => {}),
|
() => {
|
||||||
|
api.getStatus().then((s) => (status.value = s)).catch(() => {})
|
||||||
|
refreshMulti().catch(() => {})
|
||||||
|
},
|
||||||
3000,
|
3000,
|
||||||
)
|
)
|
||||||
// Retry the static lists until non-empty (covers a failed first load and a
|
// Retry the static lists until non-empty (covers a failed first load and a
|
||||||
@@ -279,6 +326,16 @@ onUnmounted(() => {
|
|||||||
@delete-preset="handleDeletePreset"
|
@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" />
|
<BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
|
||||||
<SweepPanel :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 getVersion = () => http.get('/version').then((r) => r.data)
|
||||||
export const getStatus = () => http.get('/status').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 getStrategies = () => http.get('/strategies').then((r) => r.data)
|
||||||
export const getPositions = () => http.get('/positions').then((r) => r.data)
|
export const getPositions = () => http.get('/positions').then((r) => r.data)
|
||||||
export const getOrders = () => http.get('/orders').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