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.
370 lines
11 KiB
Vue
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>
|