import logging from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import Response from app.ratelimit import limiter from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Post, iso_utc from app.schemas import PostFilterCounts, PostListResponse, PriceImpact, SourceCount, TrumpPost router = APIRouter() logger = logging.getLogger(__name__) _ARCHIVE_EXCLUDED_SOURCES = ( "truth", "btc_bottom_reversal", "funding_reversal", "kol_divergence", ) _AI_SCORED_EXPR = ( (func.coalesce(Post.ai_confidence, 0) > 0) | Post.ai_reasoning.is_not(None) ) def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[bool]: if pct is None or signal is None: return None if signal == "buy": return pct > 0 if signal in ("short", "sell"): return pct < 0 return None # hold has no direction def _post_to_schema(post: Post) -> TrumpPost: price_impact: Optional[PriceImpact] = None if post.price_impact_asset and post.price_at_post is not None: # Overlay live rolling peaks for windows that haven't closed yet from app.services.price_impact_monitor import get_live_impact live = get_live_impact(post.id) or {} m5 = live.get("price_impact_m5", post.price_impact_m5) m15 = live.get("price_impact_m15", post.price_impact_m15) m1h = live.get("price_impact_m1h", post.price_impact_m1h) price_impact = PriceImpact( asset=post.price_impact_asset, m5=m5, m15=m15, m1h=m1h, price_at_post=post.price_at_post, correct_m5=_direction_correct(post.signal, m5), correct_m15=_direction_correct(post.signal, m15), correct_m1h=_direction_correct(post.signal, m1h), ) return TrumpPost( id=post.id, text=post.text, source=post.source, published_at=iso_utc(post.published_at), sentiment=post.sentiment, signal=post.signal, ai_confidence=post.ai_confidence, ai_reasoning=post.ai_reasoning, prefilter_reason=post.prefilter_reason, analysis_version=post.analysis_version, relevant=post.relevant, price_impact=price_impact, # v5 routing fields — null for pre-v5 posts target_asset=post.target_asset, category=post.category, expected_move_pct=post.expected_move_pct, invalidation_price=post.invalidation_price, ) @router.get("/posts", response_model=List[TrumpPost]) @limiter.limit("60/minute") async def get_posts( request: Request, limit: int = Query(default=20, ge=1, le=500), page: int = Query(default=1, ge=1), source: Optional[str] = Query( default=None, description="Filter to a single source (e.g. 'btc_bottom_reversal', " "'funding_reversal', 'truth'). Without it, rare-source " "signals can be pushed off the latest-N page by Trump posts.", ), db: AsyncSession = Depends(get_db), response: Response = None, ): offset = (page - 1) * limit stmt = select(Post) if source: stmt = stmt.where(Post.source == source) stmt = stmt.order_by(Post.published_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) posts = result.scalars().all() # Posts are scraped every 5s but rarely change once written — allow CDN/browser # to cache for 30s. stale-while-revalidate=60 means stale content is served # while a fresh fetch happens in the background (no loading flash). if response is not None: response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60" return [_post_to_schema(p) for p in posts] @router.get("/posts-paged", response_model=PostListResponse) @limiter.limit("60/minute") async def get_posts_page( request: Request, limit: int = Query(default=20, ge=1, le=500), page: int = Query(default=1, ge=1), source: Optional[str] = Query( default=None, description="Filter to a single source (e.g. 'truth', 'btc_bottom_reversal').", ), source_in: Optional[str] = Query( default=None, description="Comma-separated allowlist of sources.", ), source_not_in: Optional[str] = Query( default=None, description="Comma-separated denylist of sources.", ), archive_only: bool = Query( default=False, description="When true, return only archived/retired sources (exclude live modules).", ), sentiment: Optional[str] = Query( default=None, pattern="^(bullish|bearish|neutral)$", description="Optional sentiment filter.", ), signal: Optional[str] = Query( default=None, pattern="^(buy|short|actionable)$", description="Optional signal filter. 'actionable' = buy or short.", ), ai_scored_only: bool = Query( default=False, description="When true, exclude off-topic rows that were skipped before AI scoring.", ), db: AsyncSession = Depends(get_db), response: Response = None, ): offset = (page - 1) * limit stmt = select(Post) count_stmt = select(func.count()).select_from(Post) counts_stmt = select( func.count().label("all_count"), func.sum(case((Post.signal.in_(("buy", "short")), 1), else_=0)).label("actionable_count"), func.sum(case((Post.signal == "buy", 1), else_=0)).label("buy_count"), func.sum(case((Post.signal == "short", 1), else_=0)).label("short_count"), func.sum(case((_AI_SCORED_EXPR, 0), else_=1)).label("off_topic_count"), ).select_from(Post) source_counts_stmt = select( Post.source.label("source"), func.count(Post.id).label("count"), func.max(Post.published_at).label("latest"), ).select_from(Post) included_sources = [s.strip() for s in (source_in or "").split(",") if s.strip()] excluded_sources = [s.strip() for s in (source_not_in or "").split(",") if s.strip()] if archive_only: excluded_sources = list(dict.fromkeys([*excluded_sources, *_ARCHIVE_EXCLUDED_SOURCES])) if source: stmt = stmt.where(Post.source == source) count_stmt = count_stmt.where(Post.source == source) counts_stmt = counts_stmt.where(Post.source == source) source_counts_stmt = source_counts_stmt.where(Post.source == source) elif included_sources: stmt = stmt.where(Post.source.in_(included_sources)) count_stmt = count_stmt.where(Post.source.in_(included_sources)) counts_stmt = counts_stmt.where(Post.source.in_(included_sources)) # NOTE: source_counts is the chip/source breakdown the UI renders the # filter bar from. It must reflect every source available in the # current view scope — NOT just the one the user has selected. So # `source_in` (the chip-selection narrowing) is deliberately NOT # applied here; only the exclusion filters (archive_only / # source_not_in) below scope it. Applying it would collapse the chip # bar to the single selected source with no way back to "all". if excluded_sources: stmt = stmt.where(~Post.source.in_(excluded_sources)) count_stmt = count_stmt.where(~Post.source.in_(excluded_sources)) counts_stmt = counts_stmt.where(~Post.source.in_(excluded_sources)) source_counts_stmt = source_counts_stmt.where(~Post.source.in_(excluded_sources)) if sentiment: stmt = stmt.where(Post.sentiment == sentiment) count_stmt = count_stmt.where(Post.sentiment == sentiment) counts_stmt = counts_stmt.where(Post.sentiment == sentiment) source_counts_stmt = source_counts_stmt.where(Post.sentiment == sentiment) if ai_scored_only: stmt = stmt.where(_AI_SCORED_EXPR) count_stmt = count_stmt.where(_AI_SCORED_EXPR) source_counts_stmt = source_counts_stmt.where(_AI_SCORED_EXPR) if signal == "actionable": stmt = stmt.where(Post.signal.in_(("buy", "short"))) count_stmt = count_stmt.where(Post.signal.in_(("buy", "short"))) elif signal: stmt = stmt.where(Post.signal == signal) count_stmt = count_stmt.where(Post.signal == signal) stmt = stmt.order_by(Post.published_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) total_result = await db.execute(count_stmt) counts_result = await db.execute(counts_stmt) source_counts_result = await db.execute( source_counts_stmt.group_by(Post.source).order_by(func.count(Post.id).desc(), Post.source.asc()) ) posts = result.scalars().all() total = int(total_result.scalar_one() or 0) counts_row = counts_result.one() off_topic = int(counts_row.off_topic_count or 0) all_count = int(counts_row.all_count or 0) if response is not None: response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60" return PostListResponse( items=[_post_to_schema(p) for p in posts], total=total, page=page, limit=limit, counts=PostFilterCounts( all=max(0, all_count - off_topic) if ai_scored_only else all_count, actionable=int(counts_row.actionable_count or 0), buy=int(counts_row.buy_count or 0), short=int(counts_row.short_count or 0), off_topic=off_topic, ), source_counts=[ SourceCount( source=row.source, count=int(row.count or 0), latest=iso_utc(row.latest), ) for row in source_counts_result.all() ], ) @router.get("/signals/accuracy") async def signal_accuracy(db: AsyncSession = Depends(get_db)): """Aggregate accuracy of directional signals against realised price moves. Scoped to the CURRENT signal taxonomy the live bot actually trades: * only buy/short (the retired "sell" vocabulary is excluded — the bot emits buy/short now, and 42 legacy truth/sell rows would otherwise pollute the public scoreboard), * only production sources (SUPPORTED_TRADING_SOURCES) — retired/test ingest sources like rsi_reversal, sma_reclaim, breakout, phase1 and `test` must not appear in the public accuracy stats. """ from app.services.signal_categories import SUPPORTED_TRADING_SOURCES result = await db.execute( select(Post).where( Post.signal.in_(["buy", "short"]), func.lower(Post.source).in_(SUPPORTED_TRADING_SOURCES), ) ) posts = result.scalars().all() def bucket(): return {"checked": 0, "correct": 0} stats = {"m5": bucket(), "m15": bucket(), "m1h": bucket()} by_signal: dict[str, dict] = {} for p in posts: sig = p.signal if sig not in by_signal: by_signal[sig] = {"m5": bucket(), "m15": bucket(), "m1h": bucket(), "count": 0} by_signal[sig]["count"] += 1 for win, val in (("m5", p.price_impact_m5), ("m15", p.price_impact_m15), ("m1h", p.price_impact_m1h)): ok = _direction_correct(sig, val) if ok is None: continue stats[win]["checked"] += 1 stats[win]["correct"] += int(ok) by_signal[sig][win]["checked"] += 1 by_signal[sig][win]["correct"] += int(ok) def pct(b): return round(b["correct"] / b["checked"] * 100, 1) if b["checked"] else None return { "overall": {k: {**v, "accuracy_pct": pct(v)} for k, v in stats.items()}, "by_signal": { s: { "count": d["count"], "m5": {**d["m5"], "accuracy_pct": pct(d["m5"])}, "m15": {**d["m15"], "accuracy_pct": pct(d["m15"])}, "m1h": {**d["m1h"], "accuracy_pct": pct(d["m1h"])}, } for s, d in by_signal.items() }, "total_directional_signals": len(posts), } @router.get("/posts/{post_id}", response_model=TrumpPost) async def get_post(post_id: int, db: AsyncSession = Depends(get_db)): result = await db.execute(select(Post).where(Post.id == post_id)) post = result.scalar_one_or_none() if post is None: raise HTTPException(status_code=404, detail="Post not found") return _post_to_schema(post)