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:
Le
2026-07-04 14:50:36 +07:00
parent 1bcd15829e
commit 662586c6bc
12 changed files with 733 additions and 90 deletions
+12 -3
View File
@@ -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)
+6 -2
View File
@@ -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,
+4
View File
@@ -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"])