feat: simulate trading fees/slippage in backtest, compute real PnL for real trades
Backtest/walk-forward priced every fill at the exact candle close with zero cost, making reported win rate/profit factor systematically more optimistic than live trading. Added configurable taker-fee + slippage simulation (defaults 0.1%/0.05% per fill) applied to every entry/exit, threaded through walk-forward's grid search and both API endpoints. sync_real_trades() hardcoded pnl=0 for every real trade needing it, silently reporting break-even for real-money trades regardless of actual outcome. Replaced with FIFO lot matching per (user, symbol, exchange), and fixed orders.py to persist the exchange's actual average fill price instead of the (always-None-for-market-orders) requested price, so there's real price data to match against. Also verified (and locked in with regression tests) that Divergence/SMC's pivot-confirmation delay is already causally consistent between live and backtest — no repaint, no look-ahead leak. 187 backend tests pass (+17). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,12 @@ from app.database import get_db
|
||||
from app.models.candle import Candle
|
||||
from app.models.symbol import Symbol
|
||||
from app.models.exchange import Exchange
|
||||
from app.services.backtest_engine import run_backtest as _run_backtest, MIN_CANDLES
|
||||
from app.services.backtest_engine import (
|
||||
run_backtest as _run_backtest,
|
||||
MIN_CANDLES,
|
||||
DEFAULT_TAKER_FEE_PCT,
|
||||
DEFAULT_SLIPPAGE_PCT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/backtest", tags=["backtest"])
|
||||
@@ -76,11 +81,13 @@ async def run_backtest(
|
||||
timeframe: str = Query("30m"),
|
||||
days: int = Query(7),
|
||||
trade_size: float = Query(10.0),
|
||||
fee_pct: float = Query(DEFAULT_TAKER_FEE_PCT, ge=0, description="Round-trip-per-fill taker fee, e.g. 0.001 = 0.1%"),
|
||||
slippage_pct: float = Query(DEFAULT_SLIPPAGE_PCT, ge=0, description="Adverse slippage per fill, e.g. 0.0005 = 0.05%"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run backtest and return JSON results."""
|
||||
result = await _run_backtest(
|
||||
db, symbol, exchange, timeframe, days, Decimal(str(trade_size))
|
||||
db, symbol, exchange, timeframe, days, Decimal(str(trade_size)), fee_pct, slippage_pct,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
@@ -94,7 +101,9 @@ async def run_backtest_post(
|
||||
timeframe: str = Query("30m"),
|
||||
days: int = Query(7),
|
||||
trade_size: float = Query(10.0),
|
||||
fee_pct: float = Query(DEFAULT_TAKER_FEE_PCT, ge=0),
|
||||
slippage_pct: float = Query(DEFAULT_SLIPPAGE_PCT, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Alias for GET /backtest/run — supports POST method."""
|
||||
return await run_backtest(symbol, exchange, timeframe, days, trade_size, db)
|
||||
return await run_backtest(symbol, exchange, timeframe, days, trade_size, fee_pct, slippage_pct, db)
|
||||
|
||||
@@ -96,7 +96,11 @@ async def place_order(
|
||||
current_user.username, exchange_name, req.symbol, req.side, req.amount,
|
||||
)
|
||||
|
||||
# Persist to real_trades
|
||||
# Persist to real_trades. For market orders `req.price` is None (no
|
||||
# limit price was ever set) — the actual execution price only comes
|
||||
# back from the exchange as `order.average`/`order.price`. Without
|
||||
# it, this row would have no price at all and PnL could never be
|
||||
# computed for it later (see sync_real_trades' FIFO PnL matching).
|
||||
real_trade = RealTrade(
|
||||
user_id=current_user.id,
|
||||
exchange=exchange_name,
|
||||
@@ -104,7 +108,7 @@ async def place_order(
|
||||
side=req.side,
|
||||
order_type=req.order_type,
|
||||
amount=req.amount,
|
||||
price=req.price,
|
||||
price=order.average or order.price or req.price,
|
||||
filled_amount=order.filled,
|
||||
status=order.status,
|
||||
order_id=order.order_id,
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User as UserModel
|
||||
from app.services.walk_forward import run_walk_forward
|
||||
from app.services.backtest_engine import DEFAULT_TAKER_FEE_PCT, DEFAULT_SLIPPAGE_PCT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/walk-forward", tags=["walk_forward"])
|
||||
@@ -35,6 +36,8 @@ async def run(
|
||||
train_days: int = Query(270, ge=30, description="Train window size per fold, in days"),
|
||||
test_days: int = Query(90, ge=14, description="Held-out test window size per fold, in days"),
|
||||
trade_size: float = Query(10.0),
|
||||
fee_pct: float = Query(DEFAULT_TAKER_FEE_PCT, ge=0, description="Round-trip-per-fill taker fee, e.g. 0.001 = 0.1%"),
|
||||
slippage_pct: float = Query(DEFAULT_SLIPPAGE_PCT, ge=0, description="Adverse slippage per fill, e.g. 0.0005 = 0.05%"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
):
|
||||
@@ -43,6 +46,7 @@ async def run(
|
||||
db, symbol, exchange, timeframe,
|
||||
total_days=total_days, train_days=train_days, test_days=test_days,
|
||||
trade_size=Decimal(str(trade_size)),
|
||||
fee_pct=fee_pct, slippage_pct=slippage_pct,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
Reference in New Issue
Block a user