Risk controls, alerts, API token, more exchanges, presets
- RiskManager halts + flattens on max daily loss / drawdown, caps position size and trades per day; adds a manual kill switch (POST /api/bot/kill). - Telegram and webhook notifications on trades, errors and halts. - Optional API bearer token on /api, injected server-side by the frontend nginx so the browser never sees it. - Kraken and Coinbase market data (public); Coinbase live broker. Live on an unsupported exchange now errors instead of silently falling back to paper. - Strategy presets backed by the StrategyConfig table (CRUD API + UI). - Idempotent additive-column migration so existing SQLite databases upgrade in place (fixes the new strategy_configs.name column).
This commit is contained in:
1 parent
9c3bee87ac
commit
03dfa369e5
35 files changed
+1409
-45
No files matched your search
+45
-1
@@ -28,6 +28,7 @@ const status = ref({
|
||||
})
|
||||
|
||||
const strategies = ref([])
|
||||
const presets = ref([])
|
||||
const positions = ref([])
|
||||
const orders = ref([])
|
||||
const events = ref([])
|
||||
@@ -55,6 +56,20 @@ async function loadStrategies() {
|
||||
strategies.value = await api.getStrategies()
|
||||
}
|
||||
|
||||
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 handleStart(payload) {
|
||||
busy.value = true
|
||||
try {
|
||||
@@ -77,6 +92,18 @@ async function handleStop() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKill() {
|
||||
busy.value = true
|
||||
try {
|
||||
status.value = await api.killBot('manual kill switch')
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClose(id) {
|
||||
try {
|
||||
status.value = await api.closePosition(id)
|
||||
@@ -96,7 +123,12 @@ function handleMessage(message) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStrategies(), api.getStatus().then((s) => (status.value = s)), refreshAll()])
|
||||
await Promise.all([
|
||||
loadStrategies(),
|
||||
loadPresets(),
|
||||
api.getStatus().then((s) => (status.value = s)),
|
||||
refreshAll(),
|
||||
])
|
||||
socket = api.openSocket({
|
||||
onMessage: handleMessage,
|
||||
onOpen: () => (connected.value = true),
|
||||
@@ -137,6 +169,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="status.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
|
||||
flattened. Restart the bot to resume (limits reset).
|
||||
</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>
|
||||
@@ -147,8 +187,12 @@ onUnmounted(() => {
|
||||
:strategies="strategies"
|
||||
:status="status"
|
||||
:busy="busy"
|
||||
:presets="presets"
|
||||
@start="handleStart"
|
||||
@stop="handleStop"
|
||||
@kill="handleKill"
|
||||
@save-preset="handleSavePreset"
|
||||
@delete-preset="handleDeletePreset"
|
||||
/>
|
||||
|
||||
<BacktestPanel :strategies="strategies" :status="status" />
|
||||
|
||||
@@ -11,12 +11,18 @@ export const getEquity = () => http.get('/equity').then((r) => r.data)
|
||||
|
||||
export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
|
||||
export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
|
||||
export const killBot = (reason = 'manual kill switch') =>
|
||||
http.post('/bot/kill', { reason }).then((r) => r.data)
|
||||
export const closePosition = (id, reason = 'manual') =>
|
||||
http.post(`/positions/${id}/close`, { reason }).then((r) => r.data)
|
||||
export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data)
|
||||
export const runBacktest = (payload) =>
|
||||
http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data)
|
||||
|
||||
export const getPresets = () => http.get('/presets').then((r) => r.data)
|
||||
export const createPreset = (payload) => http.post('/presets', payload).then((r) => r.data)
|
||||
export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r.data)
|
||||
|
||||
export function openSocket({ onMessage, onOpen, onClose }) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
let socket
|
||||
|
||||
@@ -8,6 +8,7 @@ defineProps({
|
||||
const levelClass = {
|
||||
info: 'text-slate-300',
|
||||
trade: 'text-sky-300',
|
||||
warning: 'text-amber-300',
|
||||
error: 'text-rose-300',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,11 +19,18 @@ const cards = computed(() => [
|
||||
},
|
||||
{ label: 'Cash', value: fmtMoney(props.status.cash), cls: 'text-slate-100' },
|
||||
{ label: 'Price', value: fmtPrice(props.status.price), cls: 'text-slate-100' },
|
||||
{ label: 'Day P&L', value: signed(props.status.day_pnl), cls: pnlClass(props.status.day_pnl) },
|
||||
{ label: 'Drawdown', value: `${fmtMoney(props.status.drawdown_pct)}%`, cls: 'text-amber-300' },
|
||||
{
|
||||
label: 'Trades today',
|
||||
value: String(props.status.trades_today ?? 0),
|
||||
cls: 'text-slate-100',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
|
||||
<div v-for="card in cards" :key="card.label" class="card">
|
||||
<div class="label">{{ card.label }}</div>
|
||||
<div class="value" :class="card.cls">{{ card.value }}</div>
|
||||
|
||||
@@ -9,13 +9,45 @@ const props = defineProps({
|
||||
submitClass: { type: String, default: 'btn-primary' },
|
||||
symbol: { type: String, default: 'BTC/USDT' },
|
||||
timeframe: { type: String, default: '1m' },
|
||||
presets: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['submit'])
|
||||
const emit = defineEmits(['submit', 'save-preset', 'delete-preset', 'load-preset'])
|
||||
|
||||
const selectedKey = ref('')
|
||||
const values = ref({})
|
||||
const symbolValue = ref(props.symbol)
|
||||
const timeframeValue = ref(props.timeframe)
|
||||
const presetName = ref('')
|
||||
|
||||
function loadPreset(event) {
|
||||
const preset = props.presets.find((p) => p.id === Number(event.target.value))
|
||||
if (!preset) return
|
||||
selectedKey.value = preset.strategy_key
|
||||
symbolValue.value = preset.symbol
|
||||
timeframeValue.value = preset.timeframe
|
||||
values.value = { ...preset.params }
|
||||
emit('load-preset', preset)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function savePreset() {
|
||||
if (!selected.value) return
|
||||
emit('save-preset', { name: presetName.value, ...buildPayload() })
|
||||
presetName.value = ''
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const params = {}
|
||||
for (const param of selected.value.params) {
|
||||
params[param.name] = coerce(param, values.value[param.name])
|
||||
}
|
||||
return {
|
||||
strategy_key: selectedKey.value,
|
||||
params,
|
||||
symbol: symbolValue.value,
|
||||
timeframe: timeframeValue.value,
|
||||
}
|
||||
}
|
||||
|
||||
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
|
||||
|
||||
@@ -58,16 +90,7 @@ function coerce(param, raw) {
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const params = {}
|
||||
for (const param of selected.value.params) {
|
||||
params[param.name] = coerce(param, values.value[param.name])
|
||||
}
|
||||
emit('submit', {
|
||||
strategy_key: selectedKey.value,
|
||||
params,
|
||||
symbol: symbolValue.value,
|
||||
timeframe: timeframeValue.value,
|
||||
})
|
||||
emit('submit', buildPayload())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,6 +138,34 @@ function submit() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-2 border-t border-edge/60 pt-3">
|
||||
<label class="space-y-1">
|
||||
<span class="label">Load preset</span>
|
||||
<select class="input" :disabled="disabled" @change="loadPreset">
|
||||
<option value="">—</option>
|
||||
<option v-for="p in presets" :key="p.id" :value="p.id">
|
||||
{{ p.name || `${p.strategy_key} #${p.id}` }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="label">Save as</span>
|
||||
<input v-model="presetName" class="input" placeholder="preset name" :disabled="disabled" />
|
||||
</label>
|
||||
<button class="btn-muted" :disabled="busy || disabled || !selectedKey" @click="savePreset">
|
||||
Save preset
|
||||
</button>
|
||||
<button
|
||||
v-if="presets.length"
|
||||
class="btn-muted"
|
||||
:disabled="busy || disabled"
|
||||
@click="emit('delete-preset', presets[0].id)"
|
||||
title="Delete the most recent preset"
|
||||
>
|
||||
Delete latest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button :class="submitClass" :disabled="busy || disabled || !selectedKey" @click="submit">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,15 @@ const props = defineProps({
|
||||
strategies: { type: Array, required: true },
|
||||
status: { type: Object, required: true },
|
||||
busy: { type: Boolean, default: false },
|
||||
presets: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['start', 'stop'])
|
||||
const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset'])
|
||||
|
||||
function confirmKill() {
|
||||
if (window.confirm('Kill switch: flatten the position and halt trading?')) {
|
||||
emit('kill')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,11 +35,23 @@ const emit = defineEmits(['start', 'stop'])
|
||||
submit-label="Start bot"
|
||||
:symbol="status.symbol"
|
||||
:timeframe="status.timeframe"
|
||||
:presets="presets"
|
||||
@submit="emit('start', $event)"
|
||||
@save-preset="emit('save-preset', $event)"
|
||||
@delete-preset="emit('delete-preset', $event)"
|
||||
/>
|
||||
|
||||
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
|
||||
Stop bot
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
|
||||
Stop bot
|
||||
</button>
|
||||
<button
|
||||
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
|
||||
:disabled="busy || (!status.running && status.open_positions === 0)"
|
||||
@click="confirmKill"
|
||||
>
|
||||
Kill switch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user