Files
trumpsignal-backend/app/api/kol.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

438 lines
16 KiB
Python

"""KOL module — public read API.
Endpoints:
GET /api/kol/posts list KOL posts (newest first), with summary +
tickers but WITHOUT raw_text to keep payload small
GET /api/kol/posts/{id} full detail incl. raw_text for the modal/page view
"""
import json
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import KolDivergence, KolHoldingChange, KolHoldingSnapshot, KolPost, KolWallet, iso_utc
def _verify_admin_key(x_ingest_key: Optional[str]) -> None:
"""Shared-secret auth for write endpoints (wallet add, scan trigger).
Same key as the signal ingest endpoint. Fail-closed if INGEST_API_KEY unset."""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "write endpoint disabled (INGEST_API_KEY not configured)")
if not x_ingest_key:
raise HTTPException(401, "missing X-Ingest-Key header")
if x_ingest_key != expected:
raise HTTPException(401, "invalid X-Ingest-Key")
router = APIRouter()
logger = logging.getLogger(__name__)
def _parse_tickers(raw: Optional[str]) -> List[dict]:
if not raw:
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def _summary_dto(post: KolPost) -> dict:
"""List-view shape: no raw_text, no content_hash."""
return {
"id": post.id,
"kol_handle": post.kol_handle,
"source": post.source,
"url": post.url,
"title": post.title,
"published_at": iso_utc(post.published_at),
"summary": post.summary,
"tickers": _parse_tickers(post.tickers_json),
"analyzed_at": iso_utc(post.analyzed_at),
"analysis_model": post.analysis_model,
# Extended x_analysis fields (migration 027)
"tier": post.tier,
"post_type": post.post_type,
"talks_vs_trades_flag": post.talks_vs_trades_flag or False,
"sentiment": post.sentiment,
}
def _detail_dto(post: KolPost) -> dict:
base = _summary_dto(post)
base["raw_text"] = post.raw_text
return base
@router.get("/kol/posts")
async def list_kol_posts(
handle: Optional[str] = Query(default=None, description="filter by kol_handle"),
source: Optional[str] = Query(default=None, description="substack | blog | podcast"),
signals_only: bool = Query(default=False,
description="exclude noise posts (tier='noise')"),
ticker: Optional[str] = Query(default=None, description="filter by ticker symbol e.g. BTC"),
days: Optional[int] = Query(default=None, ge=1, le=365, description="restrict to last N days"),
limit: int = Query(default=50, ge=1, le=200),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
base = select(KolPost)
if handle:
base = base.where(KolPost.kol_handle == handle)
if source:
base = base.where(KolPost.source == source)
if signals_only:
base = base.where(
(KolPost.tier.is_(None)) | (KolPost.tier != "noise")
)
if ticker:
# tickers_json stores [{"ticker":"BTC",...}] — match the key-value pair
base = base.where(KolPost.tickers_json.like(f'%"ticker": "{ticker.upper()}"%'))
if days:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
base = base.where(KolPost.published_at >= cutoff)
total: int = (await db.execute(
select(func.count()).select_from(base.subquery())
)).scalar_one()
rows = (await db.execute(
base.order_by(KolPost.published_at.desc())
.offset((page - 1) * limit)
.limit(limit)
)).scalars().all()
return {
"items": [_summary_dto(p) for p in rows],
"page": page,
"limit": limit,
"total": total,
}
@router.get("/kol/posts/{post_id}")
async def get_kol_post(post_id: int, db: AsyncSession = Depends(get_db)) -> dict:
row = (await db.execute(select(KolPost).where(KolPost.id == post_id))).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="KOL post not found")
return _detail_dto(row)
# ── Action ordering for picking the "dominant" call per ticker ─────────
# When the same ticker appears across multiple posts with different actions,
# the strongest signal wins. buy/sell (explicit position) outrank
# bullish/bearish (directional view) outrank mention (passing reference).
_ACTION_STRENGTH = {"buy": 3, "sell": 3, "bullish": 2, "bearish": 2, "mention": 1}
_ACTION_SIDE = {"buy": "long", "bullish": "long",
"sell": "short", "bearish": "short", "mention": "neutral"}
@router.get("/kol/digest")
async def kol_digest(
days: int = Query(default=7, ge=1, le=90,
description="rolling window in days"),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Aggregate ticker calls across recent KOL posts.
For each ticker mentioned in posts within the window, returns the
dominant action, the highest conviction, and the list of (KOL, post,
action, conviction) calls. Sorted by post_count desc, then conviction.
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
rows = (await db.execute(
select(KolPost)
.where(KolPost.published_at >= since)
.where(KolPost.tickers_json.is_not(None))
.order_by(KolPost.published_at.desc())
)).scalars().all()
# ticker -> list of call dicts
bucket: dict[str, list[dict]] = defaultdict(list)
for post in rows:
try:
tickers = json.loads(post.tickers_json or "[]")
except json.JSONDecodeError:
continue
for t in tickers:
sym = (t.get("ticker") or "").upper()
action = (t.get("action") or "mention").lower()
if not sym or action == "mention":
# Don't surface 'mention' on the digest — too noisy. Users
# can still see it on the post detail.
continue
bucket[sym].append({
"post_id": post.id,
"kol_handle": post.kol_handle,
"post_title": post.title,
"published_at": iso_utc(post.published_at),
"action": action,
"conviction": float(t.get("conviction") or 0.0),
"quote": (t.get("quote") or "")[:240],
})
tickers_out: list[dict] = []
for sym, calls in bucket.items():
# Dominant action: pick the action with the highest sum of conviction,
# broken by strength tier then count. Ensures one strong "buy" beats
# three weak "bullish" mentions.
action_weights: dict[str, float] = defaultdict(float)
for c in calls:
action_weights[c["action"]] += c["conviction"]
dominant = max(
action_weights.items(),
key=lambda kv: (_ACTION_STRENGTH.get(kv[0], 0), kv[1]),
)[0]
max_conv = max(c["conviction"] for c in calls)
unique_kols = sorted({c["kol_handle"] for c in calls})
tickers_out.append({
"ticker": sym,
"dominant_action": dominant,
"side": _ACTION_SIDE.get(dominant, "neutral"),
"max_conviction": round(max_conv, 2),
"post_count": len(calls),
"kol_count": len(unique_kols),
"kols": unique_kols,
"calls": sorted(calls, key=lambda c: c["conviction"], reverse=True),
})
tickers_out.sort(
key=lambda t: (t["kol_count"], t["post_count"], t["max_conviction"]),
reverse=True,
)
return {
"window_days": days,
"since": iso_utc(since),
"post_count": len(rows),
"ticker_count": len(tickers_out),
"tickers": tickers_out,
}
# ── A-tier: on-chain wallets + holdings ──────────────────────────────────────
def _wallet_dto(w: KolWallet) -> dict:
return {
"id": w.id,
"handle": w.handle,
"chain": w.chain,
"address": w.address,
"label": w.label,
"source_url": w.source_url,
"active": w.active,
"added_at": iso_utc(w.added_at),
}
def _snapshot_dto(s: KolHoldingSnapshot) -> dict:
return {
"id": s.id,
"wallet_id": s.wallet_id,
"snapshot_date": s.snapshot_date,
"holdings": json.loads(s.holdings_json or "[]"),
"total_usd": s.total_usd,
"source": s.source,
"created_at": iso_utc(s.created_at),
}
def _change_dto(c: KolHoldingChange, handle: str) -> dict:
return {
"id": c.id,
"wallet_id": c.wallet_id,
"handle": handle,
"detected_at": iso_utc(c.detected_at),
"ticker": c.ticker,
"change_type": c.change_type,
"usd_before": c.usd_before,
"usd_after": c.usd_after,
"pct_change": c.pct_change,
}
@router.get("/kol/wallets")
async def list_kol_wallets(
handle: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
stmt = select(KolWallet).where(KolWallet.active == True)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolWallet.handle)
rows = (await db.execute(stmt)).scalars().all()
return {"wallets": [_wallet_dto(w) for w in rows]}
@router.post("/kol/wallets")
async def add_kol_wallet(
body: dict,
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Add a new KOL wallet address. Body: {handle, chain, address, label?, source_url?}.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
required = {"handle", "chain", "address"}
if not required.issubset(body):
raise HTTPException(status_code=422, detail=f"Required fields: {required}")
# Check duplicate
existing = (await db.execute(
select(KolWallet).where(
KolWallet.chain == body["chain"],
KolWallet.address == body["address"].lower(),
)
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Wallet already tracked")
wallet = KolWallet(
handle=body["handle"],
chain=body["chain"],
address=body["address"].lower(),
label=body.get("label"),
source_url=body.get("source_url"),
)
db.add(wallet)
await db.commit()
await db.refresh(wallet)
return _wallet_dto(wallet)
@router.get("/kol/wallets/{wallet_id}/snapshots")
async def get_wallet_snapshots(
wallet_id: int,
limit: int = Query(default=30, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
wallet = (await db.execute(
select(KolWallet).where(KolWallet.id == wallet_id)
)).scalar_one_or_none()
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
snaps = (await db.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet_id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(limit)
)).scalars().all()
return {
"wallet": _wallet_dto(wallet),
"snapshots": [_snapshot_dto(s) for s in snaps],
}
@router.get("/kol/changes")
async def list_holding_changes(
handle: Optional[str] = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Recent on-chain position changes across all tracked KOL wallets."""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
stmt = (
select(KolHoldingChange, KolWallet.handle)
.join(KolWallet, KolHoldingChange.wallet_id == KolWallet.id)
.where(KolHoldingChange.detected_at >= since)
)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolHoldingChange.detected_at.desc()).limit(200)
rows = (await db.execute(stmt)).all()
changes = [_change_dto(c, h) for c, h in rows]
return {
"window_days": days,
"since": iso_utc(since),
"count": len(changes),
"changes": changes,
}
# ── Talks-vs-trades divergence ────────────────────────────────────────────────
def _divergence_dto(d: KolDivergence) -> dict:
return {
"id": d.id,
"handle": d.handle,
"ticker": d.ticker,
"signal_type": d.signal_type, # divergence | alignment
"direction": d.direction, # long | short
"post_id": d.post_id,
"post_action": d.post_action,
"post_conviction":d.post_conviction,
"post_at": iso_utc(d.post_at),
"onchain_action": d.onchain_action,
"usd_before": d.usd_before,
"usd_after": d.usd_after,
"onchain_at": iso_utc(d.onchain_at),
"days_apart": d.days_apart,
"created_at": iso_utc(d.created_at),
}
@router.get("/kol/divergence")
async def list_divergence(
handle: Optional[str] = Query(default=None),
ticker: Optional[str] = Query(default=None),
signal_type: Optional[str] = Query(default=None, description="divergence | alignment"),
days: int = Query(default=30, ge=1, le=180),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""List talks-vs-trades cross-signal pairs.
signal_type=divergence → KOL said X but chain did the opposite (high alpha).
signal_type=alignment → KOL's words matched their on-chain action (reinforced signal).
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
# Use post_at (when the KOL post was published) rather than created_at
# (when the divergence row was written to DB). created_at can lag by
# days or weeks when bulk scans are run, causing old event-pairs to
# appear as "recent" divergences long after they occurred.
stmt = select(KolDivergence).where(KolDivergence.post_at >= since)
if handle:
stmt = stmt.where(KolDivergence.handle == handle)
if ticker:
stmt = stmt.where(KolDivergence.ticker == ticker.upper())
if signal_type:
stmt = stmt.where(KolDivergence.signal_type == signal_type)
# Order by post_at (when the KOL actually published), consistent with the
# post_at window filter above. Ordering by created_at (DB write time) put
# a backfilled older post (post_at=05-18, created_at=05-28) ahead of a
# newer one (post_at=05-23), so the "latest divergence" read wrong.
# Tie-break on created_at so same-day posts have a stable order.
stmt = stmt.order_by(KolDivergence.post_at.desc(), KolDivergence.created_at.desc()).limit(200)
rows = (await db.execute(stmt)).scalars().all()
return {
"window_days": days,
"since": iso_utc(since),
"count": len(rows),
"items": [_divergence_dto(r) for r in rows],
}
@router.post("/kol/divergence/scan")
async def trigger_divergence_scan(
lookback_days: int = Query(default=30, ge=1, le=90),
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
) -> dict[str, Any]:
"""Manually trigger the talks-vs-trades scan. Returns newly written pairs.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
from app.services.kol_divergence import run_divergence_scan
results = await run_divergence_scan(lookback_days=lookback_days)
return {"new_pairs": len(results), "items": results}