Files
crypto_trading/frontend/src/App.vue
T
matitos 57dcfe7adb 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.
2026-09-22 23:11:18 +00:00

370 lines
11 KiB
Vue

<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'
import EventLog from './components/EventLog.vue'
import LogsPanel from './components/LogsPanel.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'
import SweepPanel from './components/SweepPanel.vue'
const status = ref({
running: false,
mode: 'paper',
exchange: '',
symbol: '',
timeframe: '',
strategy_name: null,
price: null,
cash: 0,
equity: 0,
starting_balance: 0,
realized_pnl: 0,
unrealized_pnl: 0,
total_pnl: 0,
open_positions: 0,
error: null,
})
const strategies = ref([])
const strategiesLoading = ref(true)
const presets = ref([])
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] })
const runningLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) return 'Idle'
if (list.length === 1) {
return `Running: ${list[0].strategy_name || list[0].strategy_key}`
}
return `Running: ${list.length} strategies`
})
const displayStatus = computed(() => aggregateStatus(status.value, multiStatus.value))
const scopeLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) {
return status.value.symbol ? `${status.value.symbol} ${status.value.timeframe}` : ''
}
const first = list[0]
const sameScope = list.every(
(s) => s.symbol === first.symbol && s.timeframe === first.timeframe,
)
return sameScope ? `${first.symbol} ${first.timeframe}` : `${list.length} pairs`
})
const positions = ref([])
const orders = ref([])
const events = ref([])
const equityPoints = ref([])
const busy = ref(false)
const connected = ref(false)
const socketError = ref('')
const socketFailures = ref(0)
const pollingFallback = ref(false)
// Some mobile browsers (notably iOS Safari with an auth-gated page) abort or
// silently hang the wss:// upgrade even when the server accepts it. We fall
// back to the REST poll after a few failures, or after a short grace period if
// the socket never opens at all, and present it as a normal live state.
const usePolling = computed(() => pollingFallback.value || socketFailures.value >= 3)
// Build versions shown in the footer. The client value is baked in at build
// time; the server value comes from /api/version. A mismatch means the browser
// is running a stale cached bundle.
const clientVersion = __BUILD_VERSION__
const serverVersion = ref('')
let socket
let pollTimer
let statusTimer
let graceTimer
let listTimer
async function refreshAll() {
const [p, o, e, q] = await Promise.all([
api.getPositions(),
api.getOrders(),
api.getEvents(),
api.getEquity(),
])
positions.value = p
orders.value = o
events.value = e
equityPoints.value = q
}
async function loadStrategies() {
strategiesLoading.value = true
try {
strategies.value = await api.getStrategies()
} finally {
strategiesLoading.value = false
}
}
async function loadPresets() {
presets.value = await api.getPresets()
}
async function handleSavePreset(payload) {
await api.createPreset(payload)
await loadPresets()
}
async function handleDeletePreset(id) {
await api.deletePreset(id)
await loadPresets()
}
async function refreshMulti() {
multiStatus.value = await api.getMultiStatus()
}
async function refreshLiveCheck() {
liveCheck.value = await api.getLiveCheck()
}
async function handleStartMany(payload) {
if (liveCheck.value.mode === 'live' && !liveCheck.value.ok) {
status.value = {
...status.value,
error: 'Live trading blocked: ' + liveCheck.value.blockers.join('; '),
}
return
}
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 handleClose(id) {
try {
status.value = await api.closePosition(id)
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
}
}
function handleMessage(message) {
if (message.type === 'status') {
status.value = message.data
} else if (message.type === 'event') {
events.value = [message.data, ...events.value].slice(0, 50)
if (message.data.level === 'trade' || message.data.level === 'error') refreshAll()
}
}
// If the server is running a newer build than this (possibly cached) bundle,
// reload once with a cache-busting query so phones pick up deploys without a
// manual hard-refresh. Guarded so it can never loop.
async function ensureFreshBuild() {
try {
const { version } = await api.getVersion()
serverVersion.value = version || ''
if (!version || version === 'unknown') return
if (version === __BUILD_VERSION__) return
const key = `reloaded:${version}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
const url = new URL(window.location.href)
url.searchParams.set('v', version)
window.location.replace(url.toString())
} catch {
// ignore; not worth blocking the app
}
}
onMounted(() => {
ensureFreshBuild()
// Set up the socket and the polling fallback first, so a failing initial
// fetch can never prevent them (that left the badge stuck on "connecting").
socket = api.openSocket({
onMessage: handleMessage,
onOpen: () => {
connected.value = true
socketError.value = ''
},
onClose: () => (connected.value = false),
onError: (code) => {
socketFailures.value += 1
if (code === 1008) socketError.value = 'rejected by the server (token/auth)'
else socketError.value = `socket closed (code ${code})`
},
})
// If the socket hasn't opened shortly after load, commit to polling so the
// badge never hangs on "connecting".
graceTimer = setTimeout(() => {
if (!connected.value) pollingFallback.value = true
}, 6000)
// 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
// strategy dropdown empty forever.
loadStrategies()
loadPresets()
// 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(() => {})
refreshMulti().catch(() => {})
refreshLiveCheck().catch(() => {})
},
3000,
)
// Retry the static lists until non-empty (covers a failed first load and a
// backend that was still starting up).
listTimer = setInterval(() => {
if (!strategies.value.length) loadStrategies()
if (!presets.value.length) loadPresets()
if (strategies.value.length && presets.value.length) clearInterval(listTimer)
}, 3000)
})
onUnmounted(() => {
socket?.close()
clearInterval(pollTimer)
clearInterval(statusTimer)
clearInterval(listTimer)
clearTimeout(graceTimer)
})
</script>
<template>
<div class="mx-auto max-w-6xl space-y-4 p-4">
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold">Crypto Trader</h1>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold uppercase"
:class="status.mode === 'live' ? 'bg-rose-500/20 text-rose-300' : 'bg-amber-500/20 text-amber-300'"
>
{{ status.mode }}
</span>
<span class="flex items-center gap-1.5 text-xs text-slate-400" :title="socketError || ''">
<span
class="h-2 w-2 rounded-full"
:class="connected || usePolling ? 'bg-emerald-400' : 'bg-slate-500'"
></span>
<template v-if="connected">live</template>
<template v-else-if="usePolling">live (polling)</template>
<template v-else>connecting…</template>
</span>
</div>
<div class="text-xs text-slate-400">
<span>{{ runningLabel }}</span>
<span v-if="scopeLabel"> · {{ scopeLabel }}</span>
</div>
</header>
<div
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 — {{ displayStatus.halt_reason }}. Trading is stopped and any open position was
flattened. Restart the bot to resume (limits reset).
</div>
<div
v-if="liveCheck.mode === 'live' && !liveCheck.ok"
class="rounded-lg border border-amber-500 bg-amber-500/15 p-3 text-sm text-amber-100"
>
<p class="font-semibold">Live trading is blocked by the go-live checks:</p>
<ul class="mt-1 list-disc pl-5">
<li v-for="(b, i) in liveCheck.blockers" :key="i">{{ b }}</li>
</ul>
</div>
<div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ status.error }}
</div>
<StatusCards :status="displayStatus" />
<MultiStrategyPanel
:strategies="strategies"
:multi-status="multiStatus"
:presets="presets"
:busy="busy"
:loading="strategiesLoading"
@start-many="handleStartMany"
@stop="handleStopMany"
@kill="handleKillMany"
@save-preset="handleSavePreset"
@delete-preset="handleDeletePreset"
/>
<BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
<SweepPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
<div class="grid gap-4 lg:grid-cols-2">
<EquityChart :points="equityPoints" />
<EventLog :events="events" />
</div>
<LogsPanel />
<PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" />
<footer class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-edge/60 pt-3 text-xs text-slate-500">
<span>client <span class="font-mono text-slate-400">{{ clientVersion }}</span></span>
<span>
server
<span class="font-mono text-slate-400">{{ serverVersion || '…' }}</span>
</span>
<span
v-if="serverVersion && serverVersion !== clientVersion"
class="text-amber-300"
>
version mismatch — reloading to update
</span>
<span v-else-if="serverVersion" class="text-emerald-400">up to date</span>
<span v-if="socketError" class="text-amber-300">socket: {{ socketError }}</span>
</footer>
</div>
</template>