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), )