Aggregate the top status cards across strategies
With several strategies the header cards showed only the first one. Expose day_pnl and drawdown_pct per strategy and add aggregateStatus() (frontend) so the cards show the whole account: summed cash/equity/P&L/trades/positions, worst drawdown, and any halt. Single-strategy runs are unchanged. Adds tests.
This commit is contained in:
1 parent
657c473e82
commit
57dcfe7adb
6 files changed
+133
-3
No files matched your search
@@ -158,6 +158,8 @@ class MultiStrategyManager:
|
||||
halted=engine_status.halted,
|
||||
halt_reason=engine_status.halt_reason,
|
||||
trades_today=engine_status.trades_today,
|
||||
day_pnl=engine_status.day_pnl,
|
||||
drawdown_pct=engine_status.drawdown_pct,
|
||||
error=engine_status.error,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -177,6 +177,8 @@ class StrategyStatusOut(BaseModel):
|
||||
halted: bool = False
|
||||
halt_reason: str | None = None
|
||||
trades_today: int = 0
|
||||
day_pnl: float = 0.0
|
||||
drawdown_pct: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,8 @@ def test_start_many_and_multi_status(runtime: Runtime, market: FakeMarketData) -
|
||||
assert status["running"] is True
|
||||
assert len(status["strategies"]) == 2
|
||||
assert status["equity"] == 10000
|
||||
# Per-strategy fields the dashboard aggregates must be present.
|
||||
assert {"day_pnl", "drawdown_pct"} <= set(status["strategies"][0])
|
||||
|
||||
client.post("/api/bot/stop")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { aggregateStatus } from './aggregate'
|
||||
import * as api from './api'
|
||||
import BacktestPanel from './components/BacktestPanel.vue'
|
||||
import EquityChart from './components/EquityChart.vue'
|
||||
@@ -44,6 +45,8 @@ const runningLabel = computed(() => {
|
||||
return `Running: ${list.length} strategies`
|
||||
})
|
||||
|
||||
const displayStatus = computed(() => aggregateStatus(status.value, multiStatus.value))
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const list = multiStatus.value.strategies || []
|
||||
if (!list.length) {
|
||||
@@ -298,10 +301,10 @@ onUnmounted(() => {
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="status.halted"
|
||||
v-if="displayStatus.halted"
|
||||
class="rounded-lg border border-rose-500 bg-rose-500/20 p-3 text-sm font-semibold text-rose-100"
|
||||
>
|
||||
HALTED — {{ status.halt_reason }}. Trading is stopped and any open position was
|
||||
HALTED — {{ displayStatus.halt_reason }}. Trading is stopped and any open position was
|
||||
flattened. Restart the bot to resume (limits reset).
|
||||
</div>
|
||||
|
||||
@@ -319,7 +322,7 @@ onUnmounted(() => {
|
||||
{{ status.error }}
|
||||
</div>
|
||||
|
||||
<StatusCards :status="status" />
|
||||
<StatusCards :status="displayStatus" />
|
||||
|
||||
<MultiStrategyPanel
|
||||
:strategies="strategies"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Top-of-dashboard status. With one strategy it is that strategy's status;
|
||||
* with several, the per-strategy numbers are aggregated for the whole account.
|
||||
*/
|
||||
export function aggregateStatus(status, multiStatus) {
|
||||
const list = multiStatus?.strategies || []
|
||||
if (list.length <= 1) return status
|
||||
|
||||
const sum = (field) => list.reduce((acc, s) => acc + (s[field] || 0), 0)
|
||||
const halted = list.filter((s) => s.halted)
|
||||
|
||||
return {
|
||||
...status,
|
||||
running: multiStatus.running,
|
||||
price: multiStatus.price ?? status.price,
|
||||
cash: sum('cash'),
|
||||
equity: multiStatus.equity,
|
||||
starting_balance: multiStatus.starting_balance,
|
||||
realized_pnl: sum('realized_pnl'),
|
||||
unrealized_pnl: sum('unrealized_pnl'),
|
||||
total_pnl: multiStatus.total_pnl,
|
||||
day_pnl: sum('day_pnl'),
|
||||
// Worst individual strategy drawdown; the portfolio peak is not tracked.
|
||||
drawdown_pct: Math.max(0, ...list.map((s) => s.drawdown_pct || 0)),
|
||||
trades_today: sum('trades_today'),
|
||||
open_positions: sum('open_positions'),
|
||||
halted: halted.length > 0,
|
||||
halt_reason:
|
||||
halted
|
||||
.map((s) => s.halt_reason)
|
||||
.filter(Boolean)
|
||||
.join('; ') || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { aggregateStatus } from './aggregate'
|
||||
|
||||
const SINGLE = { equity: 100, total_pnl: 5, strategies: [] }
|
||||
|
||||
function strategy(overrides = {}) {
|
||||
return {
|
||||
strategy_key: 'sma_cross',
|
||||
symbol: 'BTC/USDT',
|
||||
timeframe: '5m',
|
||||
allocation: 0.5,
|
||||
cash: 1000,
|
||||
equity: 1000,
|
||||
realized_pnl: 0,
|
||||
unrealized_pnl: 0,
|
||||
total_pnl: 0,
|
||||
open_positions: 0,
|
||||
halted: false,
|
||||
halt_reason: null,
|
||||
trades_today: 0,
|
||||
day_pnl: 0,
|
||||
drawdown_pct: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('aggregateStatus', () => {
|
||||
it('returns the plain status when there is at most one strategy', () => {
|
||||
expect(aggregateStatus(SINGLE, { strategies: [] })).toBe(SINGLE)
|
||||
expect(aggregateStatus(SINGLE, { strategies: [strategy()] })).toBe(SINGLE)
|
||||
})
|
||||
|
||||
it('sums the per-strategy numbers', () => {
|
||||
const multi = {
|
||||
running: true,
|
||||
equity: 2100,
|
||||
starting_balance: 2000,
|
||||
total_pnl: 100,
|
||||
price: 123,
|
||||
strategies: [
|
||||
strategy({ cash: 1000, equity: 1100, realized_pnl: 100, unrealized_pnl: 0, total_pnl: 100, trades_today: 2, open_positions: 1, day_pnl: 40 }),
|
||||
strategy({ cash: 1000, equity: 1000, realized_pnl: 0, unrealized_pnl: 0, total_pnl: 0, trades_today: 0, open_positions: 0, day_pnl: -10 }),
|
||||
],
|
||||
}
|
||||
const result = aggregateStatus(SINGLE, multi)
|
||||
expect(result.cash).toBe(2000)
|
||||
expect(result.equity).toBe(2100)
|
||||
expect(result.realized_pnl).toBe(100)
|
||||
expect(result.total_pnl).toBe(100)
|
||||
expect(result.day_pnl).toBe(30)
|
||||
expect(result.trades_today).toBe(2)
|
||||
expect(result.open_positions).toBe(1)
|
||||
expect(result.price).toBe(123)
|
||||
expect(result.running).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the worst drawdown and any halt', () => {
|
||||
const multi = {
|
||||
running: true,
|
||||
equity: 2000,
|
||||
starting_balance: 2000,
|
||||
total_pnl: 0,
|
||||
price: null,
|
||||
strategies: [
|
||||
strategy({ drawdown_pct: 3, halted: false }),
|
||||
strategy({ drawdown_pct: 7.5, halted: true, halt_reason: 'daily loss limit hit' }),
|
||||
],
|
||||
}
|
||||
const result = aggregateStatus(SINGLE, multi)
|
||||
expect(result.drawdown_pct).toBe(7.5)
|
||||
expect(result.halted).toBe(true)
|
||||
expect(result.halt_reason).toBe('daily loss limit hit')
|
||||
})
|
||||
|
||||
it('falls back to the plain price when multi has none', () => {
|
||||
const multi = {
|
||||
running: false,
|
||||
equity: 2000,
|
||||
starting_balance: 2000,
|
||||
total_pnl: 0,
|
||||
price: null,
|
||||
strategies: [strategy(), strategy()],
|
||||
}
|
||||
const result = aggregateStatus({ ...SINGLE, price: 99 }, multi)
|
||||
expect(result.price).toBe(99)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user