3754d2caf8
Two changes ship together — both reshape the Telegram-bot surface.
──── 1. Telegram daily digest (3-section brief)
Once-a-day push covering Macro Vibes / KOL talks-vs-trades / Trump 24h.
Body is rule-based templating (no LLM); each section reads structured DB
fields and picks a phrasing. Per-user opt-out + per-user UTC hour.
- 023 migration: TelegramBinding gains digest_enabled, digest_hour_utc,
last_digest_sent_at (idempotent against coalesced cron / restarts).
- New services/telegram_digest.py: build_global_digest +
format_digest + send_daily_digest + send_preview_for.
- Hourly cron at :00 fans out to bindings whose digest_hour_utc matches.
- New bot commands: /digest (preview), /digest on|off, /digest_time HH.
- HELP_TEXT + /status updated to surface the new prefs.
──── 2. System-2 manage-only refactor
The Macro Vibes (BTC bottom + funding) signal no longer auto-opens
positions. Strategy is day-K — a 24h entry delay is irrelevant — but the
auto-open path carried real execution surface (leverage clipping, daily
budget split, concurrency caps, paper branches, key handling) for ~zero
alpha. The valuable part — multi-month exit management (5-rung stop
ladder + de-risk + pyramid + peak-trail) — is preserved and runs
against positions the user adopts.
Flow: scanner fires → Telegram alert with "/adopt" CTA → user opens
manually on Hyperliquid → /adopt picks the position via inline keyboard
→ picks Standard or Aggressive mode → bot creates BotTrade + registers
watchdog. Escape hatch: /release marks released_at, unregisters
watchdog, leaves the HL position open under user control.
- 024 migration: BotTrade.released_at — "user took back control" marker.
- bot_engine.process_post early-returns for sys2 (no auto-open path).
- New services/adoption.py: list_hl_positions + adopt_position +
release_management + AdoptionError. Per-wallet asyncio lock prevents
dual-adopt race. Pre-flight checks: no_subscription / no_hl_key /
paper_mode / circuit_breaker / already_adopted / concurrency_cap.
Protective stop + de-risk + addon + peak-trail ladders all built
against the ACTUAL HL leverage so the "inside liquidation" guarantee
holds.
- New API: GET /positions/hl/{wallet}, POST /positions/adopt,
POST /positions/{id}/release (all signed).
- telegram.py: send_message supports reply_markup; new edit_message +
answer_callback for the inline-keyboard pickers; sys2 alert format
now ends with the "/adopt" CTA.
- telegram_bot.py: /adopt + /release commands with picker → confirm
→ execute flow via inline keyboards. New _handle_callback dispatches
on "adopt:*" / "release:*" callback_data; run_bot_loop now consumes
callback_query updates alongside messages.
- recovery.py + reconciler.py skip released_at IS NOT NULL rows so a
restart doesn't silently re-attach the watchdog to a released trade.
- /positions/open, /positions/today, and telegram_digest's user-state
line all filter released_at so they don't lie about what the bot is
actually managing.
Tests: 26 new (8 digest snapshot + 14 adoption + 4 absorbed via existing
suites). All 64 pass.
Deploy: alembic upgrade head (runs 023 + 024) → restart backend. Existing
TelegramBindings get digest_enabled=true / digest_hour_utc=12 via server
defaults. In-flight auto-opened System-2 positions continue to be managed
(recovery rehydrates them) — no in-flight trade is abandoned.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
425 lines
18 KiB
Python
425 lines
18 KiB
Python
import asyncio
|
||
import logging
|
||
from contextlib import asynccontextmanager
|
||
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
from app.config import settings
|
||
from app.database import AsyncSessionLocal, engine
|
||
from app.models import Base
|
||
from app.scrapers.truth_social import poll_truth_social, backfill_history
|
||
from app.scrapers.trumpstruth import poll_trumpstruth
|
||
from app.services.binance import run_binance_ws
|
||
from app.ws.manager import manager
|
||
|
||
# Import all routers
|
||
from app.api.posts import router as posts_router
|
||
from app.api.prices import router as prices_router
|
||
from app.api.trades import router as trades_router
|
||
from app.api.performance import router as performance_router
|
||
from app.api.subscribe import router as subscribe_router
|
||
from app.api.user import router as user_router
|
||
from app.api.funding_signal import router as funding_signal_router
|
||
from app.api.funding_reversal import router as funding_reversal_router
|
||
from app.api.telegram import router as telegram_router
|
||
from app.api.signals import router as signals_router
|
||
from app.api.positions import router as positions_router
|
||
from app.api.scanners import router as scanners_router
|
||
from app.api.kol import router as kol_router
|
||
from app.api.macro import router as macro_router
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from typing import Optional
|
||
_binance_task: Optional[asyncio.Task] = None
|
||
_telegram_task: Optional[asyncio.Task] = None
|
||
_scheduler: Optional[AsyncIOScheduler] = None
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
global _binance_task, _telegram_task, _scheduler
|
||
|
||
# 1. Dev convenience only. Production should rely on Alembic so schema
|
||
# ownership stays explicit and startup never mutates the DB implicitly.
|
||
if settings.environment == "development":
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
logger.info("Database tables ensured (development mode).")
|
||
else:
|
||
logger.info("Skipping create_all; expecting schema managed by Alembic.")
|
||
|
||
# 2. Backfill historical posts on startup (fast, no Claude API call)
|
||
asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500))
|
||
|
||
# 2b. Rehydrate TP/SL + max-hold backstops for any positions still open in DB.
|
||
# Critical: without this, a redeploy/crash silently drops protection on live trades.
|
||
from app.services.recovery import rehydrate_open_trades
|
||
asyncio.create_task(rehydrate_open_trades())
|
||
|
||
# 3. Start Binance WebSocket background task
|
||
_binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws")
|
||
logger.info("Binance WebSocket task started.")
|
||
|
||
# 3. Start Truth Social poller via APScheduler
|
||
_scheduler = AsyncIOScheduler()
|
||
# Signal monitor — polls every 5 minutes
|
||
from app.services.funding_signal import poll_funding_signal
|
||
_scheduler.add_job(
|
||
poll_funding_signal,
|
||
"interval",
|
||
minutes=5,
|
||
id="funding_signal_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
logger.info("Breakout signal monitor scheduled every 5 minutes.")
|
||
|
||
_scheduler.add_job(
|
||
poll_truth_social,
|
||
"interval",
|
||
seconds=settings.truth_social_poll_seconds,
|
||
args=[AsyncSessionLocal],
|
||
id="truth_social_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
# Fallback poller — trumpstruth.org RSS. Same Truth Social originalId =
|
||
# same external_id hash, so dedup is automatic. Whoever sees the post
|
||
# first wins. Offset by half the interval so the two pollers don't
|
||
# hammer the network at the same instant.
|
||
# Offset the fallback by half the interval so the two pollers don't hit
|
||
# upstream at the same instant. APScheduler treats next_run_time=None as
|
||
# "paused" (job never fires) — must be an actual datetime.
|
||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||
offset = settings.truth_social_poll_seconds // 2
|
||
_scheduler.add_job(
|
||
poll_trumpstruth,
|
||
"interval",
|
||
seconds=settings.truth_social_poll_seconds,
|
||
args=[AsyncSessionLocal],
|
||
id="trumpstruth_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
next_run_time=_dt.now(_tz.utc) + _td(seconds=offset),
|
||
)
|
||
# HL <-> DB reconciliation — every 60s, detects state drift
|
||
# (manual closes on HL UI, liquidations, orphan positions). See
|
||
# app/services/reconciler.py. Critical for live trading safety.
|
||
from app.services.reconciler import reconcile_all_once, RECONCILE_INTERVAL_SECONDS
|
||
_scheduler.add_job(
|
||
reconcile_all_once,
|
||
"interval",
|
||
seconds=RECONCILE_INTERVAL_SECONDS,
|
||
id="hl_reconcile",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
logger.info("HL <-> DB reconciler scheduled every %ds.", RECONCILE_INTERVAL_SECONDS)
|
||
|
||
# ── System-2 bottom-reversal state machine ─────────────────────────────
|
||
# Low-frequency, long-only: fires when ≥2 of 3 classic bottom signals
|
||
# agree — AHR999 < 0.45, price ≤ 200-week MA ×1.05, Pi Cycle Bottom
|
||
# (150d EMA ≤ 471d SMA × 0.745). Funding is a booster inside the scanner,
|
||
# not an independent entry source. See app/services/scanners/btc_bottom_reversal.py.
|
||
from app.services.scanners.btc_bottom_reversal import scan_once as btc_bottom_scan
|
||
_scheduler.add_job(
|
||
btc_bottom_scan, "cron", hour=0, minute=45,
|
||
id="btc_bottom_reversal_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("BTC bottom-reversal scanner scheduled daily at 00:45 UTC.")
|
||
|
||
# ── BTC funding-rate reversal (hourly) ────────────────────────────────
|
||
# Mean-reversion play on extreme perp positioning. Independent of the
|
||
# bottom-reversal state machine but uses the same `evaluate_funding_reversal`
|
||
# algorithm. Runs every hour because HL funding settles hourly.
|
||
from app.services.scanners.funding_reversal import scan_once as funding_scan
|
||
_scheduler.add_job(
|
||
funding_scan, "cron", minute=7, # :07 every hour, away from other jobs
|
||
id="funding_reversal_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Funding reversal scanner scheduled hourly at :07.")
|
||
|
||
# ── KOL Substack poller (daily) ──────────────────────────────────────
|
||
# Hayes & co publish at most a few times a week. Daily poll is plenty;
|
||
# RSS dedupe by URL is idempotent if it ever fires twice.
|
||
from app.services.kol_substack import run_substack_poll
|
||
_scheduler.add_job(
|
||
run_substack_poll, "cron", hour=1, minute=15,
|
||
id="kol_substack_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL Substack poller scheduled daily at 01:15 UTC.")
|
||
|
||
# ── KOL A-tier: on-chain holdings snapshot (daily) ────────────────────
|
||
# Polls HL public API (free) for perp positions; Arkham (key optional)
|
||
# for full portfolio. Diffs against yesterday's snapshot → writes
|
||
# kol_holding_changes. Runs at 02:00 UTC, after Substack poll finishes.
|
||
from app.services.kol_onchain import run_onchain_poll
|
||
_scheduler.add_job(
|
||
run_onchain_poll, "cron", hour=2, minute=0,
|
||
id="kol_onchain_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL on-chain holdings poller scheduled daily at 02:00 UTC.")
|
||
|
||
# ── KOL talks-vs-trades divergence scan (daily) ───────────────────────
|
||
# Runs after the on-chain poll finishes. Matches post ticker signals vs
|
||
# holding changes for the same KOL+ticker within ±7 days.
|
||
from app.services.kol_divergence import run_divergence_scan
|
||
_scheduler.add_job(
|
||
run_divergence_scan, "cron", hour=2, minute=15,
|
||
id="kol_divergence_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL divergence scan scheduled daily at 02:15 UTC.")
|
||
|
||
# ── Macro indicator daily snapshot ────────────────────────────────────
|
||
# 8 indicators (AHR999, Fear & Greed, BTC dominance, ETH/BTC, stablecoin
|
||
# supply, ETF flow, BTC OI, altcoin season). One row per calendar date.
|
||
# Runs after KOL jobs so a slow KOL fetch can't make this one miss.
|
||
from app.services.macro.poll import run_macro_poll
|
||
_scheduler.add_job(
|
||
run_macro_poll, "cron", hour=3, minute=0,
|
||
id="macro_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Macro indicator snapshot scheduled daily at 03:00 UTC.")
|
||
|
||
# Kick off an initial poll on startup IF today's row doesn't exist yet.
|
||
# Otherwise a fresh deploy shows an empty macro panel until 03:00 UTC of
|
||
# the next day. Fire-and-forget — never blocks startup.
|
||
async def _macro_bootstrap():
|
||
try:
|
||
from datetime import datetime, timezone
|
||
from sqlalchemy import select
|
||
from app.models import MacroSnapshot
|
||
today = datetime.now(timezone.utc).date()
|
||
async with AsyncSessionLocal() as db:
|
||
exists = (await db.execute(
|
||
select(MacroSnapshot).where(MacroSnapshot.snapshot_date == today)
|
||
)).scalar_one_or_none()
|
||
if exists is None:
|
||
logger.info("Macro: no row for today, running one-shot bootstrap fetch.")
|
||
await run_macro_poll()
|
||
except Exception as exc:
|
||
logger.warning("Macro bootstrap fetch failed: %s (%s)",
|
||
type(exc).__name__, exc)
|
||
asyncio.create_task(_macro_bootstrap())
|
||
|
||
# ── Telegram daily digest ─────────────────────────────────────────────
|
||
# Runs every hour at :00. Each binding picks its own digest_hour_utc
|
||
# (0–23, default 12); the sender only fans out to rows whose preferred
|
||
# hour matches the current one — so we get one cron job covering all 24
|
||
# delivery slots. The send function itself dedupes against
|
||
# last_digest_sent_at (skips if < 23h), so a coalesced cron or worker
|
||
# restart can't double-send a user in the same day.
|
||
from app.services.telegram_digest import send_daily_digest
|
||
_scheduler.add_job(
|
||
send_daily_digest, "cron", minute=0,
|
||
id="telegram_daily_digest",
|
||
max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Telegram daily digest scheduled hourly at :00 UTC "
|
||
"(per-user hour selection).")
|
||
|
||
_scheduler.start()
|
||
logger.info(
|
||
"Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).",
|
||
settings.truth_social_poll_seconds,
|
||
)
|
||
|
||
# ── Telegram bot long-poll loop (optional) ────────────────────────────
|
||
# Only started if TELEGRAM_BOT_TOKEN is set. Handles /start CODE bindings
|
||
# and /stop, /status, /test commands. Survives transient network errors
|
||
# via internal back-off.
|
||
from app.services.telegram_bot import run_bot_loop
|
||
_telegram_task = asyncio.create_task(run_bot_loop(), name="telegram_bot")
|
||
logger.info("Telegram bot task created (will no-op if token missing).")
|
||
|
||
yield
|
||
|
||
# Shutdown
|
||
logger.info("Shutting down background tasks...")
|
||
if _scheduler:
|
||
_scheduler.shutdown(wait=False)
|
||
if _binance_task and not _binance_task.done():
|
||
_binance_task.cancel()
|
||
try:
|
||
await _binance_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
if _telegram_task and not _telegram_task.done():
|
||
_telegram_task.cancel()
|
||
try:
|
||
await _telegram_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
await engine.dispose()
|
||
logger.info("Shutdown complete.")
|
||
|
||
|
||
app = FastAPI(
|
||
title="TrumpSignal API",
|
||
version="1.0.0",
|
||
description="Crypto trading signals derived from Trump's Truth Social posts.",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# CORS
|
||
# In production we only allow the canonical frontend origin (FRONTEND_URL).
|
||
# In development we additionally permit the local Next dev server. NEVER
|
||
# permit "*" here — every endpoint either reads/writes user-personalised
|
||
# data or accepts signed envelopes, both of which require credentialled
|
||
# requests (and "*" is rejected by browsers in combination with credentials
|
||
# anyway).
|
||
allowed_origins = [settings.frontend_url]
|
||
if settings.environment == "development":
|
||
allowed_origins.extend([
|
||
"http://localhost:3000",
|
||
"http://localhost:3001",
|
||
])
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=allowed_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# REST routers
|
||
app.include_router(posts_router, prefix="/api")
|
||
app.include_router(prices_router, prefix="/api")
|
||
app.include_router(trades_router, prefix="/api")
|
||
app.include_router(performance_router, prefix="/api")
|
||
app.include_router(subscribe_router, prefix="/api")
|
||
app.include_router(user_router, prefix="/api")
|
||
app.include_router(funding_signal_router, prefix="/api")
|
||
app.include_router(funding_reversal_router, prefix="/api")
|
||
app.include_router(telegram_router, prefix="/api")
|
||
app.include_router(signals_router, prefix="/api")
|
||
app.include_router(positions_router, prefix="/api")
|
||
app.include_router(scanners_router, prefix="/api")
|
||
app.include_router(kol_router, prefix="/api")
|
||
app.include_router(macro_router, prefix="/api")
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
"""Shallow liveness — used by load balancers / Docker healthcheck.
|
||
Returns 200 as long as the process is alive and the event loop responds."""
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/api/health/deep")
|
||
async def health_deep():
|
||
"""Deep healthcheck — used by external uptime monitors (UptimeRobot, etc).
|
||
|
||
Returns 503 if any of:
|
||
- DB query fails
|
||
- Scraper hasn't completed a successful poll in > 90s
|
||
(poll runs every 15s; allow 6 misses before alarm)
|
||
|
||
Body always includes diagnostic fields so the alarm payload is actionable.
|
||
"""
|
||
from datetime import datetime, timezone
|
||
from fastapi import Response
|
||
from sqlalchemy import text
|
||
from app.scrapers import truth_social as ts_scraper
|
||
from app.scrapers import trumpstruth as ts_fallback
|
||
|
||
now = datetime.now(timezone.utc)
|
||
problems: list[str] = []
|
||
|
||
# 1. DB ping
|
||
db_ok = False
|
||
db_error: Optional[str] = None
|
||
try:
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(text("SELECT 1"))
|
||
db_ok = True
|
||
except Exception as exc:
|
||
db_error = str(exc)
|
||
problems.append(f"db: {db_error}")
|
||
|
||
# 2. Scraper liveness — system is healthy if AT LEAST ONE source polled
|
||
# recently. Both stale = real problem (network down, or both upstreams dead).
|
||
sources = [
|
||
("cnn", ts_scraper.last_successful_poll_at, ts_scraper.last_poll_error),
|
||
("trumpstruth", ts_fallback.last_successful_poll_at, ts_fallback.last_poll_error),
|
||
]
|
||
source_status = []
|
||
freshest_age: Optional[int] = None
|
||
for name, last, err in sources:
|
||
age = int((now - last).total_seconds()) if last else None
|
||
source_status.append({
|
||
"name": name,
|
||
"last_poll": last.isoformat() if last else None,
|
||
"age_sec": age,
|
||
"last_error": err,
|
||
})
|
||
if age is not None and (freshest_age is None or age < freshest_age):
|
||
freshest_age = age
|
||
|
||
if freshest_age is None:
|
||
problems.append("scrapers: no source has ever completed a poll")
|
||
elif freshest_age > 90:
|
||
# 6× the 15s interval — both sources are silent
|
||
problems.append(f"scrapers: all stale (freshest={freshest_age}s)")
|
||
|
||
body = {
|
||
"status": "ok" if not problems else "degraded",
|
||
"now": now.isoformat(),
|
||
"db_ok": db_ok,
|
||
"db_error": db_error,
|
||
"scrapers": source_status,
|
||
"freshest_age_sec": freshest_age,
|
||
"problems": problems,
|
||
}
|
||
|
||
if problems:
|
||
return Response(
|
||
content=__import__("json").dumps(body),
|
||
status_code=503,
|
||
media_type="application/json",
|
||
)
|
||
return body
|
||
|
||
if settings.environment == "development":
|
||
from app.api.dev import router as dev_router
|
||
app.include_router(dev_router, prefix="/api")
|
||
|
||
|
||
# ── WebSocket endpoints ────────────────────────────────────────────────────
|
||
|
||
|
||
@app.websocket("/ws/prices")
|
||
async def ws_prices(websocket: WebSocket):
|
||
"""Subscribe to live price ticks (type: 'price')."""
|
||
await manager.connect(websocket)
|
||
try:
|
||
while True:
|
||
# Keep connection alive; messages are pushed via manager.broadcast
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
await manager.disconnect(websocket)
|
||
except Exception as exc:
|
||
logger.warning("WebSocket /ws/prices error: %s", exc)
|
||
await manager.disconnect(websocket)
|
||
|
||
|
||
@app.websocket("/ws/posts")
|
||
async def ws_posts(websocket: WebSocket):
|
||
"""Subscribe to new post events (type: 'new_post')."""
|
||
await manager.connect(websocket)
|
||
try:
|
||
while True:
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
await manager.disconnect(websocket)
|
||
except Exception as exc:
|
||
logger.warning("WebSocket /ws/posts error: %s", exc)
|
||
await manager.disconnect(websocket)
|