import logging from typing import List from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.orm import joinedload from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import BotTrade, iso_utc from app.schemas import BotTrade as BotTradeSchema from app.services.signed_request import verify_signed_request_any router = APIRouter() logger = logging.getLogger(__name__) ACTION_VIEW_TRADES = "view_trades" ACTION_VIEW_USER = "view_user" def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: trigger_source = None trigger_post_text = None if trade.trigger_post is not None: trigger_source = trade.trigger_post.source trigger_post_text = trade.trigger_post.text elif trade.hl_order_id and str(trade.hl_order_id).startswith("adopted:"): # Adopted (sys2 manage-only) trades have trigger_post_id=NULL by # construction, so trigger_source would otherwise fall through to # "Unknown" in the UI. The hl_order_id prefix is the reliable marker # (see adoption.py / CLAUDE.md) — surface it as a proper attribution. trigger_source = "adopted" is_paper = (trade.hl_order_id == "paper") # Preserve None for nullable fields — do NOT coerce to 0 / "". # The frontend uses null to distinguish "unknown/not yet settled" from # a genuine zero (e.g. a break-even trade, a 0-second hold). # `or 0` was silently masking externally-closed trades and unsettled PnL. return BotTradeSchema( id=trade.id, asset=trade.asset, side=trade.side, entry_price=trade.entry_price, exit_price=trade.exit_price, # None = position still open or extern-closed pnl_usd=trade.pnl_usd, # None = unsettled / extern-closed hold_seconds=trade.hold_seconds, # None = not yet computed trigger_post_id=trade.trigger_post_id, # None = adopted/manual, no trigger post opened_at=iso_utc(trade.opened_at) or "", closed_at=iso_utc(trade.closed_at), # None = still open (shouldn't happen here) trigger_post_text=trigger_post_text, trigger_source=trigger_source, is_paper=is_paper, ) @router.get("/trades", response_model=List[BotTradeSchema]) async def get_trades( wallet: str = Query(..., description="Wallet address (lower-cased internally)"), ts: int = Query(..., description="Signed timestamp (ms)"), sig: str = Query(..., description="EIP-191 signature"), limit: int = Query(default=20, ge=1, le=500), # raised from 100: Analytics needs 500 for full history page: int = Query(default=1, ge=1), db: AsyncSession = Depends(get_db), ): wallet = wallet.lower().strip() verify_signed_request_any( actions=[ACTION_VIEW_TRADES, ACTION_VIEW_USER], wallet=wallet, timestamp_ms=ts, signature=sig, body=None, allow_replay=True, ) offset = (page - 1) * limit # joinedload pulls trigger_post in the same query — avoids N+1 lookups # when serialising 100 trades. result = await db.execute( select(BotTrade) .options(joinedload(BotTrade.trigger_post)) .where(BotTrade.wallet_address == wallet) .where(BotTrade.closed_at.is_not(None)) .order_by(BotTrade.closed_at.desc()) # closed_at = settlement time; opened_at would bury recently-closed old trades .offset(offset) .limit(limit) ) trades = result.scalars().all() return [_trade_to_schema(t) for t in trades]