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:
@@ -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))
|
||||||
Reference in New Issue
Block a user