d6c802ef26
Batch of the pre-launch audit campaign (BUG-01…14 plus three new features): Pricing / TP-SL protection - Add app/services/hl_price_feed.py: supplemental HL allMids poller for HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store + tp_sl_monitor.on_price_tick so bot trades on these assets keep full stop-loss / take-profit / trailing protection instead of max-hold only. - Wire feed into main.py lifespan (startup task + graceful shutdown cancel). Telegram - Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump posts with no directional signal (relevant=True, signal=hold) now alert the public channel only (no per-subscriber noise). - Rate limiter (slowapi) on the API; assorted bot/digest fixes. KOL on-chain - seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate orphaned wallets (handle not in KOL_FEEDS → can never produce divergence) so the scanner stops burning cycles on them. Tests / misc - Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses realistic ms timestamps so the in-progress-day drop fires, matching the fetcher's bar count (was 0.3179 vs 0.3178 off-by-one). - Refresh stale notify_signal comment in truth_social.py. Frontend reduce-action type fix lives in the sibling repo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
477 lines
19 KiB
Python
477 lines
19 KiB
Python
"""Daily 3-section Telegram digest — Macro Vibes / KOL / Trump.
|
|
|
|
Sent to every TelegramBinding where digest_enabled=True at their chosen
|
|
digest_hour_utc. Independent of the per-signal real-time alert path
|
|
(telegram._dispatch) — a user can turn one off and the other on.
|
|
|
|
Architecture:
|
|
|
|
cron (hourly :00)
|
|
↓
|
|
send_daily_digest(current_hour)
|
|
↓
|
|
build_global_digest() ← runs ONCE, content is global
|
|
↓
|
|
for each binding where digest_hour_utc == current_hour:
|
|
format_digest(global, user_state) ← appends per-user line
|
|
send_message(chat_id, text)
|
|
update last_digest_sent_at
|
|
|
|
The body is rule-based (no LLM). Each section reads one or two structured
|
|
fields and picks a templated sentence. If we later want richer prose, the
|
|
single place to hook DeepSeek/Claude is at the bottom of
|
|
`format_digest` — everything else stays the same.
|
|
|
|
Idempotency: the SELECT filters on
|
|
last_digest_sent_at IS NULL OR last_digest_sent_at < now - 23h
|
|
so a coalesced cron or worker restart can't double-send within a day.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select, update, func, or_, and_
|
|
|
|
from app.config import settings
|
|
from app.database import AsyncSessionLocal as async_session
|
|
from app.models import (
|
|
TelegramBinding, MacroSnapshot, KolPost, KolDivergence,
|
|
Post, BotTrade, Subscription,
|
|
)
|
|
from app.services.telegram import send_message
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Data model ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class MacroBlock:
|
|
available: bool
|
|
composite: Optional[float]
|
|
regime: Optional[str] # BULL / BULLISH / NEUTRAL / BEARISH / BEAR
|
|
ahr999: Optional[float]
|
|
fear_greed: Optional[int]
|
|
bottom_signal_firing: bool # any Post(source=btc_bottom_reversal, last 24h)
|
|
bottom_votes: Optional[int] # 2 or 3 if firing, else None
|
|
funding_signal_firing: bool # any Post(source=funding_reversal, last 24h)
|
|
|
|
|
|
@dataclass
|
|
class KolBlock:
|
|
posts_24h: int
|
|
bullish: int
|
|
bearish: int
|
|
neutral: int
|
|
divergences_24h: int
|
|
sample_divergence: Optional[str] # one human-readable line, or None
|
|
|
|
|
|
@dataclass
|
|
class TrumpBlock:
|
|
posts_24h: int
|
|
actionable: list # list of (asset, side, confidence)
|
|
|
|
|
|
@dataclass
|
|
class GlobalDigest:
|
|
"""Same content for everyone. Per-user state (auto-trade, your trades)
|
|
is appended in format_digest."""
|
|
date_label: str # "May 26 · Mon"
|
|
macro: MacroBlock
|
|
kol: KolBlock
|
|
trump: TrumpBlock
|
|
headline_emoji: str # 🟢 / 🟡 / ⚪
|
|
headline_text: str # "BTC bottom signal firing. Worth a look."
|
|
|
|
|
|
# ── Builder ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def build_global_digest(now: Optional[datetime] = None) -> GlobalDigest:
|
|
"""Single DB pass over the three sources. Pure read — never raises;
|
|
returns a degraded digest if any source is missing."""
|
|
now = now or datetime.now(timezone.utc)
|
|
now_naive = now.replace(tzinfo=None)
|
|
cutoff_24h = now_naive - timedelta(hours=24)
|
|
date_label = now.strftime("%b %-d · %a")
|
|
|
|
async with async_session() as db:
|
|
# ── Macro: today's row (or yesterday's if today's not in yet) ────
|
|
today = now.date()
|
|
yesterday = today - timedelta(days=1)
|
|
snap = (await db.execute(
|
|
select(MacroSnapshot)
|
|
.where(MacroSnapshot.snapshot_date.in_([today, yesterday]))
|
|
.order_by(MacroSnapshot.snapshot_date.desc())
|
|
)).scalars().first()
|
|
|
|
# Bottom + funding triggers in last 24h
|
|
bottom_post = (await db.execute(
|
|
select(Post)
|
|
.where(Post.source == "btc_bottom_reversal",
|
|
Post.created_at >= cutoff_24h)
|
|
.order_by(Post.created_at.desc())
|
|
)).scalars().first()
|
|
funding_post = (await db.execute(
|
|
select(Post)
|
|
.where(Post.source == "funding_reversal",
|
|
Post.created_at >= cutoff_24h)
|
|
)).scalars().first()
|
|
|
|
macro = MacroBlock(
|
|
available=snap is not None,
|
|
composite=snap.composite_score if snap else None,
|
|
regime=snap.regime_label if snap else None,
|
|
ahr999=snap.ahr999 if snap else None,
|
|
fear_greed=snap.fear_greed if snap else None,
|
|
bottom_signal_firing=bottom_post is not None,
|
|
bottom_votes=(bottom_post.ai_confidence and
|
|
(3 if bottom_post.ai_confidence >= 90 else 2))
|
|
if bottom_post else None,
|
|
funding_signal_firing=funding_post is not None,
|
|
)
|
|
|
|
# ── KOL: last 24h posts + divergences ────────────────────────────
|
|
kol_posts = (await db.execute(
|
|
select(KolPost).where(KolPost.published_at >= cutoff_24h)
|
|
)).scalars().all()
|
|
# action lives inside tickers_json; easier: classify on summary text.
|
|
# The structured action is on KolDivergence rows; for the bare count
|
|
# we just split posts by whether divergence rows reference them.
|
|
bullish = bearish = neutral = 0
|
|
divergence_rows = (await db.execute(
|
|
select(KolDivergence)
|
|
.where(KolDivergence.post_at >= cutoff_24h)
|
|
.order_by(KolDivergence.created_at.desc())
|
|
)).scalars().all()
|
|
for d in divergence_rows:
|
|
if d.direction == "long":
|
|
bullish += 1
|
|
elif d.direction == "short":
|
|
bearish += 1
|
|
else:
|
|
neutral += 1
|
|
# Posts not yet classified into divergences just count as neutral.
|
|
neutral += max(0, len(kol_posts) - (bullish + bearish + neutral))
|
|
|
|
divergences_24h = sum(
|
|
1 for d in divergence_rows if d.signal_type == "divergence"
|
|
)
|
|
sample_divergence: Optional[str] = None
|
|
for d in divergence_rows:
|
|
if d.signal_type != "divergence":
|
|
continue
|
|
# "Hayes publicly bullish BTC, on-chain selling"
|
|
sample_divergence = (
|
|
f"{d.handle} publicly {d.post_action} {d.ticker}, "
|
|
f"on-chain {d.onchain_action}"
|
|
)
|
|
break
|
|
|
|
kol = KolBlock(
|
|
posts_24h=len(kol_posts),
|
|
bullish=bullish, bearish=bearish, neutral=neutral,
|
|
divergences_24h=divergences_24h,
|
|
sample_divergence=sample_divergence,
|
|
)
|
|
|
|
# ── Trump: last 24h posts, list actionable ones ──────────────────
|
|
trump_posts = (await db.execute(
|
|
select(Post)
|
|
.where(Post.source == "truth", Post.created_at >= cutoff_24h)
|
|
.order_by(Post.created_at.desc())
|
|
)).scalars().all()
|
|
actionable = []
|
|
for p in trump_posts:
|
|
if p.signal in ("buy", "short") and (p.ai_confidence or 0) >= 70:
|
|
actionable.append((
|
|
p.target_asset or "?",
|
|
"LONG" if p.signal == "buy" else "SHORT",
|
|
int(p.ai_confidence or 0),
|
|
))
|
|
trump = TrumpBlock(posts_24h=len(trump_posts), actionable=actionable)
|
|
|
|
headline_emoji, headline_text = _headline(macro, kol, trump)
|
|
|
|
return GlobalDigest(
|
|
date_label=date_label,
|
|
macro=macro, kol=kol, trump=trump,
|
|
headline_emoji=headline_emoji, headline_text=headline_text,
|
|
)
|
|
|
|
|
|
def _headline(macro: MacroBlock, kol: KolBlock, trump: TrumpBlock) -> tuple[str, str]:
|
|
"""Pick a one-liner verdict + emoji from the three sections.
|
|
|
|
Priority: macro bottom > macro funding > Trump actionable > KOL
|
|
divergence > nothing.
|
|
"""
|
|
if macro.bottom_signal_firing:
|
|
return "🟢", "BTC bottom signal firing. Worth a look."
|
|
if macro.funding_signal_firing:
|
|
return "🟢", "Funding extreme — mean-reversion setup forming."
|
|
if len(trump.actionable) > 0:
|
|
n = len(trump.actionable)
|
|
return "🟢", f"Trump posted {n} actionable crypto signal{'s' if n > 1 else ''} today."
|
|
if kol.divergences_24h > 0:
|
|
return "🟡", f"{kol.divergences_24h} KOL talks-vs-trades divergence today."
|
|
return "⚪", "Quiet day, nothing to act on."
|
|
|
|
|
|
# ── Formatter ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def format_digest(g: GlobalDigest, user_state: Optional[dict] = None) -> str:
|
|
"""Render the global digest into a single HTML message.
|
|
|
|
`user_state` is a small dict built per-binding by send_daily_digest:
|
|
{ "wallet": str | None,
|
|
"auto_trade": bool,
|
|
"trades_today": int,
|
|
"open_pnl_summary": str | None }
|
|
None or empty → free-tier user, the Pro status line is suppressed.
|
|
"""
|
|
lines: list[str] = []
|
|
lines.append("📰 <b>Trump Alpha · Daily Brief</b>")
|
|
lines.append(f"<i>{g.date_label}</i>")
|
|
lines.append("")
|
|
lines.append(f"{g.headline_emoji} <b>Today:</b> {g.headline_text}")
|
|
lines.append("")
|
|
|
|
# ── Macro ────────────────────────────────────────────────────────────
|
|
lines.append("<b>━ Macro ━</b>")
|
|
m = g.macro
|
|
if not m.available:
|
|
lines.append("Macro snapshot unavailable today.")
|
|
elif m.bottom_signal_firing:
|
|
votes = m.bottom_votes or 2
|
|
lines.append(f"BTC bottom triggers: <b>{votes}/3 firing</b>.")
|
|
if m.ahr999 is not None:
|
|
lines.append(f"AHR999={m.ahr999:.2f}, F&G={m.fear_greed or '?'}.")
|
|
elif m.funding_signal_firing:
|
|
lines.append("Funding-rate extreme — mean-reversion alert active.")
|
|
else:
|
|
# Plain day: one sentence + 1-2 backing numbers
|
|
regime_phrase = {
|
|
"BULL": "strongly bullish",
|
|
"BULLISH": "leaning bullish",
|
|
"NEUTRAL": "in neutral zone, far from bottom",
|
|
"BEARISH": "leaning bearish",
|
|
"BEAR": "strongly bearish",
|
|
}.get((m.regime or "NEUTRAL").upper(), "in neutral zone")
|
|
lines.append(f"BTC {regime_phrase}.")
|
|
bits = []
|
|
if m.ahr999 is not None:
|
|
bits.append(f"AHR999={m.ahr999:.2f} (bottom <0.45)")
|
|
if m.fear_greed is not None:
|
|
bits.append(f"F&G={m.fear_greed}")
|
|
if bits:
|
|
lines.append(", ".join(bits) + ".")
|
|
lines.append("")
|
|
|
|
# ── KOL ──────────────────────────────────────────────────────────────
|
|
lines.append("<b>━ KOL ━</b>")
|
|
k = g.kol
|
|
if k.posts_24h == 0:
|
|
lines.append("No KOL posts in last 24h.")
|
|
else:
|
|
lines.append(
|
|
f"{k.posts_24h} posts today: <b>{k.bullish}</b> bullish / "
|
|
f"<b>{k.bearish}</b> bearish."
|
|
)
|
|
if k.divergences_24h > 0 and k.sample_divergence:
|
|
lines.append(f"⚠️ {k.sample_divergence}.")
|
|
elif k.divergences_24h == 0:
|
|
lines.append("No talks-vs-trades divergence.")
|
|
lines.append("")
|
|
|
|
# ── Trump ────────────────────────────────────────────────────────────
|
|
lines.append("<b>━ Trump ━</b>")
|
|
t = g.trump
|
|
if t.posts_24h == 0:
|
|
lines.append("No posts in last 24h.")
|
|
elif not t.actionable:
|
|
lines.append(f"{t.posts_24h} posts today, none crypto-actionable.")
|
|
else:
|
|
lines.append(
|
|
f"{t.posts_24h} posts today, "
|
|
f"<b>{len(t.actionable)}</b> actionable:"
|
|
)
|
|
for asset, side, conf in t.actionable[:3]:
|
|
emoji = "🟢" if side == "LONG" else "🔴"
|
|
lines.append(f" {emoji} {asset} · {side} · conf {conf}")
|
|
if len(t.actionable) > 3:
|
|
lines.append(f" …and {len(t.actionable) - 3} more.")
|
|
lines.append("")
|
|
|
|
# ── Per-user state (Pro only) ────────────────────────────────────────
|
|
if user_state and user_state.get("wallet"):
|
|
at = "ON" if user_state.get("auto_trade") else "OFF"
|
|
td = int(user_state.get("trades_today") or 0)
|
|
bits = [f"auto-trade {at}", f"{td} trade{'s' if td != 1 else ''} today"]
|
|
if user_state.get("open_pnl_summary"):
|
|
bits.append(user_state["open_pnl_summary"])
|
|
lines.append("<b>Your status:</b> " + " · ".join(bits))
|
|
lines.append("")
|
|
|
|
# ── Footer ───────────────────────────────────────────────────────────
|
|
fe = (settings.frontend_url or "").rstrip("/")
|
|
if fe:
|
|
lines.append(f'<a href="{fe}/en/macro">→ open dashboard</a>')
|
|
lines.append("")
|
|
lines.append("<i>Daily brief · reply /digest off to unsubscribe</i>")
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
# ── Sender ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _build_user_state(db, binding: TelegramBinding,
|
|
now_naive: datetime) -> dict:
|
|
"""Pro-only: fetch auto-trade flag + today's trade count for the
|
|
wallet attached to this binding. Returns {} for free users."""
|
|
if not binding.wallet_address:
|
|
return {}
|
|
sub = (await db.execute(
|
|
select(Subscription).where(Subscription.wallet_address == binding.wallet_address)
|
|
)).scalar_one_or_none()
|
|
if sub is None:
|
|
return {}
|
|
start_of_day = now_naive.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
trades_today = (await db.execute(
|
|
select(func.count(BotTrade.id)).where(
|
|
BotTrade.wallet_address == binding.wallet_address,
|
|
BotTrade.opened_at >= start_of_day,
|
|
)
|
|
)).scalar() or 0
|
|
# Open positions' aggregated unrealised — best-effort, skip on any error.
|
|
open_pnl_summary: Optional[str] = None
|
|
try:
|
|
# Released trades aren't bot-managed any more; their unrealised
|
|
# PnL belongs to the user's manual control, not to "your bot status".
|
|
open_trades = (await db.execute(
|
|
select(BotTrade).where(
|
|
BotTrade.wallet_address == binding.wallet_address,
|
|
BotTrade.closed_at.is_(None),
|
|
BotTrade.released_at.is_(None),
|
|
)
|
|
)).scalars().all()
|
|
if open_trades:
|
|
from app.services.price_store import price_store
|
|
unreal = 0.0
|
|
for t in open_trades:
|
|
px = price_store.latest_price(t.asset)
|
|
if not px or not t.entry_price or not t.size_usd:
|
|
continue
|
|
pct = (px - t.entry_price) / t.entry_price
|
|
signed = pct if t.side == "long" else -pct
|
|
rem = t.remaining_fraction if t.remaining_fraction is not None else 1.0
|
|
unreal += t.size_usd * rem * signed
|
|
if open_trades:
|
|
sign = "+" if unreal >= 0 else ""
|
|
open_pnl_summary = f"{len(open_trades)} open ({sign}{unreal:.1f} USD)"
|
|
except Exception as exc:
|
|
logger.debug("digest user_state: skip open pnl: %s", exc)
|
|
|
|
return {
|
|
"wallet": binding.wallet_address,
|
|
"auto_trade": bool(sub.auto_trade),
|
|
"trades_today": int(trades_today),
|
|
"open_pnl_summary": open_pnl_summary,
|
|
}
|
|
|
|
|
|
async def send_daily_digest(current_hour: Optional[int] = None) -> int:
|
|
"""Send the digest to every binding whose digest_hour_utc matches the
|
|
current UTC hour AND who hasn't received one in the last 23 hours.
|
|
|
|
Returns the number of messages successfully sent. Never raises — the
|
|
cron firing must not bring the worker down.
|
|
"""
|
|
if not settings.telegram_bot_token:
|
|
return 0
|
|
|
|
now = datetime.now(timezone.utc)
|
|
hour = current_hour if current_hour is not None else now.hour
|
|
now_naive = now.replace(tzinfo=None)
|
|
dedupe_cutoff = now_naive - timedelta(hours=23)
|
|
|
|
# Build the body ONCE — it's identical for every user (only per-user
|
|
# state line varies). One DB pass instead of one per binding.
|
|
try:
|
|
global_digest = await build_global_digest(now)
|
|
except Exception as exc:
|
|
logger.exception("Digest: build_global_digest failed: %s", exc)
|
|
return 0
|
|
|
|
sent = 0
|
|
async with async_session() as db:
|
|
# NOTE: alerts_enabled is intentionally NOT gated here. /stop pauses
|
|
# real-time signal alerts but the daily digest is a separate feature
|
|
# (/digest on|off). Filtering on alerts_enabled would silently kill
|
|
# the digest for any user who typed /stop, contradicting the bot's
|
|
# own message ("Alerts paused — send /start to re-enable").
|
|
bindings = (await db.execute(
|
|
select(TelegramBinding).where(
|
|
TelegramBinding.digest_enabled.is_(True),
|
|
TelegramBinding.digest_hour_utc == hour,
|
|
or_(
|
|
TelegramBinding.last_digest_sent_at.is_(None),
|
|
TelegramBinding.last_digest_sent_at < dedupe_cutoff,
|
|
),
|
|
)
|
|
)).scalars().all()
|
|
|
|
if not bindings:
|
|
return 0
|
|
|
|
for b in bindings:
|
|
try:
|
|
user_state = await _build_user_state(db, b, now_naive)
|
|
text = format_digest(global_digest, user_state)
|
|
ok = await send_message(b.chat_id, text)
|
|
if ok:
|
|
await db.execute(
|
|
update(TelegramBinding)
|
|
.where(TelegramBinding.id == b.id)
|
|
.values(last_digest_sent_at=now_naive)
|
|
)
|
|
sent += 1
|
|
except Exception as exc:
|
|
logger.warning("Digest send failed for chat=%s: %s", b.chat_id, exc)
|
|
await db.commit()
|
|
|
|
logger.info("Daily digest: hour=%dz sent=%d/%d", hour, sent, len(bindings))
|
|
return sent
|
|
|
|
|
|
# ── Manual preview (called from bot /digest command) ──────────────────────
|
|
|
|
|
|
async def send_preview_for(chat_id: int) -> bool:
|
|
"""Render and immediately send a digest to one chat. Does NOT update
|
|
last_digest_sent_at — preview is independent of the daily slot."""
|
|
if not settings.telegram_bot_token:
|
|
return False
|
|
try:
|
|
g = await build_global_digest()
|
|
except Exception as exc:
|
|
logger.exception("Digest preview: build failed: %s", exc)
|
|
return await send_message(
|
|
chat_id, "⚠️ Digest preview unavailable — check back shortly.")
|
|
async with async_session() as db:
|
|
b = (await db.execute(
|
|
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
|
|
)).scalar_one_or_none()
|
|
user_state = (await _build_user_state(db, b, datetime.utcnow())
|
|
if b else {})
|
|
text = format_digest(g, user_state)
|
|
return await send_message(chat_id, text)
|