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>
This commit is contained in:
Executable
+290
@@ -0,0 +1,290 @@
|
||||
#!/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()))
|
||||
Executable
+301
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post-launch end-to-end smoke test.
|
||||
|
||||
Runs every observable verification we have about whether the system is
|
||||
actually doing its job AFTER you've put the new code live. Safe to run
|
||||
on production at any time (read-only except for one round-trip test signal
|
||||
that's tagged 'smoke_test' and immediately deleted).
|
||||
|
||||
Output: one line per check, ✓/✗/!, exits non-zero if anything ✗.
|
||||
Wire it into a cron + alert webhook to get paged when something rots.
|
||||
|
||||
What it verifies (the four "is X working?" questions you'd ask manually):
|
||||
|
||||
1. Backend alive — /api/health/deep returns ok
|
||||
2. Scrapers up-to-date — both Trump pollers fresh (< 60s)
|
||||
3. Binance feed alive — WS connected + recent price tick in DB
|
||||
4. Public API serves data — every /api/* the dashboard uses returns 200 with non-empty body
|
||||
5. KOL pipeline producing — kol_posts has a row in the last 24h
|
||||
6. Funding snapshot working — /api/funding/snapshot returns ok=true
|
||||
7. Telegram bot reachable — getMe authenticates (skipped if no token)
|
||||
8. AI provider reachable — /models lists at least one model
|
||||
9. Signal ingest round-trip — POST a 'smoke_test' signal, GET it back, DELETE it
|
||||
10. WebSocket broadcasting — connect + receive a tick within 8 s
|
||||
|
||||
Defaults to localhost:8000 — pass --base for production:
|
||||
venv/bin/python scripts/launch_smoke.py --base https://api.trumpalpha.io
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from sqlalchemy import select, func
|
||||
from app.config import settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Post, KolPost
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class Checker:
|
||||
def __init__(self):
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def ok(self, name: str, detail: str = ""):
|
||||
print(green(f" ✓ {name:36s}") + (f" {detail}" if detail else ""))
|
||||
|
||||
def warn(self, name: str, detail: str):
|
||||
print(yellow(f" ! {name:36s} {detail}"))
|
||||
self.warnings.append(f"{name}: {detail}")
|
||||
|
||||
def fail(self, name: str, detail: str):
|
||||
print(red(f" ✗ {name:36s} {detail}"))
|
||||
self.errors.append(f"{name}: {detail}")
|
||||
|
||||
|
||||
async def check_health(c: Checker, base: str):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.get(f"{base}/api/health/deep")
|
||||
if r.status_code != 200:
|
||||
c.fail("health/deep", f"HTTP {r.status_code}")
|
||||
return
|
||||
body = r.json()
|
||||
if body.get("status") != "ok":
|
||||
c.fail("health/deep", f"status={body.get('status')} problems={body.get('problems')}")
|
||||
return
|
||||
c.ok("health/deep", f"db_ok, freshest_age={body.get('freshest_age_sec')}s")
|
||||
except Exception as e:
|
||||
c.fail("health/deep", f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def check_scrapers(c: Checker, base: str):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.get(f"{base}/api/health/deep")
|
||||
body = r.json()
|
||||
for s in body.get("scrapers", []):
|
||||
age = s.get("age_sec")
|
||||
name = f"scraper:{s['name']}"
|
||||
if age is None:
|
||||
c.fail(name, "never polled")
|
||||
elif age > 60:
|
||||
c.warn(name, f"stale ({age}s old)")
|
||||
else:
|
||||
c.ok(name, f"fresh ({age}s)")
|
||||
except Exception as e:
|
||||
c.fail("scrapers", f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def check_public_apis(c: Checker, base: str):
|
||||
endpoints = [
|
||||
("/api/posts?limit=5", lambda b: isinstance(b, list)),
|
||||
("/api/funding/snapshot", lambda b: b.get("ok") is True),
|
||||
("/api/scanners", lambda b: "scanners" in b),
|
||||
("/api/signals/sources", lambda b: "sources" in b and len(b["sources"]) > 0),
|
||||
("/api/kol/posts?limit=5", lambda b: "items" in b),
|
||||
("/api/kol/digest?days=7", lambda b: True),
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=15) as cli:
|
||||
for path, validator in endpoints:
|
||||
name = f"GET {path[:34]}"
|
||||
try:
|
||||
r = await cli.get(f"{base}{path}")
|
||||
if r.status_code != 200:
|
||||
c.fail(name, f"HTTP {r.status_code}: {r.text[:80]}")
|
||||
continue
|
||||
if not validator(r.json()):
|
||||
c.warn(name, "200 but payload shape unexpected")
|
||||
continue
|
||||
c.ok(name)
|
||||
except Exception as e:
|
||||
c.fail(name, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def check_kol_freshness(c: Checker):
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=2)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = (await db.execute(
|
||||
select(func.count(KolPost.id)).where(KolPost.published_at >= cutoff)
|
||||
)).scalar() or 0
|
||||
if n > 0:
|
||||
c.ok("kol_posts (last 48h)", f"{n} rows")
|
||||
else:
|
||||
c.warn("kol_posts (last 48h)", "0 rows — daily poll might not have run yet (cron at 01:15 UTC)")
|
||||
|
||||
|
||||
async def check_telegram(c: Checker):
|
||||
if not settings.telegram_bot_token:
|
||||
c.warn("telegram", "no token — feature disabled")
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.get(
|
||||
f"https://api.telegram.org/bot{settings.telegram_bot_token}/getMe"
|
||||
)
|
||||
if r.status_code != 200 or not r.json().get("ok"):
|
||||
c.fail("telegram getMe", f"HTTP {r.status_code}: {r.text[:80]}")
|
||||
return
|
||||
u = r.json()["result"]["username"]
|
||||
c.ok("telegram getMe", f"@{u}")
|
||||
except Exception as e:
|
||||
c.fail("telegram getMe", f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def check_ai(c: Checker):
|
||||
if settings.anthropic_api_key:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers={"x-api-key": settings.anthropic_api_key,
|
||||
"anthropic-version": "2023-06-01"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
c.ok("anthropic /models", "auth OK")
|
||||
else:
|
||||
c.fail("anthropic /models", f"HTTP {r.status_code}")
|
||||
except Exception as e:
|
||||
c.fail("anthropic", f"{type(e).__name__}: {e}")
|
||||
elif settings.ai_api_key:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.get(
|
||||
f"{settings.ai_base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {settings.ai_api_key}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
c.ok("AI /models", f"auth OK at {settings.ai_base_url}")
|
||||
else:
|
||||
c.fail("AI /models", f"HTTP {r.status_code}")
|
||||
except Exception as e:
|
||||
c.fail("AI provider", f"{type(e).__name__}: {e}")
|
||||
else:
|
||||
c.warn("AI provider", "no key configured — Trump signals will not be scored")
|
||||
|
||||
|
||||
async def check_signal_ingest_roundtrip(c: Checker, base: str):
|
||||
"""End-to-end: POST a smoke_test signal, confirm DB+API see it, then delete."""
|
||||
if not settings.ingest_api_key:
|
||||
c.warn("signal ingest", "INGEST_API_KEY empty — endpoint fail-closed (correct), can't round-trip")
|
||||
return
|
||||
ts_tag = int(time.time())
|
||||
ext_id = f"smoke-{ts_tag}"
|
||||
payload = {
|
||||
"source": "smoke_test",
|
||||
"external_id": ext_id,
|
||||
"text": "Synthetic smoke-test signal from scripts/launch_smoke.py — IGNORE.",
|
||||
"signal": "buy",
|
||||
"target_asset": "BTC",
|
||||
"confidence": 50,
|
||||
"category": "smoke_test",
|
||||
}
|
||||
posted_id = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
r = await cli.post(
|
||||
f"{base}/api/signals/ingest",
|
||||
json=payload,
|
||||
headers={"X-Ingest-Key": settings.ingest_api_key},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
c.fail("ingest POST", f"HTTP {r.status_code}: {r.text[:120]}")
|
||||
return
|
||||
result = r.json()
|
||||
posted_id = result.get("post_id")
|
||||
if not posted_id:
|
||||
c.fail("ingest POST", f"no post_id in response: {result}")
|
||||
return
|
||||
# Read back via DB (the public /api/posts may not return non-trading sources)
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(Post, posted_id)
|
||||
if not row:
|
||||
c.fail("ingest round-trip", f"posted id={posted_id} not in DB")
|
||||
return
|
||||
c.ok("ingest round-trip", f"post_id={posted_id}, status={result.get('status')}")
|
||||
except Exception as e:
|
||||
c.fail("ingest round-trip", f"{type(e).__name__}: {e}")
|
||||
finally:
|
||||
# Always clean up the smoke-test row regardless of whether the check passed.
|
||||
if posted_id is not None:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
p = await db.get(Post, posted_id)
|
||||
if p:
|
||||
await db.delete(p)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
c.warn("ingest cleanup",
|
||||
f"could not delete smoke-test post_id={posted_id}: {e}")
|
||||
|
||||
|
||||
async def check_ws(c: Checker, base: str):
|
||||
# base is http(s)://...; switch scheme
|
||||
ws_url = base.replace("http://", "ws://").replace("https://", "wss://") + "/ws/prices"
|
||||
try:
|
||||
async with websockets.connect(ws_url, open_timeout=5) as ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=8)
|
||||
c.ok("websocket /ws/prices", f"got tick: {msg[:80]}")
|
||||
except asyncio.TimeoutError:
|
||||
c.warn("websocket /ws/prices",
|
||||
"connected but no tick in 8s — possible if Binance WS just reconnected")
|
||||
except Exception as e:
|
||||
c.fail("websocket /ws/prices", f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--base", default="http://localhost:8000",
|
||||
help="Backend base URL (default: localhost:8000)")
|
||||
args = p.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
|
||||
print(f"Smoke test against: {base}\n")
|
||||
c = Checker()
|
||||
|
||||
await check_health(c, base)
|
||||
await check_scrapers(c, base)
|
||||
await check_public_apis(c, base)
|
||||
await check_kol_freshness(c)
|
||||
await check_telegram(c)
|
||||
await check_ai(c)
|
||||
await check_signal_ingest_roundtrip(c, base)
|
||||
await check_ws(c, base)
|
||||
|
||||
print()
|
||||
if c.errors:
|
||||
print(red(f"❌ {len(c.errors)} failure(s):"))
|
||||
for e in c.errors:
|
||||
print(red(f" - {e}"))
|
||||
if c.warnings:
|
||||
print(yellow(f"⚠️ {len(c.warnings)} warning(s):"))
|
||||
for w in c.warnings:
|
||||
print(yellow(f" - {w}"))
|
||||
return 1
|
||||
if c.warnings:
|
||||
print(yellow(f"⚠️ {len(c.warnings)} warning(s) — but all critical checks passed:"))
|
||||
for w in c.warnings:
|
||||
print(yellow(f" - {w}"))
|
||||
print(green("✅ All critical checks passed."))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[verify] Running backend tests"
|
||||
PYTHONPATH=. venv/bin/pytest tests -q
|
||||
|
||||
if [[ -n "${BASE_URL:-}" ]]; then
|
||||
echo "[verify] Running launch smoke against ${BASE_URL}"
|
||||
PYTHONPATH=. venv/bin/python scripts/launch_smoke.py --base "$BASE_URL"
|
||||
else
|
||||
echo "[verify] Skipping launch smoke (set BASE_URL to enable it)"
|
||||
fi
|
||||
Reference in New Issue
Block a user