Files
trumpsignal-backend/app/api/posts.py
T
k b941223c88 fix: complete v5 routing — API exposure, rescore persistence, leverage cap, key backup doc
Three plumbing fixes + one ops doc that close the gaps from the audit.

scripts/rescore_v5.py
  Was overwriting only signal/conf/reasoning/sentiment/relevant/
  prefilter_reason/analysis_version. Now also persists target_asset,
  category, expected_move_pct — without these the bot can't route
  rescored posts correctly (would silently fall back to BTC).

app/schemas.py + app/api/posts.py
  TrumpPost response model didn't expose target_asset/category/
  expected_move_pct, so the frontend had no way to display "this
  signal will trade SOL". Added the three fields + mapping in
  _post_to_schema(). Pre-v5 posts return null. No frontend changes
  yet — display work is a follow-up.

app/services/hyperliquid.py
  HL caps max leverage per asset (BTC/ETH 50×, SOL 20×, memes 3-5×).
  set_leverage() always tried to push self._leverage — if user set
  30× and bot routed to TRUMP, HL rejected the order and the trade
  silently dropped. Added _get_max_leverage() (queries meta()'s
  maxLeverage field) and _clip_leverage() that caps to HL's max.
  set_leverage now returns the effective leverage so callers can
  use it for notional sizing if needed.

deploy/ENCRYPTION_KEY_BACKUP.md
  Documented mandatory backup procedure for the symmetric key that
  encrypts every user's HL API key. Lost key = all users' bots dead
  with no recovery. Includes rotation procedure + quarterly test
  step + things-not-to-do list.

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

133 lines
4.6 KiB
Python

import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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,
)
@router.get("/posts", response_model=List[TrumpPost])
async def get_posts(
limit: int = Query(default=20, ge=1, le=500),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
):
offset = (page - 1) * limit
result = await db.execute(
select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit)
)
posts = result.scalars().all()
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)