chore: one-shot rescore_v5 migration script

After deploying v5 analysis.py, run this once to overwrite v4 scores in
the DB with v5's interpretation. Idempotent — skips rows already at v5.

Has --dry-run mode to preview the change without AI calls or DB writes.
Live mode prompts for confirmation (skipped if stdin is non-tty so it
also works under `docker exec`).

Touches only AI-derived columns (signal, ai_confidence, ai_reasoning,
sentiment, relevant, prefilter_reason, analysis_version). Leaves all
market-derived columns intact (price_at_post, price_impact_*) — those
stay accurate regardless of which prompt version interpreted the post.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-08 15:03:45 +08:00
parent b5e7c82b51
commit 5bfc07c7e1
+191
View File
@@ -0,0 +1,191 @@
"""
One-shot migration: re-score every post in the DB with the v5 prompt.
Use this after deploying the v5 analysis.py to wipe the v4 transition state.
After running, every row in `posts` will have analysis_version='v5-extreme-alpha'.
Usage (run on the server, in the backend container):
# 1. Dry-run — shows what would change, no DB writes, no AI calls
python -m scripts.rescore_v5 --dry-run
# 2. Live run — confirm cost, then re-score everything
python -m scripts.rescore_v5
# 3. Force re-run on already-v5 rows (rare; only if you change the prompt
# again without bumping ANALYSIS_VERSION)
python -m scripts.rescore_v5 --force
What it does:
• Reads every post not already at v5 (or every post if --force).
• Calls analyze_post() — which internally applies the new prefilter and
will skip AI for ~3-5% of posts (RTs, bare URLs, empty bodies).
• Updates these columns in place: signal, ai_confidence, ai_reasoning,
sentiment, relevant, prefilter_reason, analysis_version.
• DOES NOT touch: price_at_post, price_impact_*, opened/closed_at on
related trades. Those stay accurate; only the AI's interpretation
of the post is rewritten.
• Sleeps 0.6s between AI calls to stay under your provider's rate limit.
• Logs progress every 10 posts. Continues on per-post errors.
Final report shows the signal-distribution diff so you can sanity-check
that v5 actually drops the actionable count by ~3-5×.
"""
import argparse
import asyncio
import logging
import sys
import time
from collections import Counter
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Post
from app.services.analysis import analyze_post, ANALYSIS_VERSION
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("rescore")
SLEEP_BETWEEN_CALLS = 0.6 # seconds, ~1.6 req/s — under most provider quotas
async def fetch_targets(force: bool):
async with AsyncSessionLocal() as db:
if force:
stmt = select(Post)
else:
stmt = select(Post).where(Post.analysis_version != ANALYSIS_VERSION)
rows = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "text": r.text, "old_signal": r.signal,
"old_conf": r.ai_confidence, "old_version": r.analysis_version}
for r in rows
]
async def update_post(post_id: int, new: dict) -> bool:
"""Write one re-scored row back. Returns True on success."""
async with AsyncSessionLocal() as db:
post = await db.get(Post, post_id)
if post is None:
return False
post.signal = new["signal"]
post.ai_confidence = new["confidence"]
post.ai_reasoning = new.get("reasoning") or ""
post.sentiment = new["sentiment"]
post.relevant = new["relevant"]
post.prefilter_reason = new.get("prefilter_reason")
post.analysis_version = new["analysis_version"]
# Note: do NOT touch price_impact_asset / price_at_post / m5/m15/m1h.
# Those represent actual market behavior (independent of AI's call)
# and stay accurate. The signals/accuracy endpoint will recompute
# directional correctness against the new signal automatically.
await db.commit()
return True
async def main(dry_run: bool, force: bool):
targets = await fetch_targets(force)
logger.info("Found %d posts to re-score (force=%s)", len(targets), force)
if not targets:
logger.info("Nothing to do. Exit.")
return
# Pre-flight: dry-run shows distribution we'd start from
old_counts = Counter(t["old_signal"] for t in targets)
logger.info("Current signal distribution: %s", dict(old_counts))
if dry_run:
logger.info("DRY-RUN: would call analyze_post() %d times.", len(targets))
logger.info("DRY-RUN: re-run without --dry-run to actually write.")
return
# Confirmation guard for live runs (skipped if stdin is not a tty,
# e.g. when piped from CI or Docker exec without -it)
if sys.stdin.isatty():
msg = (f"\nAbout to re-score {len(targets)} posts via the AI provider.\n"
f"Estimated cost: ~${len(targets) * 0.0054:.2f} on Haiku.\n"
f"Estimated time: ~{len(targets) * SLEEP_BETWEEN_CALLS / 60:.1f} min.\n"
f"Type 'yes' to proceed: ")
if input(msg).strip().lower() != "yes":
logger.info("Cancelled.")
return
started = time.time()
new_signals: list[str] = []
errors = 0
for i, t in enumerate(targets, 1):
try:
new = await analyze_post(t["text"])
ok = await update_post(t["id"], new)
if not ok:
logger.warning("post %d disappeared mid-run", t["id"])
continue
new_signals.append(new["signal"])
if t["old_signal"] != new["signal"]:
logger.info(
" id=%d %s(%s) → %s(%d)",
t["id"], t["old_signal"], t["old_conf"],
new["signal"], new["confidence"],
)
except Exception as exc:
errors += 1
logger.error("Failed on post %d: %s", t["id"], exc)
new_signals.append(t["old_signal"]) # keep old in counter
if i % 10 == 0:
rate = i / (time.time() - started)
eta = (len(targets) - i) / rate
logger.info("Progress: %d/%d (%.1f/s, ETA %.1f min)",
i, len(targets), rate, eta / 60)
await asyncio.sleep(SLEEP_BETWEEN_CALLS)
# ── Final diff ─────────────────────────────────────────────────
new_counts = Counter(new_signals)
elapsed_min = (time.time() - started) / 60
logger.info("=" * 60)
logger.info("RESCORE COMPLETE")
logger.info("=" * 60)
logger.info("Elapsed: %.1f min", elapsed_min)
logger.info("Errors: %d", errors)
logger.info("")
logger.info("Signal distribution diff:")
logger.info(" %-8s %8s %8s %8s", "signal", "before", "after", "delta")
for sig in ("hold", "buy", "short", "sell", None):
b = old_counts.get(sig, 0)
a = new_counts.get(sig, 0)
if b == 0 and a == 0:
continue
delta = a - b
sign = "+" if delta > 0 else ""
logger.info(" %-8s %8d %8d %s%d",
str(sig), b, a, sign, delta)
# Reality check: actionable rate should drop substantially
actionable_before = old_counts.get("buy", 0) + old_counts.get("short", 0) + old_counts.get("sell", 0)
actionable_after = new_counts.get("buy", 0) + new_counts.get("short", 0)
pct_before = actionable_before / len(targets) * 100
pct_after = actionable_after / len(targets) * 100
logger.info("")
logger.info("Actionable rate: %.1f%%%.1f%% (target: 2-4%%)",
pct_before, pct_after)
if actionable_after >= actionable_before * 0.8:
logger.warning("⚠️ Actionable rate barely dropped. Either v5 prompt isn't")
logger.warning(" strict enough, or sample is unusual. Inspect a few posts.")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true",
help="Show what would change without calling AI or writing DB")
ap.add_argument("--force", action="store_true",
help="Re-score even posts already at the current version")
args = ap.parse_args()
asyncio.run(main(dry_run=args.dry_run, force=args.force))