Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:
KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.
Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.
BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.
WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.
Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.
Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.
New ops scripts —
- scripts/preflight.py: env/DB/Telegram/AI auth verification gate
- scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
- scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder
15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+395
@@ -0,0 +1,395 @@
|
||||
"""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 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,
|
||||
}
|
||||
|
||||
|
||||
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 | twitter"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
page: int = Query(default=1, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(KolPost)
|
||||
if handle:
|
||||
stmt = stmt.where(KolPost.kol_handle == handle)
|
||||
if source:
|
||||
stmt = stmt.where(KolPost.source == source)
|
||||
stmt = stmt.order_by(KolPost.published_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return {"items": [_summary_dto(p) for p in rows], "page": page, "limit": limit}
|
||||
|
||||
|
||||
@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)
|
||||
stmt = select(KolDivergence).where(KolDivergence.created_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)
|
||||
stmt = stmt.order_by(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}
|
||||
Reference in New Issue
Block a user