Files
trumpsignal-backend/app/api/posts.py
T
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
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>
2026-06-09 22:55:16 +08:00

316 lines
12 KiB
Python

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)