Risk controls, alerts, API token, more exchanges, presets
CI/CD / backend (pull_request) Successful in 6m18s
CI/CD / frontend (pull_request) Successful in 4m55s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped

- 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:
matitos committed 2026-09-18 19:26:29 +00:00
1 parent 9c3bee87ac
commit 03dfa369e5
35 files changed
+1409 -45

No files matched your search

+8 -2
View File
@@ -2,11 +2,17 @@ FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Defaults; override at runtime. The nginx entrypoint runs envsubst over
# /etc/nginx/templates/*.template, so only these referenced vars are replaced.
ENV API_TOKEN_HEADER=X-API-Token
ENV API_TOKEN=
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80
+3
View File
@@ -16,5 +16,8 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
# Inject the optional API token server-side so the browser never sees it
# and the WebUI keeps working without any client changes.
proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}";
}
}
+45 -1
View File
@@ -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" />
+6
View File
@@ -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
+1
View File
@@ -8,6 +8,7 @@ defineProps({
const levelClass = {
info: 'text-slate-300',
trade: 'text-sky-300',
warning: 'text-amber-300',
error: 'text-rose-300',
}
</script>
+8 -1
View File
@@ -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>
+62 -11
View File
@@ -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>
+23 -4
View File
@@ -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>