Files
trumpsignal-backend/app/api/posts.py
T
k d6c802ef26 fix: pre-launch hardening — HYPE price feed, KOL wallet cleanup, Telegram Trump alert, rate limiting, brittle test
Batch of the pre-launch audit campaign (BUG-01…14 plus three new features):

Pricing / TP-SL protection
- Add app/services/hl_price_feed.py: supplemental HL allMids poller for
  HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store +
  tp_sl_monitor.on_price_tick so bot trades on these assets keep full
  stop-loss / take-profit / trailing protection instead of max-hold only.
- Wire feed into main.py lifespan (startup task + graceful shutdown cancel).

Telegram
- Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump
  posts with no directional signal (relevant=True, signal=hold) now alert
  the public channel only (no per-subscriber noise).
- Rate limiter (slowapi) on the API; assorted bot/digest fixes.

KOL on-chain
- seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate
  orphaned wallets (handle not in KOL_FEEDS → can never produce divergence)
  so the scanner stops burning cycles on them.

Tests / misc
- Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses
  realistic ms timestamps so the in-progress-day drop fires, matching the
  fetcher's bar count (was 0.3179 vs 0.3178 off-by-one).
- Refresh stale notify_signal comment in truth_social.py.

Frontend reduce-action type fix lives in the sibling repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:57:19 +08:00

147 lines
5.3 KiB
Python

import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
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),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
offset = (page - 1) * limit
result = await db.execute(
select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit)
)
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)