Files
trumpsignal-backend/scripts/launch_seed.py
T
k 4442e97f28 feat(macro): Macro Vibes — 8-indicator daily snapshot + composite score
New backend pipeline: 8 free public macro signals fetched in parallel,
upserted once per calendar day, served via /api/macro/{snapshot,history}.

  - AHR999 (computed from Binance 200d klines)
  - Altcoin Season Index (CoinGecko top-50 30d)
  - Fear & Greed (alternative.me)
  - BTC dominance, ETH/BTC ratio
  - Stablecoin supply (DeFiLlama)
  - Spot BTC ETF net flow (Farside)
  - BTC perp open interest (Binance fapi)

Each fetcher is independently @_none_on_fail decorated so one outage
can't take down the snapshot; scoring renormalises across whichever
indicators returned a value. Daily cron at 03:00 UTC; on startup a
fire-and-forget bootstrap fills today's row if missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:04:53 +08:00

291 lines
13 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
* 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).
Usage:
# Preview (no DB writes):
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --dry-run
# Execute:
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --yes
# Skip the SEED step (just clean — useful if you'd rather let the
# scheduled poll jobs trigger naturally over the next 24h):
DATABASE_URL='...' venv/bin/python scripts/launch_seed.py --yes --no-seed
NOT safe to re-run after real users are on the platform — the KOL truncation
will erase divergence/alignment context that 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 seed_real_data() -> 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}"))
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}"))
async def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dry-run", action="store_true",
help="show what would be deleted, no DB writes")
p.add_argument("--yes", action="store_true",
help="actually perform the wipe (required without --dry-run)")
p.add_argument("--no-seed", action="store_true",
help="skip the upstream re-fetch step")
args = p.parse_args()
if not args.dry_run and not args.yes:
print(red("Refusing to run without --yes (or use --dry-run to preview)."))
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()
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()))