Files

123 lines
4.5 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 (
SignedReadCreds, signed_read_creds, 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)"),
creds: SignedReadCreds = Depends(signed_read_creds),
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=creds.ts,
signature=creds.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,
)
# Only include trades with a known PnL in financial statistics.
# Trades with pnl_usd=NULL were externally closed or unsettled — treating
# them as 0 silently inflates trade count and distorts win rate / net PnL.
settled = [t for t in trades if t.pnl_usd is not None]
if not settled:
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=total_trades,
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 settled if t.pnl_usd > 0) # type: ignore[operator]
win_rate = winning / len(settled)
pnl_values = [t.pnl_usd for t in settled] # type: ignore[misc]
net_pnl = sum(pnl_values)
# For hold time use all trades (we always have opened_at + closed_at when closed)
hold_values = [t.hold_seconds for t in trades if t.hold_seconds is not None]
avg_hold = sum(hold_values) / len(hold_values) if hold_values else 0.0
# 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),
)