Files
trumpsignal-backend/app/api/performance.py
T
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:16 +08:00

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