Files
trumpsignal-backend/scripts/launch_seed.py
T
k 6876c0c280 fix: de-risk launch_seed, monitor price feeds, enforce single-process
Three pre-launch audit findings:

launch_seed.py — destructive cleanup is now OPT-IN
  Previously `launch_seed.py --yes` truncated KOL history (divergence /
  holdings / posts older than 30d) by default — an accidental run erased
  unrecoverable data, and abort_if_live_user_state() only guards on
  subscriptions/bindings, not KOL history. Now nothing is deleted unless
  --wipe is passed; the safe path is --seed-only (pure fetch). Bare/--yes
  without --wipe refuses and prints guidance.

/api/health/deep — monitor the price feeds, not just scrapers
  The deep healthcheck only watched the (redundant) Trump scrapers + DB, so a
  dead Binance/HL price feed — which silently stops ALL tp_sl_monitor stop-loss
  / take-profit firing on live trades — left health green. Added per-feed
  liveness (binance.last_tick_at, hl_price_feed.last_tick_at) with a 180s boot
  grace so startup doesn't false-503. Body now includes price_feeds[].

Single-process enforcement (multi-worker safety)
  The backend is single-process by design (in-memory scheduler, replay cache,
  tp_sl table, price_store). systemd unit lacked the --workers 1 + rationale
  that supervisor.conf already had; added it. Added a runtime advisory file
  lock (app.main._acquire_singleton_lock): only the leader starts background
  tasks; extra workers serve HTTP reads only and log CRITICAL. health/deep now
  reports is_leader so the misconfig is visible to monitors.

72 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:29:24 +08:00

376 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Pre-launch data preparation — conservative version.
What it does:
1. DROP obsolete test-only sources (test/phase1/breakout/rsi_reversal/sma_reclaim)
and their orphan bot_trades. These are residue from past scanner experiments
that no longer fire — they only confuse the UI/sitemap.
2. TRUNCATE the KOL window to the last KOL_RETAIN_DAYS days (default 30).
KOL data churns daily and a 30-day window is what the UI surfaces anyway.
3. REFETCH every upstream source so the kept window is fresh:
* Trump CNN archive (idempotent; only inserts genuinely new posts)
* KOL Substack / podcast / blog polls
* KOL on-chain snapshots (HL perps + Etherscan ERC-20 balances)
* KOL divergence detector
* Optional: BTC bottom-reversal + funding-reversal scanners
What it INTENTIONALLY DOES NOT touch:
* posts where source IN ('truth', 'btc_bottom_reversal', 'funding_reversal',
'kol_divergence') — full history kept. Trump posts are too valuable to
truncate (analytics + accuracy backtest need them); the two BTC scanners
fire too rarely (≤4×/cycle) to safely drop any historical fire.
* bot_trades older than KOL_RETAIN_DAYS — real PnL history stays. Only
bot_trades whose trigger_post has been deleted (i.e. the trade fired
off an obsolete-source signal) are pruned.
* subscriptions, telegram_bindings, kol_wallets, candles — untouched.
* The most-recent KOL holdings snapshot per wallet — kept as the diff
baseline for the next on-chain poll (without it, the next snapshot
would diff against nothing and produce zero kol_holding_changes).
DESTRUCTIVE STEPS ARE OPT-IN. By default this script only SEEDS (re-fetches
upstream data); it will NOT delete or truncate anything unless you explicitly
pass --wipe. This prevents an accidental `launch_seed.py --yes` from erasing
KOL history (divergence / holdings / posts) that cannot be recovered.
Usage:
# Pure pre-launch warmup — fetch only, NOTHING deleted (the safe default):
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --seed-only
# Preview what a wipe WOULD delete (no DB writes):
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --wipe --dry-run
# Execute the destructive wipe + reseed (requires BOTH --wipe and --yes):
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --wipe --yes
# Wipe only, skip the reseed (let scheduled polls refill over 24h):
DATABASE_URL='...' venv/bin/python scripts/launch_seed.py --wipe --yes --no-seed
The --wipe path is NOT safe to re-run after real users are on the platform —
the KOL truncation erases divergence/alignment context you can't recover.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import delete, select, func, text
from app.database import AsyncSessionLocal
from app.models import (
Post, KolPost, KolDivergence, KolHoldingChange,
KolHoldingSnapshot, KolWallet, BotTrade,
Subscription, TelegramBinding,
)
# ── Tunables ────────────────────────────────────────────────────────────────
# Test-only sources to wipe from `posts` regardless of age. The first three are
# explicit fixtures; the latter two are old scanner experiments now archived
# under app/services/scanners/_archive/ but their historical fires linger.
OBSOLETE_SOURCES = {"test", "phase1", "breakout", "rsi_reversal", "sma_reclaim"}
# Live production sources — never truncated by this script. Trump's full
# history is needed for /analytics + /signals/accuracy; the two BTC scanners
# fire too rarely (<5 times per market cycle) to safely lose any past fire.
LIVE_SOURCES_PROTECTED = {"truth", "btc_bottom_reversal", "funding_reversal", "kol_divergence"}
# KOL data churns daily and the UI surfaces a max-30-day window. Anything older
# than this on the KOL side is dead weight in the DB.
KOL_RETAIN_DAYS = 30
# ─────────────────────────────────────────────────────────────────────────────
def green(s): return f"\033[32m{s}\033[0m"
def yellow(s): return f"\033[33m{s}\033[0m"
def red(s): return f"\033[31m{s}\033[0m"
def bold(s): return f"\033[1m{s}\033[0m"
async def report_counts(label: str) -> dict:
"""Snapshot row counts across every signal-bearing table."""
counts: dict = {}
async with AsyncSessionLocal() as db:
for tbl, name in [
(Post, "posts"),
(KolPost, "kol_posts"),
(KolDivergence, "kol_divergence"),
(KolHoldingChange, "kol_holding_changes"),
(KolHoldingSnapshot, "kol_holdings_snapshots"),
(KolWallet, "kol_wallets"),
(BotTrade, "bot_trades"),
(Subscription, "subscriptions"),
(TelegramBinding, "telegram_bindings"),
]:
n = (await db.execute(select(func.count(tbl.id)))).scalar() or 0
counts[name] = n
by_src = (await db.execute(
select(Post.source, func.count(Post.id)).group_by(Post.source)
)).all()
counts["_posts_by_source"] = dict(by_src)
print(bold(f"\n── {label} ──"))
for k, v in counts.items():
if k.startswith("_"): continue
print(f" {k:25s} {v}")
print(f" posts breakdown: {counts['_posts_by_source']}")
return counts
async def wipe_phase(dry_run: bool) -> None:
"""Delete only obsolete sources + truncate KOL window. Live sources stay."""
kol_cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=KOL_RETAIN_DAYS)
print(bold("\n── WIPE ──"))
print(f" Obsolete-source posts to drop: {sorted(OBSOLETE_SOURCES)}")
print(f" KOL retention window: last {KOL_RETAIN_DAYS} days "
f"(cutoff = {kol_cutoff.date()})")
print(f" Live sources kept in full: {sorted(LIVE_SOURCES_PROTECTED)}")
# Quote source list once for repeated reuse — SQLite needs IN (...) with
# parentheses even for a single value.
obsolete_in = "(" + ",".join(f"'{s}'" for s in OBSOLETE_SOURCES) + ")"
async with AsyncSessionLocal() as db:
previews = [
("kol_divergence",
f"divergences older than {KOL_RETAIN_DAYS}d",
f"SELECT COUNT(*) FROM kol_divergence WHERE post_at < '{kol_cutoff.isoformat()}'"),
("kol_holding_changes",
f"changes older than {KOL_RETAIN_DAYS}d",
f"SELECT COUNT(*) FROM kol_holding_changes WHERE detected_at < '{kol_cutoff.isoformat()}'"),
("kol_holdings_snapshots",
f"snapshots older than {KOL_RETAIN_DAYS}d (keeping latest-per-wallet as diff baseline)",
f"""SELECT COUNT(*) FROM kol_holdings_snapshots
WHERE snapshot_date < '{kol_cutoff.date()}'
AND id NOT IN (
SELECT MAX(id) FROM kol_holdings_snapshots GROUP BY wallet_id
)"""),
("kol_posts",
f"KOL posts older than {KOL_RETAIN_DAYS}d",
f"SELECT COUNT(*) FROM kol_posts WHERE published_at < '{kol_cutoff.isoformat()}'"),
("posts",
"obsolete-source posts (test/phase1/breakout/rsi/sma)",
f"SELECT COUNT(*) FROM posts WHERE source IN {obsolete_in}"),
("bot_trades",
"trades whose trigger_post is being deleted (orphan cleanup)",
f"""SELECT COUNT(*) FROM bot_trades WHERE trigger_post_id IN (
SELECT id FROM posts WHERE source IN {obsolete_in}
)"""),
]
for table, desc, sql in previews:
n = (await db.execute(text(sql))).scalar() or 0
tag = yellow(f" would delete {n:5d}") if dry_run else green(f" delete {n:5d}")
print(f"{tag} from {table:25s} ({desc})")
if dry_run:
print(yellow("\n [DRY RUN] no DB changes made."))
return
# Execute in child→parent order to avoid FK violations on Postgres
# (SQLite usually has FK enforcement off, but PG enforces it strictly).
await db.execute(text(
f"DELETE FROM kol_divergence WHERE post_at < '{kol_cutoff.isoformat()}'"
))
await db.execute(text(
f"DELETE FROM kol_holding_changes WHERE detected_at < '{kol_cutoff.isoformat()}'"
))
await db.execute(text(f"""
DELETE FROM kol_holdings_snapshots
WHERE snapshot_date < '{kol_cutoff.date()}'
AND id NOT IN (
SELECT MAX(id) FROM kol_holdings_snapshots GROUP BY wallet_id
)
"""))
await db.execute(text(
f"DELETE FROM kol_posts WHERE published_at < '{kol_cutoff.isoformat()}'"
))
# bot_trades FIRST (it references posts.id) then posts.
await db.execute(text(f"""
DELETE FROM bot_trades WHERE trigger_post_id IN (
SELECT id FROM posts WHERE source IN {obsolete_in}
)
"""))
await db.execute(text(
f"DELETE FROM posts WHERE source IN {obsolete_in}"
))
await db.commit()
print(green(" ✓ wipe committed"))
async def abort_if_live_user_state() -> bool:
"""Refuse destructive launch seeding if the DB already looks user-live."""
async with AsyncSessionLocal() as db:
active_subs = (await db.execute(
select(func.count(Subscription.id)).where(Subscription.active == True)
)).scalar() or 0
tg_bindings = (await db.execute(
select(func.count(TelegramBinding.id))
)).scalar() or 0
if active_subs or tg_bindings:
print(red(
"Refusing to run launch_seed on a DB that already has live user state "
f"(active_subscriptions={active_subs}, telegram_bindings={tg_bindings})."
))
print(yellow(
"Use --dry-run to inspect only. If you truly intend to clean this DB, "
"do it manually with a one-off migration/backup plan."
))
return True
return False
async def seed_real_data(*, exercise_scanners: bool = False) -> None:
"""Re-fetch every upstream source. All are idempotent on (source, external_id)."""
print(bold("\n── SEED (re-running upstream fetches) ──"))
print("\n [1/5] Trump backfill (CNN archive)...")
from app.scrapers.truth_social import backfill_history
try:
await backfill_history(AsyncSessionLocal, limit=500)
print(green(" ✓ done"))
except Exception as e:
print(red(f" ✗ FAILED: {type(e).__name__}: {e}"))
print("\n [2/5] KOL Substack/podcast/blog polling...")
from app.services.kol_substack import run_substack_poll
try:
results = await run_substack_poll(analyze=True)
total_new = sum(r.get("new", 0) for r in results)
total_err = sum(r.get("errors", 0) for r in results)
print(green(f" ✓ done — {total_new} new posts ({total_err} errors)"))
except Exception as e:
print(red(f" ✗ FAILED: {type(e).__name__}: {e}"))
print("\n [3/5] KOL on-chain snapshot (HL perps + Etherscan)...")
from app.services.kol_onchain import run_onchain_poll
try:
result = await run_onchain_poll()
print(green(f" ✓ done — {result}"))
except Exception as e:
print(red(f" ✗ FAILED: {type(e).__name__}: {e}"))
print("\n [4/5] KOL divergence detection (talks vs trades)...")
from app.services.kol_divergence import run_divergence_scan
try:
# Run against the kept KOL window so we catch every still-relevant pair.
new = await run_divergence_scan(lookback_days=KOL_RETAIN_DAYS)
print(green(f" ✓ done — {len(new)} new divergence/alignment pairs"))
except Exception as e:
print(red(f" ✗ FAILED: {type(e).__name__}: {e}"))
if exercise_scanners:
print("\n [5/5] BTC bottom + funding reversal scanners (exercise the path)...")
from app.services.scanners.btc_bottom_reversal import scan_once as btc_scan
from app.services.scanners.funding_reversal import scan_once as funding_scan
for name, fn in [("btc_bottom", btc_scan), ("funding_reversal", funding_scan)]:
try:
await fn()
print(green(f"{name} scan completed (fire conditional on market state)"))
except Exception as e:
print(red(f"{name} FAILED: {type(e).__name__}: {e}"))
else:
print(yellow(
"\n [5/5] Scanner exercise skipped by default. "
"Use --exercise-scanners only if you explicitly want signal emission side-effects."
))
async def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--wipe", action="store_true",
help="OPT IN to destructive cleanup (drop obsolete-source posts + "
"truncate KOL window). Without this flag nothing is ever deleted.")
p.add_argument("--dry-run", action="store_true",
help="with --wipe: show what would be deleted, no DB writes")
p.add_argument("--seed-only", action="store_true",
help="run upstream fetch/warmup only; skip all wipe/truncation steps")
p.add_argument("--yes", action="store_true",
help="with --wipe: actually perform the wipe (required without --dry-run)")
p.add_argument("--no-seed", action="store_true",
help="skip the upstream re-fetch step")
p.add_argument("--exercise-scanners", action="store_true",
help="also run standalone scanners during seed (may emit signals/alerts)")
args = p.parse_args()
if args.seed_only and args.dry_run:
print(red("Choose either --dry-run or --seed-only, not both."))
return 2
if args.seed_only and args.no_seed:
print(red("--seed-only conflicts with --no-seed."))
return 2
if args.seed_only and args.wipe:
print(red("--seed-only conflicts with --wipe (seed-only never deletes)."))
return 2
# Default to the SAFE path. A bare invocation, or --yes/--dry-run/--no-seed
# WITHOUT --wipe, must never delete data. Steer the user to --seed-only.
if not args.wipe and not args.seed_only:
print(red("Refusing to run a destructive path without --wipe."))
print(yellow(
"This script seeds by default and only deletes when --wipe is given.\n"
" • Pure pre-launch fetch (recommended): --seed-only\n"
" • Preview a destructive wipe: --wipe --dry-run\n"
" • Execute a destructive wipe + reseed: --wipe --yes"
))
return 2
if args.seed_only:
before = await report_counts("BEFORE")
await seed_real_data(exercise_scanners=args.exercise_scanners)
after = await report_counts("AFTER")
print(bold("\n── DELTA ──"))
for k in sorted(before):
if k.startswith("_"): continue
d = after[k] - before[k]
arrow = green(f"+{d}") if d > 0 else (red(str(d)) if d < 0 else " ·")
print(f" {k:25s} {before[k]:6d}{after[k]:6d} ({arrow})")
print(bold(green("\n✓ seed-only warmup complete. Next: start backend and run scripts/launch_smoke.py.")))
return 0
if not args.dry_run and not args.yes:
print(red("Refusing to run without --yes (or use --dry-run to preview)."))
return 2
if not args.dry_run and await abort_if_live_user_state():
return 2
before = await report_counts("BEFORE")
await wipe_phase(dry_run=args.dry_run)
if args.dry_run:
print(yellow("\n[DRY RUN COMPLETE] re-run with --yes to execute."))
return 0
if not args.no_seed:
await seed_real_data(exercise_scanners=args.exercise_scanners)
after = await report_counts("AFTER")
print(bold("\n── DELTA ──"))
for k in sorted(before):
if k.startswith("_"): continue
d = after[k] - before[k]
arrow = green(f"+{d}") if d > 0 else (red(str(d)) if d < 0 else " ·")
print(f" {k:25s} {before[k]:6d}{after[k]:6d} ({arrow})")
print(bold(green("\n✓ reset complete. Next: run scripts/launch_smoke.py after the backend is up.")))
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))