Files
trumpsignal-backend/app/api/performance.py
T
k 0d88e3e43a fix(performance): exclude paper trades by default (real-money tile must be real)
/performance summed paper + live P&L into one number. DashboardClient's
30d Performance tile consumes this directly and presents it as real
performance — so a user who tried paper mode then went live saw simulated
gains inflating the headline number on the homepage.

Now: exclude hl_order_id=='paper' by default. Added optional ?include_paper
query param for callers that explicitly want simulated stats. Analytics page
already filters client-side by is_paper (separate fix), so both surfaces now
agree: real-money numbers never include simulated fills.

72 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 03:04:38 +08:00

108 lines
3.8 KiB
Python

import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade
from app.schemas import BotPerformance
from app.services.signed_request import verify_signed_request_any
router = APIRouter()
logger = logging.getLogger(__name__)
PERIOD_DAYS = 30
ACTION_VIEW_PERFORMANCE = "view_performance"
ACTION_VIEW_USER = "view_user"
@router.get("/performance", response_model=BotPerformance)
async def get_performance(
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
include_paper: bool = Query(
False,
description="Include paper (simulated) trades. Default false — this "
"endpoint reports REAL-money performance, so paper fills "
"(hl_order_id='paper') are excluded unless explicitly asked.",
),
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
verify_signed_request_any(
actions=[ACTION_VIEW_PERFORMANCE, ACTION_VIEW_USER],
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=PERIOD_DAYS)
# Period basis is closed_at (realized-PnL-in-window), NOT opened_at. This
# matches how the frontend Analytics page filters every time window, so the
# 30d numbers shown there (and on the dashboard tile) use one consistent
# basis. Ordering by closed_at also makes the drawdown equity curve reflect
# realization order. A trade opened before the window but closed inside it
# correctly counts; one opened inside but still open does not (closed_at IS
# NOT NULL already excludes it).
#
# MONEY-SAFETY: by default exclude paper trades (hl_order_id == "paper").
# Mixing simulated and real P&L into one "performance" number is misleading
# — the dashboard tile that consumes this shows it as real performance.
stmt = (
select(BotTrade)
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None))
.where(BotTrade.closed_at >= since)
)
if not include_paper:
stmt = stmt.where(BotTrade.hl_order_id != "paper")
stmt = stmt.order_by(BotTrade.closed_at.asc())
result = await db.execute(stmt)
trades = result.scalars().all()
total_trades = len(trades)
if total_trades == 0:
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=0,
win_rate=0.0,
net_pnl_usd=0.0,
avg_hold_seconds=0.0,
max_drawdown_pct=0.0,
)
winning = sum(1 for t in trades if (t.pnl_usd or 0) > 0)
win_rate = winning / total_trades
pnl_values = [(t.pnl_usd or 0.0) for t in trades]
net_pnl = sum(pnl_values)
hold_values = [(t.hold_seconds or 0) for t in trades]
avg_hold = sum(hold_values) / len(hold_values)
# Max drawdown: running peak → trough of cumulative PnL
cumulative = 0.0
peak = 0.0
max_drawdown = 0.0
for pnl in pnl_values:
cumulative += pnl
if cumulative > peak:
peak = cumulative
drawdown = (peak - cumulative) / peak * 100 if peak > 0 else 0.0
if drawdown > max_drawdown:
max_drawdown = drawdown
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=total_trades,
win_rate=round(win_rate, 4),
net_pnl_usd=round(net_pnl, 2),
avg_hold_seconds=round(avg_hold, 1),
max_drawdown_pct=round(max_drawdown, 4),
)