Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as api from '../api'
|
||||
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
|
||||
import EquityChart from './EquityChart.vue'
|
||||
import StrategyForm from './StrategyForm.vue'
|
||||
|
||||
defineProps({
|
||||
strategies: { type: Array, required: true },
|
||||
status: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const running = ref(false)
|
||||
const error = ref(null)
|
||||
const result = ref(null)
|
||||
const limit = ref(500)
|
||||
|
||||
async function run(payload) {
|
||||
running.value = true
|
||||
error.value = null
|
||||
try {
|
||||
result.value = await api.runBacktest({ ...payload, limit: Number(limit.value) })
|
||||
if (result.value.error) error.value = result.value.error
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || e.message
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Backtest</h2>
|
||||
<span v-if="result" class="text-xs text-slate-400">
|
||||
{{ result.symbol }} {{ result.timeframe }} · {{ result.equity_curve.length }} candles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategyForm
|
||||
:strategies="strategies"
|
||||
:busy="running"
|
||||
:symbol="status.symbol"
|
||||
:timeframe="status.timeframe"
|
||||
submit-label="Run backtest"
|
||||
@submit="run"
|
||||
/>
|
||||
|
||||
<label class="block max-w-[12rem] space-y-1">
|
||||
<span class="label">Historical candles</span>
|
||||
<input v-model="limit" type="number" min="20" max="1000" class="input" />
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<template v-if="result && !result.error">
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Total return</div>
|
||||
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.total_return_pct)">
|
||||
{{ signed(result.metrics.total_return_pct) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Buy & hold</div>
|
||||
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.buy_hold_return_pct)">
|
||||
{{ signed(result.metrics.buy_hold_return_pct) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Max drawdown</div>
|
||||
<div class="text-lg font-semibold tabular-nums text-amber-300">
|
||||
-{{ fmtMoney(result.metrics.max_drawdown_pct) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Final equity</div>
|
||||
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.final_equity) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Trades</div>
|
||||
<div class="text-lg font-semibold tabular-nums">{{ result.metrics.num_trades }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Win rate</div>
|
||||
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.win_rate_pct) }}%</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Profit factor</div>
|
||||
<div class="text-lg font-semibold tabular-nums">
|
||||
{{ result.metrics.profit_factor === null ? '—' : fmtMoney(result.metrics.profit_factor) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-edge/60 p-3">
|
||||
<div class="label">Sharpe</div>
|
||||
<div class="text-lg font-semibold tabular-nums">
|
||||
{{ result.metrics.sharpe === null ? '—' : fmtMoney(result.metrics.sharpe) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EquityChart :points="result.equity_curve" title="Backtest equity" />
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="label">
|
||||
<tr>
|
||||
<th class="pb-2">Side</th>
|
||||
<th class="pb-2 text-right">Entry</th>
|
||||
<th class="pb-2 text-right">Exit</th>
|
||||
<th class="pb-2 text-right">P&L</th>
|
||||
<th class="pb-2 text-right">Return</th>
|
||||
<th class="pb-2">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(t, i) in result.trades" :key="i" class="border-t border-edge/60">
|
||||
<td class="py-1.5" :class="t.side === 'long' ? 'text-emerald-400' : 'text-rose-400'">
|
||||
{{ t.side }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.entry_price) }}</td>
|
||||
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.exit_price) }}</td>
|
||||
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.pnl)">{{ signed(t.pnl) }}</td>
|
||||
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.return_pct)">
|
||||
{{ signed(t.return_pct) }}%
|
||||
</td>
|
||||
<td class="py-1.5 text-slate-400">{{ t.reason }}</td>
|
||||
</tr>
|
||||
<tr v-if="!result.trades.length">
|
||||
<td colspan="6" class="py-6 text-center text-slate-500">No trades</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { fmtMoney } from '../format'
|
||||
|
||||
const props = defineProps({
|
||||
points: { type: Array, required: true },
|
||||
title: { type: String, default: 'Equity' },
|
||||
})
|
||||
|
||||
const WIDTH = 600
|
||||
const HEIGHT = 160
|
||||
|
||||
const path = computed(() => {
|
||||
const values = props.points.map((p) => p.equity)
|
||||
if (values.length < 2) return ''
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const span = max - min || 1
|
||||
return values
|
||||
.map((value, i) => {
|
||||
const x = (i / (values.length - 1)) * WIDTH
|
||||
const y = HEIGHT - ((value - min) / span) * HEIGHT
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const latest = computed(() => (props.points.length ? props.points[props.points.length - 1].equity : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-semibold">{{ title }}</h2>
|
||||
<span class="tabular-nums text-slate-300">{{ fmtMoney(latest) }}</span>
|
||||
</div>
|
||||
<svg
|
||||
v-if="path"
|
||||
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
||||
preserveAspectRatio="none"
|
||||
class="h-40 w-full"
|
||||
>
|
||||
<path :d="path" fill="none" stroke="#34d399" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<div v-else class="py-12 text-center text-sm text-slate-500">Not enough data yet</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { fmtTime } from '../format'
|
||||
|
||||
defineProps({
|
||||
events: { type: Array, required: true },
|
||||
})
|
||||
|
||||
const levelClass = {
|
||||
info: 'text-slate-300',
|
||||
trade: 'text-sky-300',
|
||||
error: 'text-rose-300',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-3 font-semibold">Activity</h2>
|
||||
<div class="max-h-72 space-y-1 overflow-y-auto font-mono text-xs">
|
||||
<div v-for="e in events" :key="e.id" class="flex gap-2 border-b border-edge/40 py-1">
|
||||
<span class="shrink-0 text-slate-500">{{ fmtTime(e.created_at) }}</span>
|
||||
<span class="shrink-0" :class="levelClass[e.level] || 'text-slate-300'">[{{ e.level }}]</span>
|
||||
<span :class="levelClass[e.level] || 'text-slate-300'">{{ e.message }}</span>
|
||||
</div>
|
||||
<div v-if="!events.length" class="py-6 text-center text-slate-500">No activity yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { fmtNum, fmtPrice, fmtTime } from '../format'
|
||||
|
||||
defineProps({
|
||||
orders: { type: Array, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-3 font-semibold">Orders</h2>
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="label sticky top-0 bg-panel">
|
||||
<tr>
|
||||
<th class="pb-2">Time</th>
|
||||
<th class="pb-2">Side</th>
|
||||
<th class="pb-2">Symbol</th>
|
||||
<th class="pb-2 text-right">Qty</th>
|
||||
<th class="pb-2 text-right">Price</th>
|
||||
<th class="pb-2">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id" class="border-t border-edge/60">
|
||||
<td class="py-2 text-slate-400">{{ fmtTime(o.created_at) }}</td>
|
||||
<td class="py-2" :class="o.side === 'buy' ? 'text-emerald-400' : 'text-rose-400'">
|
||||
{{ o.side }}
|
||||
</td>
|
||||
<td class="py-2">{{ o.symbol }}</td>
|
||||
<td class="py-2 text-right tabular-nums">{{ fmtNum(o.qty) }}</td>
|
||||
<td class="py-2 text-right tabular-nums">{{ fmtPrice(o.price) }}</td>
|
||||
<td class="py-2 text-slate-400">{{ o.reason }}</td>
|
||||
</tr>
|
||||
<tr v-if="!orders.length">
|
||||
<td colspan="6" class="py-6 text-center text-slate-500">No orders yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { fmtNum, fmtPrice, fmtTime, pnlClass, signed } from '../format'
|
||||
|
||||
defineProps({
|
||||
positions: { type: Array, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-3 font-semibold">Positions</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="label">
|
||||
<tr>
|
||||
<th class="pb-2">#</th>
|
||||
<th class="pb-2">Side</th>
|
||||
<th class="pb-2">Symbol</th>
|
||||
<th class="pb-2 text-right">Qty</th>
|
||||
<th class="pb-2 text-right">Entry</th>
|
||||
<th class="pb-2 text-right">Exit</th>
|
||||
<th class="pb-2 text-right">P&L</th>
|
||||
<th class="pb-2">Status</th>
|
||||
<th class="pb-2">Opened</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in positions" :key="p.id" class="border-t border-edge/60">
|
||||
<td class="py-2 text-slate-400">{{ p.id }}</td>
|
||||
<td class="py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs font-medium"
|
||||
:class="
|
||||
p.side === 'long'
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'bg-rose-500/20 text-rose-300'
|
||||
"
|
||||
>
|
||||
{{ p.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">{{ p.symbol }}</td>
|
||||
<td class="py-2 text-right tabular-nums">{{ fmtNum(p.qty) }}</td>
|
||||
<td class="py-2 text-right tabular-nums">{{ fmtPrice(p.entry_price) }}</td>
|
||||
<td class="py-2 text-right tabular-nums">{{ fmtPrice(p.exit_price) }}</td>
|
||||
<td class="py-2 text-right tabular-nums" :class="pnlClass(p.realized_pnl ?? p.unrealized_pnl)">
|
||||
{{ signed(p.realized_pnl ?? p.unrealized_pnl) }}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs"
|
||||
:class="p.status === 'open' ? 'bg-sky-500/20 text-sky-300' : 'bg-slate-600/40 text-slate-300'"
|
||||
>
|
||||
{{ p.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 text-slate-400">{{ fmtTime(p.opened_at) }}</td>
|
||||
<td class="py-2 text-right">
|
||||
<button
|
||||
v-if="p.status === 'open'"
|
||||
class="btn-muted"
|
||||
@click="emit('close', p.id)"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!positions.length">
|
||||
<td colspan="10" class="py-6 text-center text-slate-500">No positions yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: 'Equity', value: fmtMoney(props.status.equity), cls: 'text-slate-100' },
|
||||
{ label: 'Total P&L', value: signed(props.status.total_pnl), cls: pnlClass(props.status.total_pnl) },
|
||||
{
|
||||
label: 'Realized P&L',
|
||||
value: signed(props.status.realized_pnl),
|
||||
cls: pnlClass(props.status.realized_pnl),
|
||||
},
|
||||
{
|
||||
label: 'Unrealized P&L',
|
||||
value: signed(props.status.unrealized_pnl),
|
||||
cls: pnlClass(props.status.unrealized_pnl),
|
||||
},
|
||||
{ label: 'Cash', value: fmtMoney(props.status.cash), cls: 'text-slate-100' },
|
||||
{ label: 'Price', value: fmtPrice(props.status.price), cls: 'text-slate-100' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
strategies: { type: Array, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
busy: { type: Boolean, default: false },
|
||||
submitLabel: { type: String, default: 'Run' },
|
||||
submitClass: { type: String, default: 'btn-primary' },
|
||||
symbol: { type: String, default: 'BTC/USDT' },
|
||||
timeframe: { type: String, default: '1m' },
|
||||
})
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const selectedKey = ref('')
|
||||
const values = ref({})
|
||||
const symbolValue = ref(props.symbol)
|
||||
const timeframeValue = ref(props.timeframe)
|
||||
|
||||
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
|
||||
|
||||
watch(
|
||||
selected,
|
||||
(strategy) => {
|
||||
if (strategy) {
|
||||
values.value = Object.fromEntries(strategy.params.map((p) => [p.name, p.default]))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.strategies,
|
||||
(list) => {
|
||||
if (!selectedKey.value && list.length) selectedKey.value = list[0].key
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.symbol,
|
||||
(value) => {
|
||||
if (value) symbolValue.value = value
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.timeframe,
|
||||
(value) => {
|
||||
if (value) timeframeValue.value = value
|
||||
},
|
||||
)
|
||||
|
||||
function coerce(param, raw) {
|
||||
if (param.type === 'int') return parseInt(raw, 10)
|
||||
if (param.type === 'float') return parseFloat(raw)
|
||||
if (param.type === 'bool') return raw === true || raw === 'true'
|
||||
return 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,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<label class="space-y-1">
|
||||
<span class="label">Strategy</span>
|
||||
<select v-model="selectedKey" class="input" :disabled="disabled">
|
||||
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="label">Symbol</span>
|
||||
<input v-model="symbolValue" class="input" :disabled="disabled" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="label">Timeframe</span>
|
||||
<input v-model="timeframeValue" class="input" :disabled="disabled" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="selected" class="text-xs text-slate-400">{{ selected.description }}</p>
|
||||
|
||||
<div v-if="selected && selected.params.length" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label v-for="param in selected.params" :key="param.name" class="space-y-1">
|
||||
<span class="label" :title="param.description">{{ param.name }}</span>
|
||||
<select
|
||||
v-if="param.type === 'bool'"
|
||||
v-model="values[param.name]"
|
||||
class="input"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option :value="true">true</option>
|
||||
<option :value="false">false</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="values[param.name]"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button :class="submitClass" :disabled="busy || disabled || !selectedKey" @click="submit">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import StrategyForm from './StrategyForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
strategies: { type: Array, required: true },
|
||||
status: { type: Object, required: true },
|
||||
busy: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['start', 'stop'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Strategy</h2>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="status.running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
|
||||
>
|
||||
{{ status.running ? 'running' : 'stopped' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategyForm
|
||||
:strategies="strategies"
|
||||
:disabled="status.running"
|
||||
:busy="busy"
|
||||
submit-label="Start bot"
|
||||
:symbol="status.symbol"
|
||||
:timeframe="status.timeframe"
|
||||
@submit="emit('start', $event)"
|
||||
/>
|
||||
|
||||
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
|
||||
Stop bot
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user