Files
trumpsignal-backend/app/services/reanalyze.py
T
k 5fb1d52026 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>
2026-05-25 00:52:56 +08:00

180 lines
6.6 KiB
Python

"""
Batch re-analyzer — runs Claude analysis on posts that were backfilled
without AI scoring (ai_confidence == 0).
Designed to run as a one-shot background task. Processes posts oldest-first,
1 per second, so 479 posts ≈ 8 minutes. Progress is logged at every batch.
Usage (via dev endpoint POST /api/dev/reanalyze):
curl -X POST "http://localhost:8000/api/dev/reanalyze?limit=500&dry_run=false"
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
logger = logging.getLogger(__name__)
# Global so the HTTP endpoint can check progress without storing state externally.
_reanalyze_state: dict = {
"running": False,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": None,
"finished_at": None,
}
def get_state() -> dict:
return dict(_reanalyze_state)
async def reanalyze_unscored(
db_session_factory,
limit: int = 500,
dry_run: bool = False,
delay_secs: float = 1.0,
legacy_signals: bool = False,
model: Optional[str] = None,
) -> dict:
"""
Find all posts with ai_confidence == 0, run analyze_post on each,
and write results back to the DB.
Args:
limit: Maximum number of posts to process in this run.
dry_run: If True, analyze but do NOT write to DB (for testing).
delay_secs: Pause between API calls to avoid rate-limiting.
Returns:
Summary dict with processed/updated/errors counts.
"""
global _reanalyze_state
if _reanalyze_state["running"]:
logger.warning("reanalyze already in progress, skipping")
return _reanalyze_state
_reanalyze_state = {
"running": True,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
}
from app.models import Post
from app.services.analysis import analyze_post
try:
# ── Fetch posts needing (re-)analysis ────────────────────────────
# Two cases:
# 1. Never analyzed (ai_confidence == 0)
# 2. Analyzed before target_asset column was added (has buy/short
# signal but target_asset IS NULL). Pass legacy_signals=True
# to target ONLY these, bypassing unscored posts.
from sqlalchemy import or_, and_
if legacy_signals:
condition = and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
else:
# Use analysis_version IS NULL (not ai_confidence==0) so that
# posts already analyzed as "hold" (conf=0, version set) are not
# re-run every time. Only truly unanalyzed posts are targeted.
condition = or_(
Post.analysis_version == None, # noqa: E711
and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
)
async with db_session_factory() as db:
rows = await db.execute(
select(Post.id, Post.text)
.where(condition)
.order_by(Post.published_at.asc())
.limit(limit)
)
posts_to_do = rows.all() # list of (id, text) tuples
_reanalyze_state["total"] = len(posts_to_do)
logger.info("Reanalyze: %d posts queued (limit=%d, dry_run=%s)",
len(posts_to_do), limit, dry_run)
if not posts_to_do:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
return _reanalyze_state
# ── Process each post ─────────────────────────────────────────────
for post_id, text in posts_to_do:
_reanalyze_state["processed"] += 1
try:
analysis = await analyze_post(text, model=model) # caller decides quality vs speed
if not dry_run:
async with db_session_factory() as db:
result = await db.execute(
select(Post).where(Post.id == post_id)
)
post = result.scalar_one_or_none()
if post is None:
continue
post.sentiment = analysis["sentiment"]
post.signal = analysis.get("signal")
post.ai_confidence = analysis["confidence"]
post.ai_reasoning = analysis.get("reasoning")
post.prefilter_reason = analysis.get("prefilter_reason")
post.analysis_version = analysis.get("analysis_version")
post.relevant = analysis["relevant"]
post.price_impact_asset = analysis["asset"] if analysis["relevant"] else None
post.target_asset = analysis.get("target_asset")
post.category = analysis.get("category")
post.expected_move_pct = analysis.get("expected_move_pct")
await db.commit()
_reanalyze_state["updated"] += 1
if _reanalyze_state["processed"] % 20 == 0:
logger.info(
"Reanalyze progress: %d/%d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["total"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
except Exception as exc:
_reanalyze_state["errors"] += 1
logger.error("Reanalyze error on post %d: %s", post_id, exc)
# Rate-limit: wait between API calls
if delay_secs > 0:
await asyncio.sleep(delay_secs)
except Exception as exc:
logger.error("Reanalyze fatal error: %s", exc)
finally:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
logger.info(
"Reanalyze finished: %d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
return _reanalyze_state