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:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
24
frontend/index.html
Normal file
24
frontend/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lowkey Backtest</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
code, pre, .font-mono, input, select {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2427
frontend/package-lock.json
generated
Normal file
2427
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"plotly.js-dist-min": "^3.3.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
72
frontend/src/App.vue
Normal file
72
frontend/src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import RunHistory from '@/components/RunHistory.vue'
|
||||
|
||||
const historyOpen = ref(true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="w-16 bg-bg-secondary border-r border-border flex flex-col items-center py-4 gap-4">
|
||||
<!-- Logo -->
|
||||
<div class="w-10 h-10 rounded-lg bg-accent-blue flex items-center justify-center text-black font-bold text-lg">
|
||||
LB
|
||||
</div>
|
||||
|
||||
<!-- Nav Links -->
|
||||
<nav class="flex flex-col gap-2 mt-4">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center hover:bg-bg-hover transition-colors"
|
||||
:class="{ 'bg-bg-tertiary': $route.path === '/' }"
|
||||
title="Dashboard"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/compare"
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center hover:bg-bg-hover transition-colors"
|
||||
:class="{ 'bg-bg-tertiary': $route.path === '/compare' }"
|
||||
title="Compare Runs"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Toggle History -->
|
||||
<button
|
||||
@click="historyOpen = !historyOpen"
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center hover:bg-bg-hover transition-colors"
|
||||
:class="{ 'bg-bg-tertiary': historyOpen }"
|
||||
title="Toggle Run History"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<!-- Run History Sidebar -->
|
||||
<aside
|
||||
v-if="historyOpen"
|
||||
class="w-72 bg-bg-secondary border-l border-border overflow-hidden flex flex-col"
|
||||
>
|
||||
<RunHistory />
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
81
frontend/src/api/client.ts
Normal file
81
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* API client for Lowkey Backtest backend.
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
StrategiesResponse,
|
||||
DataStatusResponse,
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
BacktestListResponse,
|
||||
CompareResult,
|
||||
} from './types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Get list of available strategies with parameters.
|
||||
*/
|
||||
export async function getStrategies(): Promise<StrategiesResponse> {
|
||||
const response = await api.get<StrategiesResponse>('/strategies')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available symbols with data status.
|
||||
*/
|
||||
export async function getSymbols(): Promise<DataStatusResponse> {
|
||||
const response = await api.get<DataStatusResponse>('/symbols')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a backtest with the given configuration.
|
||||
*/
|
||||
export async function runBacktest(request: BacktestRequest): Promise<BacktestResult> {
|
||||
const response = await api.post<BacktestResult>('/backtest', request)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of saved backtest runs.
|
||||
*/
|
||||
export async function getBacktests(params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
strategy?: string
|
||||
symbol?: string
|
||||
}): Promise<BacktestListResponse> {
|
||||
const response = await api.get<BacktestListResponse>('/backtests', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific backtest run by ID.
|
||||
*/
|
||||
export async function getBacktest(runId: string): Promise<BacktestResult> {
|
||||
const response = await api.get<BacktestResult>(`/backtest/${runId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backtest run.
|
||||
*/
|
||||
export async function deleteBacktest(runId: string): Promise<void> {
|
||||
await api.delete(`/backtest/${runId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare multiple backtest runs.
|
||||
*/
|
||||
export async function compareRuns(runIds: string[]): Promise<CompareResult> {
|
||||
const response = await api.post<CompareResult>('/compare', { run_ids: runIds })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default api
|
||||
131
frontend/src/api/types.ts
Normal file
131
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* TypeScript types matching the FastAPI Pydantic schemas.
|
||||
*/
|
||||
|
||||
// Strategy types
|
||||
export interface StrategyInfo {
|
||||
name: string
|
||||
display_name: string
|
||||
market_type: string
|
||||
default_leverage: number
|
||||
default_params: Record<string, unknown>
|
||||
grid_params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface StrategiesResponse {
|
||||
strategies: StrategyInfo[]
|
||||
}
|
||||
|
||||
// Symbol/Data types
|
||||
export interface SymbolInfo {
|
||||
symbol: string
|
||||
exchange: string
|
||||
market_type: string
|
||||
timeframes: string[]
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
row_count: number
|
||||
}
|
||||
|
||||
export interface DataStatusResponse {
|
||||
symbols: SymbolInfo[]
|
||||
}
|
||||
|
||||
// Backtest types
|
||||
export interface BacktestRequest {
|
||||
strategy: string
|
||||
symbol: string
|
||||
exchange?: string
|
||||
timeframe?: string
|
||||
market_type?: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
init_cash?: number
|
||||
leverage?: number | null
|
||||
fees?: number | null
|
||||
slippage?: number
|
||||
sl_stop?: number | null
|
||||
tp_stop?: number | null
|
||||
sl_trail?: boolean
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TradeRecord {
|
||||
entry_time: string
|
||||
exit_time: string | null
|
||||
entry_price: number
|
||||
exit_price: number | null
|
||||
size: number
|
||||
direction: string
|
||||
pnl: number | null
|
||||
return_pct: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface EquityPoint {
|
||||
timestamp: string
|
||||
value: number
|
||||
drawdown: number
|
||||
}
|
||||
|
||||
export interface BacktestMetrics {
|
||||
total_return: number
|
||||
benchmark_return: number
|
||||
alpha: number
|
||||
sharpe_ratio: number
|
||||
max_drawdown: number
|
||||
win_rate: number
|
||||
total_trades: number
|
||||
profit_factor: number | null
|
||||
avg_trade_return: number | null
|
||||
total_fees: number
|
||||
total_funding: number
|
||||
liquidation_count: number
|
||||
liquidation_loss: number
|
||||
adjusted_return: number | null
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
run_id: string
|
||||
strategy: string
|
||||
symbol: string
|
||||
market_type: string
|
||||
timeframe: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
leverage: number
|
||||
params: Record<string, unknown>
|
||||
metrics: BacktestMetrics
|
||||
equity_curve: EquityPoint[]
|
||||
trades: TradeRecord[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BacktestSummary {
|
||||
run_id: string
|
||||
strategy: string
|
||||
symbol: string
|
||||
market_type: string
|
||||
timeframe: string
|
||||
total_return: number
|
||||
sharpe_ratio: number
|
||||
max_drawdown: number
|
||||
total_trades: number
|
||||
created_at: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BacktestListResponse {
|
||||
runs: BacktestSummary[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Comparison types
|
||||
export interface CompareRequest {
|
||||
run_ids: string[]
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
runs: BacktestResult[]
|
||||
param_diff: Record<string, unknown[]>
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
186
frontend/src/components/BacktestConfig.vue
Normal file
186
frontend/src/components/BacktestConfig.vue
Normal 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>
|
||||
88
frontend/src/components/EquityCurve.vue
Normal file
88
frontend/src/components/EquityCurve.vue
Normal 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>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
144
frontend/src/components/MetricsPanel.vue
Normal file
144
frontend/src/components/MetricsPanel.vue
Normal 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>
|
||||
141
frontend/src/components/RunHistory.vue
Normal file
141
frontend/src/components/RunHistory.vue
Normal 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>
|
||||
157
frontend/src/components/TradeLog.vue
Normal file
157
frontend/src/components/TradeLog.vue
Normal 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>
|
||||
150
frontend/src/composables/useBacktest.ts
Normal file
150
frontend/src/composables/useBacktest.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Composable for managing backtest state across components.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import type { BacktestResult, BacktestSummary, StrategyInfo, SymbolInfo } from '@/api/types'
|
||||
import { getStrategies, getSymbols, getBacktests, getBacktest, runBacktest, deleteBacktest } from '@/api/client'
|
||||
import type { BacktestRequest } from '@/api/types'
|
||||
|
||||
// Shared state
|
||||
const strategies = ref<StrategyInfo[]>([])
|
||||
const symbols = ref<SymbolInfo[]>([])
|
||||
const runs = ref<BacktestSummary[]>([])
|
||||
const currentResult = ref<BacktestResult | null>(null)
|
||||
const selectedRuns = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const symbolsByMarket = computed(() => {
|
||||
const grouped: Record<string, SymbolInfo[]> = {}
|
||||
for (const s of symbols.value) {
|
||||
const key = `${s.market_type}`
|
||||
if (!grouped[key]) grouped[key] = []
|
||||
grouped[key].push(s)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
export function useBacktest() {
|
||||
/**
|
||||
* Load strategies and symbols on app init.
|
||||
*/
|
||||
async function init() {
|
||||
try {
|
||||
const [stratRes, symRes] = await Promise.all([
|
||||
getStrategies(),
|
||||
getSymbols(),
|
||||
])
|
||||
strategies.value = stratRes.strategies
|
||||
symbols.value = symRes.symbols
|
||||
} catch (e) {
|
||||
error.value = `Failed to load initial data: ${e}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the run history list.
|
||||
*/
|
||||
async function refreshRuns() {
|
||||
try {
|
||||
const res = await getBacktests({ limit: 100 })
|
||||
runs.value = res.runs
|
||||
} catch (e) {
|
||||
error.value = `Failed to load runs: ${e}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a new backtest.
|
||||
*/
|
||||
async function executeBacktest(request: BacktestRequest) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await runBacktest(request)
|
||||
currentResult.value = result
|
||||
await refreshRuns()
|
||||
return result
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
error.value = `Backtest failed: ${msg}`
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific run by ID.
|
||||
*/
|
||||
async function loadRun(runId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await getBacktest(runId)
|
||||
currentResult.value = result
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = `Failed to load run: ${e}`
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a run.
|
||||
*/
|
||||
async function removeRun(runId: string) {
|
||||
try {
|
||||
await deleteBacktest(runId)
|
||||
await refreshRuns()
|
||||
if (currentResult.value?.run_id === runId) {
|
||||
currentResult.value = null
|
||||
}
|
||||
selectedRuns.value = selectedRuns.value.filter(id => id !== runId)
|
||||
} catch (e) {
|
||||
error.value = `Failed to delete run: ${e}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle run selection for comparison.
|
||||
*/
|
||||
function toggleRunSelection(runId: string) {
|
||||
const idx = selectedRuns.value.indexOf(runId)
|
||||
if (idx >= 0) {
|
||||
selectedRuns.value.splice(idx, 1)
|
||||
} else if (selectedRuns.value.length < 5) {
|
||||
selectedRuns.value.push(runId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections.
|
||||
*/
|
||||
function clearSelections() {
|
||||
selectedRuns.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
strategies,
|
||||
symbols,
|
||||
symbolsByMarket,
|
||||
runs,
|
||||
currentResult,
|
||||
selectedRuns,
|
||||
loading,
|
||||
error,
|
||||
// Actions
|
||||
init,
|
||||
refreshRuns,
|
||||
executeBacktest,
|
||||
loadRun,
|
||||
removeRun,
|
||||
toggleRunSelection,
|
||||
clearSelections,
|
||||
}
|
||||
}
|
||||
8
frontend/src/main.ts
Normal file
8
frontend/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
21
frontend/src/router/index.ts
Normal file
21
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import CompareView from '@/views/CompareView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: '/compare',
|
||||
name: 'compare',
|
||||
component: CompareView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
198
frontend/src/style.css
Normal file
198
frontend/src/style.css
Normal file
@@ -0,0 +1,198 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* QuantConnect-inspired dark theme */
|
||||
@theme {
|
||||
/* Background colors */
|
||||
--color-bg-primary: #0d1117;
|
||||
--color-bg-secondary: #161b22;
|
||||
--color-bg-tertiary: #21262d;
|
||||
--color-bg-card: #1c2128;
|
||||
--color-bg-hover: #30363d;
|
||||
|
||||
/* Text colors */
|
||||
--color-text-primary: #e6edf3;
|
||||
--color-text-secondary: #8b949e;
|
||||
--color-text-muted: #6e7681;
|
||||
|
||||
/* Accent colors */
|
||||
--color-accent-blue: #58a6ff;
|
||||
--color-accent-purple: #a371f7;
|
||||
--color-accent-cyan: #39d4e8;
|
||||
|
||||
/* Status colors */
|
||||
--color-profit: #3fb950;
|
||||
--color-loss: #f85149;
|
||||
--color-warning: #d29922;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
|
||||
/* Chart colors for comparison */
|
||||
--color-chart-1: #58a6ff;
|
||||
--color-chart-2: #a371f7;
|
||||
--color-chart-3: #39d4e8;
|
||||
--color-chart-4: #f0883e;
|
||||
--color-chart-5: #db61a2;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Input styling */
|
||||
input[type="number"],
|
||||
input[type="text"],
|
||||
select {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="text"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Button base */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent-blue);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #79b8ff;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.card {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-muted);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Profit/Loss coloring */
|
||||
.profit {
|
||||
color: var(--color-profit);
|
||||
}
|
||||
|
||||
.loss {
|
||||
color: var(--color-loss);
|
||||
}
|
||||
|
||||
/* Metric value styling */
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
4
frontend/src/types/plotly.d.ts
vendored
Normal file
4
frontend/src/types/plotly.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'plotly.js-dist-min' {
|
||||
import Plotly from 'plotly.js'
|
||||
export default Plotly
|
||||
}
|
||||
362
frontend/src/views/CompareView.vue
Normal file
362
frontend/src/views/CompareView.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Plotly from 'plotly.js-dist-min'
|
||||
import { useBacktest } from '@/composables/useBacktest'
|
||||
import { compareRuns } from '@/api/client'
|
||||
import type { BacktestResult, CompareResult } from '@/api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const { selectedRuns, clearSelections } = useBacktest()
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const compareResult = ref<CompareResult | null>(null)
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#58a6ff', // blue
|
||||
'#a371f7', // purple
|
||||
'#39d4e8', // cyan
|
||||
'#f0883e', // orange
|
||||
'#db61a2', // pink
|
||||
]
|
||||
|
||||
async function loadComparison() {
|
||||
if (selectedRuns.value.length < 2) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
compareResult.value = await compareRuns(selectedRuns.value)
|
||||
renderChart()
|
||||
} catch (e) {
|
||||
error.value = `Failed to load comparison: ${e}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value || !compareResult.value) return
|
||||
|
||||
const traces: Plotly.Data[] = compareResult.value.runs.map((run, idx) => {
|
||||
// Normalize equity curves to start at 100 for comparison
|
||||
const startValue = run.equity_curve[0]?.value || 1
|
||||
const normalizedValues = run.equity_curve.map(p => (p.value / startValue) * 100)
|
||||
|
||||
return {
|
||||
x: run.equity_curve.map(p => p.timestamp),
|
||||
y: normalizedValues,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: `${run.strategy} (${run.params.period || ''})`,
|
||||
line: { color: CHART_COLORS[idx % CHART_COLORS.length], width: 2 },
|
||||
hovertemplate: `%{x}<br>${run.strategy}: %{y:.2f}<extra></extra>`,
|
||||
}
|
||||
})
|
||||
|
||||
const layout: Partial<Plotly.Layout> = {
|
||||
title: {
|
||||
text: 'Normalized Equity Comparison (Base 100)',
|
||||
font: { color: '#8b949e', size: 14 },
|
||||
},
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
margin: { l: 60, r: 20, t: 50, b: 40 },
|
||||
xaxis: {
|
||||
showgrid: true,
|
||||
gridcolor: '#30363d',
|
||||
tickfont: { color: '#8b949e', size: 10 },
|
||||
linecolor: '#30363d',
|
||||
},
|
||||
yaxis: {
|
||||
showgrid: true,
|
||||
gridcolor: '#30363d',
|
||||
tickfont: { color: '#8b949e', size: 10 },
|
||||
linecolor: '#30363d',
|
||||
title: { text: 'Normalized Value', font: { color: '#8b949e' } },
|
||||
},
|
||||
legend: {
|
||||
orientation: 'h',
|
||||
yanchor: 'bottom',
|
||||
y: 1.02,
|
||||
xanchor: 'left',
|
||||
x: 0,
|
||||
font: { color: '#8b949e' },
|
||||
},
|
||||
hovermode: 'x unified',
|
||||
}
|
||||
|
||||
Plotly.react(chartRef.value, traces, layout, { responsive: true, displayModeBar: false })
|
||||
}
|
||||
|
||||
function formatPercent(val: number): string {
|
||||
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
function formatNumber(val: number | null): string {
|
||||
if (val === null) return '-'
|
||||
return val.toFixed(2)
|
||||
}
|
||||
|
||||
function getBestIndex(runs: BacktestResult[], metric: keyof BacktestResult['metrics'], higher = true): number {
|
||||
let bestIdx = 0
|
||||
let bestVal = runs[0]?.metrics[metric] ?? 0
|
||||
|
||||
for (let i = 1; i < runs.length; i++) {
|
||||
const val = runs[i]?.metrics[metric] ?? 0
|
||||
const isBetter = higher ? (val as number) > (bestVal as number) : (val as number) < (bestVal as number)
|
||||
if (isBetter) {
|
||||
bestIdx = i
|
||||
bestVal = val
|
||||
}
|
||||
}
|
||||
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
function handleClearAndBack() {
|
||||
clearSelections()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadComparison()
|
||||
window.addEventListener('resize', renderChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', renderChart)
|
||||
if (chartRef.value) {
|
||||
Plotly.purge(chartRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedRuns, () => {
|
||||
if (selectedRuns.value.length >= 2) {
|
||||
loadComparison()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Compare Runs</h1>
|
||||
<p class="text-text-secondary text-sm mt-1">
|
||||
Comparing {{ selectedRuns.length }} backtest runs
|
||||
</p>
|
||||
</div>
|
||||
<button @click="handleClearAndBack" class="btn btn-secondary">
|
||||
Clear & Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="p-4 rounded-lg bg-loss/10 border border-loss/30 text-loss">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="card flex items-center justify-center h-[400px]">
|
||||
<div class="spinner" style="width: 40px; height: 40px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison Results -->
|
||||
<template v-else-if="compareResult">
|
||||
<!-- Equity Curve Comparison -->
|
||||
<div class="card">
|
||||
<div ref="chartRef" class="h-[400px]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Comparison Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<h3 class="text-sm font-semibold text-text-secondary uppercase tracking-wide mb-4">
|
||||
Metrics Comparison
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full mr-2"
|
||||
:style="{ backgroundColor: CHART_COLORS[idx] }"
|
||||
></span>
|
||||
{{ run.strategy }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Total Return -->
|
||||
<tr>
|
||||
<td class="font-medium">Strategy Return</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="[
|
||||
run.metrics.total_return >= 0 ? 'profit' : 'loss',
|
||||
idx === getBestIndex(compareResult.runs, 'total_return') ? 'font-bold' : ''
|
||||
]"
|
||||
>
|
||||
{{ formatPercent(run.metrics.total_return) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Benchmark Return -->
|
||||
<tr>
|
||||
<td class="font-medium">Benchmark (B&H)</td>
|
||||
<td
|
||||
v-for="run in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="run.metrics.benchmark_return >= 0 ? 'profit' : 'loss'"
|
||||
>
|
||||
{{ formatPercent(run.metrics.benchmark_return) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alpha -->
|
||||
<tr>
|
||||
<td class="font-medium">Alpha</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="[
|
||||
run.metrics.alpha >= 0 ? 'profit' : 'loss',
|
||||
idx === getBestIndex(compareResult.runs, 'alpha') ? 'font-bold' : ''
|
||||
]"
|
||||
>
|
||||
{{ formatPercent(run.metrics.alpha) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Sharpe Ratio -->
|
||||
<tr>
|
||||
<td class="font-medium">Sharpe Ratio</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="idx === getBestIndex(compareResult.runs, 'sharpe_ratio') ? 'font-bold text-accent-blue' : ''"
|
||||
>
|
||||
{{ formatNumber(run.metrics.sharpe_ratio) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Max Drawdown -->
|
||||
<tr>
|
||||
<td class="font-medium">Max Drawdown</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center loss"
|
||||
:class="idx === getBestIndex(compareResult.runs, 'max_drawdown', false) ? 'font-bold' : ''"
|
||||
>
|
||||
{{ formatPercent(run.metrics.max_drawdown) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Win Rate -->
|
||||
<tr>
|
||||
<td class="font-medium">Win Rate</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="idx === getBestIndex(compareResult.runs, 'win_rate') ? 'font-bold text-profit' : ''"
|
||||
>
|
||||
{{ formatNumber(run.metrics.win_rate) }}%
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total Trades -->
|
||||
<tr>
|
||||
<td class="font-medium">Total Trades</td>
|
||||
<td
|
||||
v-for="run in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
>
|
||||
{{ run.metrics.total_trades }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Profit Factor -->
|
||||
<tr>
|
||||
<td class="font-medium">Profit Factor</td>
|
||||
<td
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
:class="idx === getBestIndex(compareResult.runs, 'profit_factor') ? 'font-bold text-profit' : ''"
|
||||
>
|
||||
{{ formatNumber(run.metrics.profit_factor) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Parameter Differences -->
|
||||
<div v-if="Object.keys(compareResult.param_diff).length > 0" class="card">
|
||||
<h3 class="text-sm font-semibold text-text-secondary uppercase tracking-wide mb-4">
|
||||
Parameter Differences
|
||||
</h3>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th
|
||||
v-for="(run, idx) in compareResult.runs"
|
||||
:key="run.run_id"
|
||||
class="text-center"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full mr-2"
|
||||
:style="{ backgroundColor: CHART_COLORS[idx] }"
|
||||
></span>
|
||||
Run {{ idx + 1 }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(values, key) in compareResult.param_diff" :key="key">
|
||||
<td class="font-medium font-mono">{{ key }}</td>
|
||||
<td
|
||||
v-for="(val, idx) in values"
|
||||
:key="idx"
|
||||
class="text-center font-mono"
|
||||
>
|
||||
{{ val ?? '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No Selection -->
|
||||
<div v-else class="card flex items-center justify-center h-[400px]">
|
||||
<div class="text-center text-text-muted">
|
||||
<p>Select at least 2 runs from the history to compare.</p>
|
||||
<button @click="router.push('/')" class="btn btn-secondary mt-4">
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
136
frontend/src/views/DashboardView.vue
Normal file
136
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { useBacktest } from '@/composables/useBacktest'
|
||||
import BacktestConfig from '@/components/BacktestConfig.vue'
|
||||
import EquityCurve from '@/components/EquityCurve.vue'
|
||||
import MetricsPanel from '@/components/MetricsPanel.vue'
|
||||
import TradeLog from '@/components/TradeLog.vue'
|
||||
|
||||
const { currentResult, loading, error } = useBacktest()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Lowkey Backtest</h1>
|
||||
<p class="text-text-secondary text-sm mt-1">
|
||||
Run and analyze trading strategy backtests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 rounded-lg bg-loss/10 border border-loss/30 text-loss"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Config Panel (Left) -->
|
||||
<div class="lg:col-span-1">
|
||||
<BacktestConfig />
|
||||
</div>
|
||||
|
||||
<!-- Results (Right) -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="card flex items-center justify-center h-[400px]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="spinner mx-auto mb-4" style="width: 40px; height: 40px;"></div>
|
||||
<p class="text-text-secondary">Running backtest...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Display -->
|
||||
<template v-else-if="currentResult">
|
||||
<!-- Result Header -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ currentResult.strategy }} on {{ currentResult.symbol }}
|
||||
</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">
|
||||
{{ currentResult.start_date }} - {{ currentResult.end_date }}
|
||||
<span class="mx-2">|</span>
|
||||
{{ currentResult.market_type.toUpperCase() }}
|
||||
<span v-if="currentResult.leverage > 1" class="ml-2">
|
||||
{{ currentResult.leverage }}x
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="text-2xl font-bold"
|
||||
:class="currentResult.metrics.total_return >= 0 ? 'profit' : 'loss'"
|
||||
>
|
||||
{{ currentResult.metrics.total_return >= 0 ? '+' : '' }}{{ currentResult.metrics.total_return.toFixed(2) }}%
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
Sharpe: {{ currentResult.metrics.sharpe_ratio.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equity Curve -->
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold text-text-secondary uppercase tracking-wide mb-4">
|
||||
Equity Curve
|
||||
</h3>
|
||||
<div class="h-[350px]">
|
||||
<EquityCurve :data="currentResult.equity_curve" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics -->
|
||||
<MetricsPanel
|
||||
:metrics="currentResult.metrics"
|
||||
:leverage="currentResult.leverage"
|
||||
:market-type="currentResult.market_type"
|
||||
/>
|
||||
|
||||
<!-- Trade Log -->
|
||||
<TradeLog :trades="currentResult.trades" />
|
||||
|
||||
<!-- Parameters Used -->
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold text-text-secondary uppercase tracking-wide mb-3">
|
||||
Parameters
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(value, key) in currentResult.params"
|
||||
:key="key"
|
||||
class="px-2 py-1 rounded bg-bg-tertiary text-sm font-mono"
|
||||
>
|
||||
{{ key }}: {{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="card flex items-center justify-center h-[400px]"
|
||||
>
|
||||
<div class="text-center text-text-muted">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p>Configure and run a backtest to see results.</p>
|
||||
<p class="text-xs mt-2">Or select a run from history.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user