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: # Join against trigger_post to surface the source tag. When a trade was # opened by an ingested signal (VCP scanner, user's module, etc.) the # source reveals WHICH module produced it — critical for "is module X # actually making money?" attribution analysis. trigger_source = None if trade.trigger_post is not None: trigger_source = trade.trigger_post.source # Paper trades are tagged via hl_order_id at open time; that's the only # stable signal we have to distinguish them in aggregate views. is_paper = (trade.hl_order_id == "paper") return BotTradeSchema( id=trade.id, asset=trade.asset, side=trade.side, entry_price=trade.entry_price, exit_price=trade.exit_price or 0.0, pnl_usd=trade.pnl_usd or 0.0, hold_seconds=trade.hold_seconds or 0, trigger_post_id=trade.trigger_post_id or 0, opened_at=iso_utc(trade.opened_at) or "", closed_at=iso_utc(trade.closed_at) or "", 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=100), 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.opened_at.desc()) .offset(offset) .limit(limit) ) trades = result.scalars().all() return [_trade_to_schema(t) for t in trades]