Implement FastAPI backend and Vue 3 frontend for Lowkey Backtest UI

- Added FastAPI backend with core API endpoints for strategies, backtests, and data management.
- Introduced Vue 3 frontend with a dark theme, enabling users to run backtests, adjust parameters, and compare results.
- Implemented Pydantic schemas for request/response validation and SQLAlchemy models for database interactions.
- Enhanced project structure with dedicated modules for services, routers, and components.
- Updated dependencies in `pyproject.toml` and `frontend/package.json` to include FastAPI, SQLAlchemy, and Vue-related packages.
- Improved `.gitignore` to exclude unnecessary files and directories.
This commit is contained in:
2026-01-14 21:44:04 +08:00
parent 1e4cb87da3
commit 0c82c4f366
53 changed files with 8328 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useBacktest } from '@/composables/useBacktest'
import type { BacktestRequest } from '@/api/types'
const { strategies, symbols, loading, init, executeBacktest } = useBacktest()
// Form state
const selectedStrategy = ref('')
const selectedSymbol = ref('')
const selectedMarket = ref('perpetual')
const timeframe = ref('1h')
const initCash = ref(10000)
const leverage = ref<number | null>(null)
const slStop = ref<number | null>(null)
const tpStop = ref<number | null>(null)
const params = ref<Record<string, number | boolean>>({})
// Initialize
onMounted(async () => {
await init()
if (strategies.value.length > 0 && strategies.value[0]) {
selectedStrategy.value = strategies.value[0].name
}
})
// Get current strategy config
const currentStrategy = computed(() =>
strategies.value.find(s => s.name === selectedStrategy.value)
)
// Filter symbols by market type
const filteredSymbols = computed(() =>
symbols.value.filter(s => s.market_type === selectedMarket.value)
)
// Update params when strategy changes
watch(selectedStrategy, (name) => {
const strategy = strategies.value.find(s => s.name === name)
if (strategy) {
params.value = { ...strategy.default_params } as Record<string, number | boolean>
selectedMarket.value = strategy.market_type
leverage.value = strategy.default_leverage > 1 ? strategy.default_leverage : null
}
})
// Update symbol when market changes
watch([filteredSymbols, selectedMarket], () => {
const firstSymbol = filteredSymbols.value[0]
if (filteredSymbols.value.length > 0 && firstSymbol && !filteredSymbols.value.find(s => s.symbol === selectedSymbol.value)) {
selectedSymbol.value = firstSymbol.symbol
}
})
async function handleSubmit() {
if (!selectedStrategy.value || !selectedSymbol.value) return
const request: BacktestRequest = {
strategy: selectedStrategy.value,
symbol: selectedSymbol.value,
market_type: selectedMarket.value,
timeframe: timeframe.value,
init_cash: initCash.value,
leverage: leverage.value,
sl_stop: slStop.value,
tp_stop: tpStop.value,
params: params.value,
}
await executeBacktest(request)
}
function getParamType(value: unknown): 'number' | 'boolean' | 'unknown' {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
return 'unknown'
}
</script>
<template>
<div class="card">
<h2 class="text-lg font-semibold mb-4">Backtest Configuration</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Strategy -->
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Strategy</label>
<select v-model="selectedStrategy" class="w-full">
<option v-for="s in strategies" :key="s.name" :value="s.name">
{{ s.display_name }}
</option>
</select>
</div>
<!-- Market Type & Symbol -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Market</label>
<select v-model="selectedMarket" class="w-full">
<option value="spot">Spot</option>
<option value="perpetual">Perpetual</option>
</select>
</div>
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Symbol</label>
<select v-model="selectedSymbol" class="w-full">
<option v-for="s in filteredSymbols" :key="s.symbol" :value="s.symbol">
{{ s.symbol }}
</option>
</select>
</div>
</div>
<!-- Timeframe & Cash -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Timeframe</label>
<select v-model="timeframe" class="w-full">
<option value="1h">1 Hour</option>
<option value="4h">4 Hours</option>
<option value="1d">1 Day</option>
</select>
</div>
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Initial Cash</label>
<input type="number" v-model.number="initCash" class="w-full" min="100" step="100" />
</div>
</div>
<!-- Leverage (perpetual only) -->
<div v-if="selectedMarket === 'perpetual'" class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Leverage</label>
<input type="number" v-model.number="leverage" class="w-full" min="1" max="100" placeholder="1" />
</div>
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Stop Loss %</label>
<input type="number" v-model.number="slStop" class="w-full" min="0" max="100" step="0.1" placeholder="None" />
</div>
<div>
<label class="block text-xs text-text-secondary uppercase mb-1">Take Profit %</label>
<input type="number" v-model.number="tpStop" class="w-full" min="0" max="100" step="0.1" placeholder="None" />
</div>
</div>
<!-- Strategy Parameters -->
<div v-if="currentStrategy && Object.keys(params).length > 0">
<h3 class="text-sm font-medium text-text-secondary mb-2">Strategy Parameters</h3>
<div class="grid grid-cols-2 gap-3">
<div v-for="(value, key) in params" :key="key">
<label class="block text-xs text-text-secondary uppercase mb-1">
{{ String(key).replace(/_/g, ' ') }}
</label>
<template v-if="getParamType(value) === 'boolean'">
<input
type="checkbox"
:checked="Boolean(value)"
@change="params[key] = ($event.target as HTMLInputElement).checked"
class="w-5 h-5"
/>
</template>
<template v-else>
<input
type="number"
:value="value"
@input="params[key] = parseFloat(($event.target as HTMLInputElement).value)"
class="w-full"
step="any"
/>
</template>
</div>
</div>
</div>
<!-- Submit -->
<button
type="submit"
class="btn btn-primary w-full"
:disabled="loading || !selectedStrategy || !selectedSymbol"
>
<span v-if="loading" class="spinner"></span>
<span v-else>Run Backtest</span>
</button>
</form>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import Plotly from 'plotly.js-dist-min'
import type { EquityPoint } from '@/api/types'
const props = defineProps<{
data: EquityPoint[]
title?: string
}>()
const chartRef = ref<HTMLDivElement | null>(null)
const CHART_COLORS = {
equity: '#58a6ff',
grid: '#30363d',
text: '#8b949e',
}
function renderChart() {
if (!chartRef.value || props.data.length === 0) return
const timestamps = props.data.map(p => p.timestamp)
const values = props.data.map(p => p.value)
const traces: Plotly.Data[] = [
{
x: timestamps,
y: values,
type: 'scatter',
mode: 'lines',
name: 'Portfolio Value',
line: { color: CHART_COLORS.equity, width: 2 },
hovertemplate: '%{x}<br>Value: $%{y:,.2f}<extra></extra>',
},
]
const layout: Partial<Plotly.Layout> = {
title: props.title ? {
text: props.title,
font: { color: CHART_COLORS.text, size: 14 },
} : undefined,
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
margin: { l: 60, r: 20, t: props.title ? 40 : 20, b: 40 },
xaxis: {
showgrid: true,
gridcolor: CHART_COLORS.grid,
tickfont: { color: CHART_COLORS.text, size: 10 },
linecolor: CHART_COLORS.grid,
},
yaxis: {
showgrid: true,
gridcolor: CHART_COLORS.grid,
tickfont: { color: CHART_COLORS.text, size: 10 },
linecolor: CHART_COLORS.grid,
tickprefix: '$',
hoverformat: ',.2f',
},
showlegend: false,
hovermode: 'x unified',
}
const config: Partial<Plotly.Config> = {
responsive: true,
displayModeBar: false,
}
Plotly.react(chartRef.value, traces, layout, config)
}
watch(() => props.data, renderChart, { deep: true })
onMounted(() => {
renderChart()
window.addEventListener('resize', renderChart)
})
onUnmounted(() => {
window.removeEventListener('resize', renderChart)
if (chartRef.value) {
Plotly.purge(chartRef.value)
}
})
</script>
<template>
<div ref="chartRef" class="w-full h-full min-h-[300px]"></div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { BacktestMetrics } from '@/api/types'
const props = defineProps<{
metrics: BacktestMetrics
leverage?: number
marketType?: string
}>()
function formatPercent(val: number): string {
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'
}
function formatNumber(val: number | null | undefined, decimals = 2): string {
if (val === null || val === undefined) return '-'
return val.toFixed(decimals)
}
function formatCurrency(val: number): string {
return '$' + val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Total Return -->
<div class="card">
<div class="metric-label">Strategy Return</div>
<div
class="metric-value"
:class="metrics.total_return >= 0 ? 'profit' : 'loss'"
>
{{ formatPercent(metrics.total_return) }}
</div>
<div v-if="metrics.adjusted_return !== null && metrics.adjusted_return !== metrics.total_return" class="text-xs text-text-muted mt-1">
Adj: {{ formatPercent(metrics.adjusted_return) }}
</div>
</div>
<!-- Benchmark Return -->
<div class="card">
<div class="metric-label">Benchmark (B&H)</div>
<div
class="metric-value"
:class="metrics.benchmark_return >= 0 ? 'profit' : 'loss'"
>
{{ formatPercent(metrics.benchmark_return) }}
</div>
<div class="text-xs text-text-muted mt-1">
Market change
</div>
</div>
<!-- Alpha -->
<div class="card">
<div class="metric-label">Alpha</div>
<div
class="metric-value"
:class="metrics.alpha >= 0 ? 'profit' : 'loss'"
>
{{ formatPercent(metrics.alpha) }}
</div>
<div class="text-xs text-text-muted mt-1">
vs Buy & Hold
</div>
</div>
<!-- Sharpe Ratio -->
<div class="card">
<div class="metric-label">Sharpe Ratio</div>
<div
class="metric-value"
:class="metrics.sharpe_ratio >= 1 ? 'profit' : metrics.sharpe_ratio < 0 ? 'loss' : ''"
>
{{ formatNumber(metrics.sharpe_ratio) }}
</div>
</div>
<!-- Max Drawdown -->
<div class="card">
<div class="metric-label">Max Drawdown</div>
<div class="metric-value loss">
{{ formatPercent(metrics.max_drawdown) }}
</div>
</div>
<!-- Win Rate -->
<div class="card">
<div class="metric-label">Win Rate</div>
<div
class="metric-value"
:class="metrics.win_rate >= 50 ? 'profit' : 'loss'"
>
{{ formatNumber(metrics.win_rate, 1) }}%
</div>
</div>
<!-- Total Trades -->
<div class="card">
<div class="metric-label">Total Trades</div>
<div class="metric-value">
{{ metrics.total_trades }}
</div>
</div>
<!-- Profit Factor -->
<div class="card">
<div class="metric-label">Profit Factor</div>
<div
class="metric-value"
:class="(metrics.profit_factor || 0) >= 1 ? 'profit' : 'loss'"
>
{{ formatNumber(metrics.profit_factor) }}
</div>
</div>
<!-- Total Fees -->
<div class="card">
<div class="metric-label">Total Fees</div>
<div class="metric-value text-warning">
{{ formatCurrency(metrics.total_fees) }}
</div>
</div>
<!-- Funding (perpetual only) -->
<div v-if="marketType === 'perpetual'" class="card">
<div class="metric-label">Funding Paid</div>
<div class="metric-value text-warning">
{{ formatCurrency(metrics.total_funding) }}
</div>
</div>
<!-- Liquidations (if any) -->
<div v-if="metrics.liquidation_count > 0" class="card">
<div class="metric-label">Liquidations</div>
<div class="metric-value loss">
{{ metrics.liquidation_count }}
</div>
<div class="text-xs text-text-muted mt-1">
Lost: {{ formatCurrency(metrics.liquidation_loss) }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useBacktest } from '@/composables/useBacktest'
import { useRouter } from 'vue-router'
const router = useRouter()
const {
runs,
currentResult,
selectedRuns,
refreshRuns,
loadRun,
removeRun,
toggleRunSelection
} = useBacktest()
onMounted(() => {
refreshRuns()
})
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function formatReturn(val: number): string {
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'
}
async function handleClick(runId: string) {
await loadRun(runId)
router.push('/')
}
function handleCheckbox(e: Event, runId: string) {
e.stopPropagation()
toggleRunSelection(runId)
}
function handleDelete(e: Event, runId: string) {
e.stopPropagation()
if (confirm('Delete this run?')) {
removeRun(runId)
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="p-4 border-b border-border">
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide">
Run History
</h2>
<p class="text-xs text-text-muted mt-1">
{{ runs.length }} runs | {{ selectedRuns.length }} selected
</p>
</div>
<!-- Run List -->
<div class="flex-1 overflow-y-auto">
<div
v-for="run in runs"
:key="run.run_id"
@click="handleClick(run.run_id)"
class="p-3 border-b border-border-muted cursor-pointer hover:bg-bg-hover transition-colors"
:class="{ 'bg-bg-tertiary': currentResult?.run_id === run.run_id }"
>
<div class="flex items-start gap-2">
<!-- Checkbox for comparison -->
<input
type="checkbox"
:checked="selectedRuns.includes(run.run_id)"
@click="handleCheckbox($event, run.run_id)"
class="mt-1 w-4 h-4 rounded border-border bg-bg-tertiary"
/>
<div class="flex-1 min-w-0">
<!-- Strategy & Symbol -->
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ run.strategy }}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-bg-tertiary text-text-secondary">
{{ run.symbol }}
</span>
</div>
<!-- Metrics -->
<div class="flex items-center gap-3 mt-1">
<span
class="text-sm font-mono"
:class="run.total_return >= 0 ? 'profit' : 'loss'"
>
{{ formatReturn(run.total_return) }}
</span>
<span class="text-xs text-text-muted">
SR {{ run.sharpe_ratio.toFixed(2) }}
</span>
</div>
<!-- Date -->
<div class="text-xs text-text-muted mt-1">
{{ formatDate(run.created_at) }}
</div>
</div>
<!-- Delete button -->
<button
@click="handleDelete($event, run.run_id)"
class="p-1 rounded hover:bg-loss/20 text-text-muted hover:text-loss transition-colors"
title="Delete run"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty state -->
<div v-if="runs.length === 0" class="p-8 text-center text-text-muted">
<p>No runs yet.</p>
<p class="text-xs mt-1">Run a backtest to see results here.</p>
</div>
</div>
<!-- Compare Button -->
<div v-if="selectedRuns.length >= 2" class="p-4 border-t border-border">
<router-link
to="/compare"
class="btn btn-primary w-full"
>
Compare {{ selectedRuns.length }} Runs
</router-link>
</div>
</div>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TradeRecord } from '@/api/types'
const props = defineProps<{
trades: TradeRecord[]
}>()
type SortKey = 'entry_time' | 'pnl' | 'return_pct' | 'size'
const sortKey = ref<SortKey>('entry_time')
const sortDesc = ref(true)
const sortedTrades = computed(() => {
return [...props.trades].sort((a, b) => {
let aVal: number | string = 0
let bVal: number | string = 0
switch (sortKey.value) {
case 'entry_time':
aVal = a.entry_time
bVal = b.entry_time
break
case 'pnl':
aVal = a.pnl ?? 0
bVal = b.pnl ?? 0
break
case 'return_pct':
aVal = a.return_pct ?? 0
bVal = b.return_pct ?? 0
break
case 'size':
aVal = a.size
bVal = b.size
break
}
if (aVal < bVal) return sortDesc.value ? 1 : -1
if (aVal > bVal) return sortDesc.value ? -1 : 1
return 0
})
})
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortDesc.value = !sortDesc.value
} else {
sortKey.value = key
sortDesc.value = true
}
}
function formatDate(iso: string): string {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatPrice(val: number | null): string {
if (val === null) return '-'
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatPnL(val: number | null): string {
if (val === null) return '-'
const sign = val >= 0 ? '+' : ''
return sign + val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatReturn(val: number | null): string {
if (val === null) return '-'
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'
}
</script>
<template>
<div class="card overflow-hidden">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-text-secondary uppercase tracking-wide">
Trade Log
</h3>
<span class="text-xs text-text-muted">{{ trades.length }} trades</span>
</div>
<div class="overflow-x-auto max-h-[400px] overflow-y-auto">
<table class="min-w-full">
<thead class="sticky top-0 bg-bg-card">
<tr>
<th
@click="toggleSort('entry_time')"
class="cursor-pointer hover:text-text-primary"
>
Entry Time
<span v-if="sortKey === 'entry_time'">{{ sortDesc ? ' v' : ' ^' }}</span>
</th>
<th>Exit Time</th>
<th>Direction</th>
<th>Entry</th>
<th>Exit</th>
<th
@click="toggleSort('size')"
class="cursor-pointer hover:text-text-primary"
>
Size
<span v-if="sortKey === 'size'">{{ sortDesc ? ' v' : ' ^' }}</span>
</th>
<th
@click="toggleSort('pnl')"
class="cursor-pointer hover:text-text-primary"
>
PnL
<span v-if="sortKey === 'pnl'">{{ sortDesc ? ' v' : ' ^' }}</span>
</th>
<th
@click="toggleSort('return_pct')"
class="cursor-pointer hover:text-text-primary"
>
Return
<span v-if="sortKey === 'return_pct'">{{ sortDesc ? ' v' : ' ^' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(trade, idx) in sortedTrades" :key="idx">
<td class="text-text-secondary">{{ formatDate(trade.entry_time) }}</td>
<td class="text-text-secondary">{{ formatDate(trade.exit_time || '') }}</td>
<td>
<span
class="px-2 py-0.5 rounded text-xs font-medium"
:class="trade.direction === 'Long' ? 'bg-profit/20 text-profit' : 'bg-loss/20 text-loss'"
>
{{ trade.direction }}
</span>
</td>
<td>${{ formatPrice(trade.entry_price) }}</td>
<td>${{ formatPrice(trade.exit_price) }}</td>
<td>{{ trade.size.toFixed(4) }}</td>
<td :class="(trade.pnl ?? 0) >= 0 ? 'profit' : 'loss'">
${{ formatPnL(trade.pnl) }}
</td>
<td :class="(trade.return_pct ?? 0) >= 0 ? 'profit' : 'loss'">
{{ formatReturn(trade.return_pct) }}
</td>
</tr>
</tbody>
</table>
<div v-if="trades.length === 0" class="p-8 text-center text-text-muted">
No trades executed.
</div>
</div>
</div>
</template>