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

3
api/services/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Business logic services for backtest execution and storage.
"""

300
api/services/runner.py Normal file
View File

@@ -0,0 +1,300 @@
"""
Backtest runner service that wraps the existing Backtester engine.
"""
import uuid
from datetime import datetime, timezone
from typing import Any
import pandas as pd
from api.models.schemas import (
BacktestMetrics,
BacktestRequest,
BacktestResult,
EquityPoint,
TradeRecord,
)
from engine.backtester import Backtester
from engine.data_manager import DataManager
from engine.logging_config import get_logger
from engine.market import MarketType
from strategies.factory import get_strategy
logger = get_logger(__name__)
class BacktestRunner:
"""
Service for executing backtests via the API.
Wraps the existing Backtester engine and converts results
to API response format.
"""
def __init__(self):
self.dm = DataManager()
self.bt = Backtester(self.dm)
def run(self, request: BacktestRequest) -> BacktestResult:
"""
Execute a backtest and return structured results.
Args:
request: BacktestRequest with strategy, symbol, and parameters
Returns:
BacktestResult with metrics, equity curve, and trades
"""
# Get strategy instance
strategy, default_params = get_strategy(request.strategy, is_grid=False)
# Merge default params with request params
params = {**default_params, **request.params}
# Convert market type string to enum
market_type = MarketType(request.market_type)
# Override strategy market type if specified
strategy.default_market_type = market_type
logger.info(
"Running backtest: %s on %s (%s), params=%s",
request.strategy, request.symbol, request.timeframe, params
)
# Execute backtest
result = self.bt.run_strategy(
strategy=strategy,
exchange_id=request.exchange,
symbol=request.symbol,
timeframe=request.timeframe,
start_date=request.start_date,
end_date=request.end_date,
init_cash=request.init_cash,
fees=request.fees,
slippage=request.slippage,
sl_stop=request.sl_stop,
tp_stop=request.tp_stop,
sl_trail=request.sl_trail,
leverage=request.leverage,
**params
)
# Extract data from portfolio
portfolio = result.portfolio
# Build trade records
trades = self._build_trade_records(portfolio)
# Build equity curve (trimmed to trading period)
equity_curve = self._build_equity_curve(portfolio)
# Build metrics
metrics = self._build_metrics(result, portfolio)
# Get date range from actual trading period (first trade to end)
idx = portfolio.wrapper.index
end_date = idx[-1].strftime("%Y-%m-%d %H:%M")
# Use first trade time as start if trades exist
trades_df = portfolio.trades.records_readable
if not trades_df.empty:
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
if first_entry_col in trades_df.columns:
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
start_date = first_trade_time.strftime("%Y-%m-%d %H:%M")
else:
start_date = idx[0].strftime("%Y-%m-%d %H:%M")
else:
start_date = idx[0].strftime("%Y-%m-%d %H:%M")
return BacktestResult(
run_id=str(uuid.uuid4()),
strategy=request.strategy,
symbol=request.symbol,
market_type=result.market_type.value,
timeframe=request.timeframe,
start_date=start_date,
end_date=end_date,
leverage=result.leverage,
params=params,
metrics=metrics,
equity_curve=equity_curve,
trades=trades,
created_at=datetime.now(timezone.utc).isoformat(),
)
def _build_equity_curve(self, portfolio) -> list[EquityPoint]:
"""Extract equity curve with drawdown from portfolio, starting from first trade."""
value_series = portfolio.value()
drawdown_series = portfolio.drawdown()
# Handle multi-column case (from grid search)
if hasattr(value_series, 'columns') and len(value_series.columns) > 1:
value_series = value_series.iloc[:, 0]
drawdown_series = drawdown_series.iloc[:, 0]
elif hasattr(value_series, 'columns'):
value_series = value_series.iloc[:, 0]
drawdown_series = drawdown_series.iloc[:, 0]
# Find first trade time to trim equity curve
first_trade_idx = 0
trades_df = portfolio.trades.records_readable
if not trades_df.empty:
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
if first_entry_col in trades_df.columns:
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
# Find index in value_series closest to first trade
if hasattr(value_series.index, 'get_indexer'):
first_trade_idx = value_series.index.get_indexer([first_trade_time], method='nearest')[0]
# Start a few bars before first trade for context
first_trade_idx = max(0, first_trade_idx - 5)
# Slice from first trade onwards
value_series = value_series.iloc[first_trade_idx:]
drawdown_series = drawdown_series.iloc[first_trade_idx:]
points = []
for i, (ts, val) in enumerate(value_series.items()):
dd = drawdown_series.iloc[i] if i < len(drawdown_series) else 0.0
points.append(EquityPoint(
timestamp=ts.isoformat(),
value=float(val),
drawdown=float(dd) * 100, # Convert to percentage
))
return points
def _build_trade_records(self, portfolio) -> list[TradeRecord]:
"""Extract trade records from portfolio."""
trades_df = portfolio.trades.records_readable
if trades_df.empty:
return []
records = []
for _, row in trades_df.iterrows():
# Handle different column names in vectorbt
entry_time = row.get('Entry Timestamp', row.get('Entry Time', ''))
exit_time = row.get('Exit Timestamp', row.get('Exit Time', ''))
records.append(TradeRecord(
entry_time=str(entry_time) if pd.notna(entry_time) else "",
exit_time=str(exit_time) if pd.notna(exit_time) else None,
entry_price=float(row.get('Avg Entry Price', row.get('Entry Price', 0))),
exit_price=float(row.get('Avg Exit Price', row.get('Exit Price', 0)))
if pd.notna(row.get('Avg Exit Price', row.get('Exit Price'))) else None,
size=float(row.get('Size', 0)),
direction=str(row.get('Direction', 'Long')),
pnl=float(row.get('PnL', 0)) if pd.notna(row.get('PnL')) else None,
return_pct=float(row.get('Return', 0)) * 100
if pd.notna(row.get('Return')) else None,
status="closed" if pd.notna(exit_time) else "open",
))
return records
def _build_metrics(self, result, portfolio) -> BacktestMetrics:
"""Build metrics from backtest result."""
stats = portfolio.stats()
# Extract values, handling potential multi-column results
def get_stat(key: str, default: float = 0.0) -> float:
val = stats.get(key, default)
if hasattr(val, 'mean'):
return float(val.mean())
return float(val) if pd.notna(val) else default
total_return = portfolio.total_return()
if hasattr(total_return, 'mean'):
total_return = total_return.mean()
# Calculate benchmark return from first trade to end (not full period)
# This gives accurate comparison when strategy has training period
close = portfolio.close
benchmark_return = 0.0
if hasattr(close, 'iloc'):
# Find first trade entry time
trades_df = portfolio.trades.records_readable
if not trades_df.empty:
# Get the first trade entry timestamp
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
if first_entry_col in trades_df.columns:
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
# Find the price at first trade
if hasattr(close.index, 'get_indexer'):
# Find closest index to first trade time
idx = close.index.get_indexer([first_trade_time], method='nearest')[0]
start_price = close.iloc[idx]
else:
start_price = close.iloc[0]
end_price = close.iloc[-1]
if hasattr(start_price, 'mean'):
start_price = start_price.mean()
if hasattr(end_price, 'mean'):
end_price = end_price.mean()
benchmark_return = ((end_price - start_price) / start_price)
else:
# No trades - use full period
start_price = close.iloc[0]
end_price = close.iloc[-1]
if hasattr(start_price, 'mean'):
start_price = start_price.mean()
if hasattr(end_price, 'mean'):
end_price = end_price.mean()
benchmark_return = ((end_price - start_price) / start_price)
# Alpha = strategy return - benchmark return
alpha = float(total_return) - float(benchmark_return)
sharpe = portfolio.sharpe_ratio()
if hasattr(sharpe, 'mean'):
sharpe = sharpe.mean()
max_dd = portfolio.max_drawdown()
if hasattr(max_dd, 'mean'):
max_dd = max_dd.mean()
win_rate = portfolio.trades.win_rate()
if hasattr(win_rate, 'mean'):
win_rate = win_rate.mean()
trade_count = portfolio.trades.count()
if hasattr(trade_count, 'mean'):
trade_count = int(trade_count.mean())
else:
trade_count = int(trade_count)
return BacktestMetrics(
total_return=float(total_return) * 100,
benchmark_return=float(benchmark_return) * 100,
alpha=float(alpha) * 100,
sharpe_ratio=float(sharpe) if pd.notna(sharpe) else 0.0,
max_drawdown=float(max_dd) * 100,
win_rate=float(win_rate) * 100 if pd.notna(win_rate) else 0.0,
total_trades=trade_count,
profit_factor=get_stat('Profit Factor'),
avg_trade_return=get_stat('Avg Winning Trade [%]'),
total_fees=get_stat('Total Fees Paid'),
total_funding=result.total_funding_paid,
liquidation_count=result.liquidation_count,
liquidation_loss=result.total_liquidation_loss,
adjusted_return=result.adjusted_return,
)
# Singleton instance
_runner: BacktestRunner | None = None
def get_runner() -> BacktestRunner:
"""Get or create the backtest runner instance."""
global _runner
if _runner is None:
_runner = BacktestRunner()
return _runner

225
api/services/storage.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Storage service for persisting and retrieving backtest runs.
"""
from sqlalchemy.orm import Session
from api.models.database import BacktestRun
from api.models.schemas import (
BacktestResult,
BacktestSummary,
EquityPoint,
BacktestMetrics,
TradeRecord,
)
class StorageService:
"""
Service for saving and loading backtest runs from SQLite.
"""
def save_run(self, db: Session, result: BacktestResult) -> BacktestRun:
"""
Persist a backtest result to the database.
Args:
db: Database session
result: BacktestResult to save
Returns:
Created BacktestRun record
"""
run = BacktestRun(
run_id=result.run_id,
strategy=result.strategy,
symbol=result.symbol,
market_type=result.market_type,
timeframe=result.timeframe,
leverage=result.leverage,
params=result.params,
start_date=result.start_date,
end_date=result.end_date,
total_return=result.metrics.total_return,
benchmark_return=result.metrics.benchmark_return,
alpha=result.metrics.alpha,
sharpe_ratio=result.metrics.sharpe_ratio,
max_drawdown=result.metrics.max_drawdown,
win_rate=result.metrics.win_rate,
total_trades=result.metrics.total_trades,
profit_factor=result.metrics.profit_factor,
total_fees=result.metrics.total_fees,
total_funding=result.metrics.total_funding,
liquidation_count=result.metrics.liquidation_count,
liquidation_loss=result.metrics.liquidation_loss,
adjusted_return=result.metrics.adjusted_return,
)
# Serialize complex data
run.set_equity_curve([p.model_dump() for p in result.equity_curve])
run.set_trades([t.model_dump() for t in result.trades])
db.add(run)
db.commit()
db.refresh(run)
return run
def get_run(self, db: Session, run_id: str) -> BacktestResult | None:
"""
Retrieve a backtest run by ID.
Args:
db: Database session
run_id: UUID of the run
Returns:
BacktestResult or None if not found
"""
run = db.query(BacktestRun).filter(BacktestRun.run_id == run_id).first()
if not run:
return None
return self._to_result(run)
def list_runs(
self,
db: Session,
limit: int = 50,
offset: int = 0,
strategy: str | None = None,
symbol: str | None = None,
) -> tuple[list[BacktestSummary], int]:
"""
List backtest runs with optional filtering.
Args:
db: Database session
limit: Maximum number of runs to return
offset: Offset for pagination
strategy: Filter by strategy name
symbol: Filter by symbol
Returns:
Tuple of (list of summaries, total count)
"""
query = db.query(BacktestRun)
if strategy:
query = query.filter(BacktestRun.strategy == strategy)
if symbol:
query = query.filter(BacktestRun.symbol == symbol)
total = query.count()
runs = query.order_by(BacktestRun.created_at.desc()).offset(offset).limit(limit).all()
summaries = [self._to_summary(run) for run in runs]
return summaries, total
def get_runs_by_ids(self, db: Session, run_ids: list[str]) -> list[BacktestResult]:
"""
Retrieve multiple runs by their IDs.
Args:
db: Database session
run_ids: List of run UUIDs
Returns:
List of BacktestResults (preserves order)
"""
runs = db.query(BacktestRun).filter(BacktestRun.run_id.in_(run_ids)).all()
# Create lookup and preserve order
run_map = {run.run_id: run for run in runs}
results = []
for run_id in run_ids:
if run_id in run_map:
results.append(self._to_result(run_map[run_id]))
return results
def delete_run(self, db: Session, run_id: str) -> bool:
"""
Delete a backtest run.
Args:
db: Database session
run_id: UUID of the run to delete
Returns:
True if deleted, False if not found
"""
run = db.query(BacktestRun).filter(BacktestRun.run_id == run_id).first()
if not run:
return False
db.delete(run)
db.commit()
return True
def _to_result(self, run: BacktestRun) -> BacktestResult:
"""Convert database record to BacktestResult schema."""
equity_data = run.get_equity_curve()
trades_data = run.get_trades()
return BacktestResult(
run_id=run.run_id,
strategy=run.strategy,
symbol=run.symbol,
market_type=run.market_type,
timeframe=run.timeframe,
start_date=run.start_date or "",
end_date=run.end_date or "",
leverage=run.leverage,
params=run.params or {},
metrics=BacktestMetrics(
total_return=run.total_return,
benchmark_return=run.benchmark_return or 0.0,
alpha=run.alpha or 0.0,
sharpe_ratio=run.sharpe_ratio,
max_drawdown=run.max_drawdown,
win_rate=run.win_rate,
total_trades=run.total_trades,
profit_factor=run.profit_factor,
total_fees=run.total_fees,
total_funding=run.total_funding,
liquidation_count=run.liquidation_count,
liquidation_loss=run.liquidation_loss,
adjusted_return=run.adjusted_return,
),
equity_curve=[EquityPoint(**p) for p in equity_data],
trades=[TradeRecord(**t) for t in trades_data],
created_at=run.created_at.isoformat() if run.created_at else "",
)
def _to_summary(self, run: BacktestRun) -> BacktestSummary:
"""Convert database record to BacktestSummary schema."""
return BacktestSummary(
run_id=run.run_id,
strategy=run.strategy,
symbol=run.symbol,
market_type=run.market_type,
timeframe=run.timeframe,
total_return=run.total_return,
sharpe_ratio=run.sharpe_ratio,
max_drawdown=run.max_drawdown,
total_trades=run.total_trades,
created_at=run.created_at.isoformat() if run.created_at else "",
params=run.params or {},
)
# Singleton instance
_storage: StorageService | None = None
def get_storage() -> StorageService:
"""Get or create the storage service instance."""
global _storage
if _storage is None:
_storage = StorageService()
return _storage