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 select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Post, iso_utc from app.schemas import PriceImpact, TrumpPost router = APIRouter() logger = logging.getLogger(__name__) 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("/signals/accuracy") async def signal_accuracy(db: AsyncSession = Depends(get_db)): """Aggregate accuracy of directional signals (buy/sell/short) against realised price moves.""" result = await db.execute( select(Post).where(Post.signal.in_(["buy", "sell", "short"])) ) 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)