54884f3e24
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>
87 lines
3.5 KiB
Python
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 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]
|