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:
3
api/routers/__init__.py
Normal file
3
api/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API routers for backtest, strategies, and data endpoints.
|
||||
"""
|
||||
193
api/routers/backtest.py
Normal file
193
api/routers/backtest.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Backtest execution and history endpoints.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.database import get_db
|
||||
from api.models.schemas import (
|
||||
BacktestListResponse,
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
CompareRequest,
|
||||
CompareResult,
|
||||
)
|
||||
from api.services.runner import get_runner
|
||||
from api.services.storage import get_storage
|
||||
from engine.logging_config import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResult)
|
||||
async def run_backtest(
|
||||
request: BacktestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Execute a backtest with the specified configuration.
|
||||
|
||||
Runs the strategy on historical data and returns metrics,
|
||||
equity curve, and trade records. Results are automatically saved.
|
||||
"""
|
||||
runner = get_runner()
|
||||
storage = get_storage()
|
||||
|
||||
try:
|
||||
# Execute backtest
|
||||
result = runner.run(request)
|
||||
|
||||
# Save to database
|
||||
storage.save_run(db, result)
|
||||
|
||||
logger.info(
|
||||
"Backtest completed and saved: %s (return=%.2f%%, sharpe=%.2f)",
|
||||
result.run_id,
|
||||
result.metrics.total_return,
|
||||
result.metrics.sharpe_ratio,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid strategy: {e}")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=f"Data not found: {e}")
|
||||
except Exception as e:
|
||||
logger.error("Backtest failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/backtests", response_model=BacktestListResponse)
|
||||
async def list_backtests(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
strategy: str | None = None,
|
||||
symbol: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List saved backtest runs with optional filtering.
|
||||
|
||||
Returns summaries for quick display in the history sidebar.
|
||||
"""
|
||||
storage = get_storage()
|
||||
|
||||
runs, total = storage.list_runs(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
strategy=strategy,
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
return BacktestListResponse(runs=runs, total=total)
|
||||
|
||||
|
||||
@router.get("/backtest/{run_id}", response_model=BacktestResult)
|
||||
async def get_backtest(
|
||||
run_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Retrieve a specific backtest run by ID.
|
||||
|
||||
Returns full result including equity curve and trades.
|
||||
"""
|
||||
storage = get_storage()
|
||||
|
||||
result = storage.get_run(db, run_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/backtest/{run_id}")
|
||||
async def delete_backtest(
|
||||
run_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete a backtest run.
|
||||
"""
|
||||
storage = get_storage()
|
||||
|
||||
deleted = storage.delete_run(db, run_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")
|
||||
|
||||
return {"status": "deleted", "run_id": run_id}
|
||||
|
||||
|
||||
@router.post("/compare", response_model=CompareResult)
|
||||
async def compare_runs(
|
||||
request: CompareRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Compare multiple backtest runs (2-5 runs).
|
||||
|
||||
Returns full results for each run plus parameter differences.
|
||||
"""
|
||||
storage = get_storage()
|
||||
|
||||
runs = storage.get_runs_by_ids(db, request.run_ids)
|
||||
|
||||
if len(runs) != len(request.run_ids):
|
||||
found_ids = {r.run_id for r in runs}
|
||||
missing = [rid for rid in request.run_ids if rid not in found_ids]
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Runs not found: {missing}"
|
||||
)
|
||||
|
||||
# Calculate parameter differences
|
||||
param_diff = _calculate_param_diff(runs)
|
||||
|
||||
return CompareResult(runs=runs, param_diff=param_diff)
|
||||
|
||||
|
||||
def _calculate_param_diff(runs: list[BacktestResult]) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Find parameters that differ between runs.
|
||||
|
||||
Returns dict mapping param name to list of values (one per run).
|
||||
"""
|
||||
if not runs:
|
||||
return {}
|
||||
|
||||
# Collect all param keys
|
||||
all_keys: set[str] = set()
|
||||
for run in runs:
|
||||
all_keys.update(run.params.keys())
|
||||
|
||||
# Also include strategy and key config
|
||||
all_keys.update(['strategy', 'symbol', 'leverage', 'timeframe'])
|
||||
|
||||
diff: dict[str, list[Any]] = {}
|
||||
|
||||
for key in sorted(all_keys):
|
||||
values = []
|
||||
for run in runs:
|
||||
if key == 'strategy':
|
||||
values.append(run.strategy)
|
||||
elif key == 'symbol':
|
||||
values.append(run.symbol)
|
||||
elif key == 'leverage':
|
||||
values.append(run.leverage)
|
||||
elif key == 'timeframe':
|
||||
values.append(run.timeframe)
|
||||
else:
|
||||
values.append(run.params.get(key))
|
||||
|
||||
# Only include if values differ
|
||||
if len(set(str(v) for v in values)) > 1:
|
||||
diff[key] = values
|
||||
|
||||
return diff
|
||||
97
api/routers/data.py
Normal file
97
api/routers/data.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Data status and symbol information endpoints.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.models.schemas import DataStatusResponse, SymbolInfo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Base path for CCXT data
|
||||
DATA_BASE = Path(__file__).parent.parent.parent / "data" / "ccxt"
|
||||
|
||||
|
||||
def _scan_available_data() -> list[SymbolInfo]:
|
||||
"""
|
||||
Scan the data directory for available symbols and timeframes.
|
||||
|
||||
Returns list of SymbolInfo with date ranges and row counts.
|
||||
"""
|
||||
symbols = []
|
||||
|
||||
if not DATA_BASE.exists():
|
||||
return symbols
|
||||
|
||||
# Structure: data/ccxt/{exchange}/{market_type}/{symbol}/{timeframe}.csv
|
||||
for exchange_dir in DATA_BASE.iterdir():
|
||||
if not exchange_dir.is_dir():
|
||||
continue
|
||||
exchange = exchange_dir.name
|
||||
|
||||
for market_dir in exchange_dir.iterdir():
|
||||
if not market_dir.is_dir():
|
||||
continue
|
||||
market_type = market_dir.name
|
||||
|
||||
for symbol_dir in market_dir.iterdir():
|
||||
if not symbol_dir.is_dir():
|
||||
continue
|
||||
symbol = symbol_dir.name
|
||||
|
||||
# Find all timeframes
|
||||
timeframes = []
|
||||
start_date = None
|
||||
end_date = None
|
||||
row_count = 0
|
||||
|
||||
for csv_file in symbol_dir.glob("*.csv"):
|
||||
tf = csv_file.stem
|
||||
timeframes.append(tf)
|
||||
|
||||
# Read first and last rows for date range
|
||||
try:
|
||||
df = pd.read_csv(csv_file, parse_dates=['timestamp'])
|
||||
if not df.empty:
|
||||
row_count = len(df)
|
||||
start_date = df['timestamp'].min().strftime("%Y-%m-%d")
|
||||
end_date = df['timestamp'].max().strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if timeframes:
|
||||
symbols.append(SymbolInfo(
|
||||
symbol=symbol,
|
||||
exchange=exchange,
|
||||
market_type=market_type,
|
||||
timeframes=sorted(timeframes),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
row_count=row_count,
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
@router.get("/symbols", response_model=DataStatusResponse)
|
||||
async def get_symbols():
|
||||
"""
|
||||
Get list of available symbols with their data ranges.
|
||||
|
||||
Scans the local data directory for downloaded OHLCV data.
|
||||
"""
|
||||
symbols = _scan_available_data()
|
||||
return DataStatusResponse(symbols=symbols)
|
||||
|
||||
|
||||
@router.get("/data/status", response_model=DataStatusResponse)
|
||||
async def get_data_status():
|
||||
"""
|
||||
Get detailed data inventory status.
|
||||
|
||||
Alias for /symbols with additional metadata.
|
||||
"""
|
||||
symbols = _scan_available_data()
|
||||
return DataStatusResponse(symbols=symbols)
|
||||
67
api/routers/strategies.py
Normal file
67
api/routers/strategies.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Strategy information endpoints.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.models.schemas import StrategiesResponse, StrategyInfo
|
||||
from strategies.factory import get_registry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_param_value(value: Any) -> Any:
|
||||
"""Convert numpy arrays and other types to JSON-serializable format."""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.tolist()
|
||||
if isinstance(value, (np.integer, np.floating)):
|
||||
return value.item()
|
||||
return value
|
||||
|
||||
|
||||
def _get_display_name(name: str) -> str:
|
||||
"""Convert strategy key to display name."""
|
||||
display_names = {
|
||||
"rsi": "RSI Strategy",
|
||||
"macross": "MA Crossover",
|
||||
"meta_st": "Meta Supertrend",
|
||||
"regime": "Regime Reversion (ML)",
|
||||
}
|
||||
return display_names.get(name, name.replace("_", " ").title())
|
||||
|
||||
|
||||
@router.get("/strategies", response_model=StrategiesResponse)
|
||||
async def get_strategies():
|
||||
"""
|
||||
Get list of available strategies with their parameters.
|
||||
|
||||
Returns strategy names, default parameters, and grid search ranges.
|
||||
"""
|
||||
registry = get_registry()
|
||||
strategies = []
|
||||
|
||||
for name, config in registry.items():
|
||||
strategy_instance = config.strategy_class()
|
||||
|
||||
# Serialize parameters (convert numpy arrays to lists)
|
||||
default_params = {
|
||||
k: _serialize_param_value(v)
|
||||
for k, v in config.default_params.items()
|
||||
}
|
||||
grid_params = {
|
||||
k: _serialize_param_value(v)
|
||||
for k, v in config.grid_params.items()
|
||||
}
|
||||
|
||||
strategies.append(StrategyInfo(
|
||||
name=name,
|
||||
display_name=_get_display_name(name),
|
||||
market_type=strategy_instance.default_market_type.value,
|
||||
default_leverage=strategy_instance.default_leverage,
|
||||
default_params=default_params,
|
||||
grid_params=grid_params,
|
||||
))
|
||||
|
||||
return StrategiesResponse(strategies=strategies)
|
||||
Reference in New Issue
Block a user