Crypto trading bot: engine, brokers, backtester, dashboard, deploy
CI/CD / backend (push) Successful in 5m18s
CI/CD / frontend (push) Successful in 5m2s
CI/CD / build (push) Successful in 34s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 22s

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:
matitos committed 2026-09-18 17:02:33 +00:00
1 parent 98028248c2
commit 9c3bee87ac
73 files changed
+9759 -1

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.vite/
*.log
.DS_Store
.git/
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crypto Trader</title>
</head>
<body class="bg-surface text-slate-100">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
}
}
+4906
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "crypto-trading-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"axios": "^1.7.9",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7",
"vitest": "^2.1.9"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue'
import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue'
import StrategyPanel from './components/StrategyPanel.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 positions = ref([])
const orders = ref([])
const events = ref([])
const equityPoints = ref([])
const busy = ref(false)
const connected = ref(false)
let socket
let pollTimer
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() {
strategies.value = await api.getStrategies()
}
async function handleStart(payload) {
busy.value = true
try {
status.value = await api.startBot(payload)
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function handleStop() {
busy.value = true
try {
status.value = await api.stopBot()
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()
}
}
onMounted(async () => {
await Promise.all([loadStrategies(), api.getStatus().then((s) => (status.value = s)), refreshAll()])
socket = api.openSocket({
onMessage: handleMessage,
onOpen: () => (connected.value = true),
onClose: () => (connected.value = false),
})
pollTimer = setInterval(refreshAll, 5000)
})
onUnmounted(() => {
socket?.close()
clearInterval(pollTimer)
})
</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">
<span
class="h-2 w-2 rounded-full"
:class="connected ? 'bg-emerald-400' : 'bg-slate-500'"
></span>
{{ connected ? 'live' : 'reconnecting' }}
</span>
</div>
<div class="text-xs text-slate-400">
<span v-if="status.strategy_name">Running: {{ status.strategy_name }}</span>
<span v-else>Idle</span>
<span v-if="status.symbol"> · {{ status.symbol }} {{ status.timeframe }}</span>
</div>
</header>
<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="status" />
<StrategyPanel
:strategies="strategies"
:status="status"
:busy="busy"
@start="handleStart"
@stop="handleStop"
/>
<BacktestPanel :strategies="strategies" :status="status" />
<div class="grid gap-4 lg:grid-cols-2">
<EquityChart :points="equityPoints" />
<EventLog :events="events" />
</div>
<PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" />
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getStatus = () => http.get('/status').then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data)
export const getEvents = () => http.get('/events').then((r) => r.data)
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 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 function openSocket({ onMessage, onOpen, onClose }) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
let socket
let retryTimer
let closed = false
const connect = () => {
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`)
socket.onopen = () => onOpen?.()
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
socket.onclose = () => {
onClose?.()
if (!closed) retryTimer = setTimeout(connect, 2000)
}
socket.onerror = () => socket.close()
}
connect()
return {
close() {
closed = true
clearTimeout(retryTimer)
socket?.close()
},
}
}
+140
View File
@@ -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 &amp; 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&amp;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>
+47
View File
@@ -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>
+27
View File
@@ -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>
+42
View File
@@ -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>
+32
View File
@@ -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>
+122
View File
@@ -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>
+38
View File
@@ -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>
+36
View File
@@ -0,0 +1,36 @@
export function fmtMoney(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
}
export function fmtPrice(value) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
const n = Number(value)
return n >= 100 ? fmtMoney(n, 2) : n.toFixed(6)
}
export function fmtNum(value, digits = 6) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
return Number(value).toFixed(digits)
}
export function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString()
}
export function pnlClass(value) {
if (value === null || value === undefined) return 'text-slate-300'
if (Number(value) > 0) return 'text-emerald-400'
if (Number(value) < 0) return 'text-rose-400'
return 'text-slate-300'
}
export function signed(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
const n = Number(value)
return `${n >= 0 ? '+' : ''}${fmtMoney(n, digits)}`
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { fmtMoney, fmtPrice, pnlClass, signed } from './format'
describe('format helpers', () => {
it('formats money with two decimals', () => {
expect(fmtMoney(10000)).toBe('10,000.00')
})
it('handles null', () => {
expect(fmtMoney(null)).toBe('—')
expect(fmtPrice(undefined)).toBe('—')
})
it('formats small prices with six decimals', () => {
expect(fmtPrice(0.5)).toBe('0.500000')
})
it('signs positive values', () => {
expect(signed(12.346)).toBe('+12.35')
expect(signed(-3)).toBe('-3.00')
})
it('classifies pnl colour', () => {
expect(pnlClass(5)).toBe('text-emerald-400')
expect(pnlClass(-5)).toBe('text-rose-400')
expect(pnlClass(0)).toBe('text-slate-300')
})
})
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.card {
@apply rounded-xl border border-edge bg-panel p-4 shadow;
}
.label {
@apply text-xs uppercase tracking-wide text-slate-400;
}
.value {
@apply text-2xl font-semibold tabular-nums;
}
.btn {
@apply rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40;
}
.btn-primary {
@apply btn bg-emerald-500 text-slate-900 hover:bg-emerald-400;
}
.btn-danger {
@apply btn bg-rose-500 text-white hover:bg-rose-400;
}
.btn-muted {
@apply btn border border-edge text-slate-200 hover:bg-slate-700;
}
.input {
@apply w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-sm text-slate-100 outline-none focus:border-emerald-400;
}
}
+14
View File
@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {
colors: {
surface: '#0f172a',
panel: '#1e293b',
edge: '#334155',
},
},
},
plugins: [],
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
},
})