Files
trumpsignal-backend/app/api/trades.py
T

87 lines
3.5 KiB
Python

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 (
SignedReadCreds, signed_read_creds, 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)"),
creds: SignedReadCreds = Depends(signed_read_creds),
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=creds.ts,
signature=creds.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]