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,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
.DS_Store
|
||||
.git/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+4906
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user