Files
trumpsignal-backend/tests/test_telegram_digest.py
T
k 3754d2caf8 feat: Telegram daily digest + System-2 manage-only refactor
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>
2026-05-26 12:53:16 +08:00

177 lines
6.3 KiB
Python

"""Snapshot-style tests for the daily digest formatter.
These hit format_digest with hand-rolled GlobalDigest objects so we exercise
every branch (quiet day / bottom trigger / actionable Trump / divergence /
empty all-around) without standing up a DB. The actual build_global_digest
SQL is exercised by integration tests / manual /digest in the bot.
The point of these tests is to lock the user-facing wording — if a future
change accidentally turns "BTC bottom triggers: 3/3 firing" into a less
clear phrasing, CI catches it.
"""
from app.services.telegram_digest import (
GlobalDigest, MacroBlock, KolBlock, TrumpBlock, format_digest,
)
def _empty_blocks():
return (
MacroBlock(available=False, composite=None, regime=None,
ahr999=None, fear_greed=None,
bottom_signal_firing=False, bottom_votes=None,
funding_signal_firing=False),
KolBlock(posts_24h=0, bullish=0, bearish=0, neutral=0,
divergences_24h=0, sample_divergence=None),
TrumpBlock(posts_24h=0, actionable=[]),
)
def test_quiet_day_renders_compact_and_mentions_no_activity():
m, k, t = _empty_blocks()
m.available = True
m.regime = "NEUTRAL"
m.ahr999 = 0.62
m.fear_greed = 38
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="",
headline_text="Quiet day, nothing to act on.")
out = format_digest(g)
assert "Trump Alpha · Daily Brief" in out
assert "" in out
assert "Quiet day" in out
assert "neutral zone" in out
assert "AHR999=0.62" in out
assert "No KOL posts" in out
assert "No posts in last 24h" in out
# No per-user Pro line for free users
assert "Your status" not in out
# Always ends with the unsubscribe hint
assert "/digest off" in out
def test_bottom_trigger_day_promotes_macro_section_and_keeps_short():
m, k, t = _empty_blocks()
m.available = True
m.bottom_signal_firing = True
m.bottom_votes = 3
m.ahr999 = 0.41
m.fear_greed = 14
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="🟢",
headline_text="BTC bottom signal firing. Worth a look.")
out = format_digest(g)
assert "🟢" in out
assert "BTC bottom signal firing" in out
# Macro section shows the trigger detail
assert "3/3 firing" in out
assert "AHR999=0.41" in out
def test_trump_actionable_list_is_capped_at_three_with_summary_overflow():
m, k, t = _empty_blocks()
m.available = True
m.regime = "NEUTRAL"
t.posts_24h = 6
t.actionable = [
("BTC", "LONG", 92),
("SOL", "LONG", 81),
("ETH", "SHORT", 76),
("DOGE", "LONG", 71), # 4th — should be summarised
]
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="🟢",
headline_text="Trump posted 4 actionable crypto signals today.")
out = format_digest(g)
assert "BTC · LONG · conf 92" in out
assert "SOL · LONG · conf 81" in out
assert "ETH · SHORT · conf 76" in out
# The fourth must roll into the "and N more" line, not be listed verbatim
assert "DOGE" not in out
assert "1 more" in out
def test_divergence_warning_surfaces_sample_line():
m, k, t = _empty_blocks()
m.available = True
m.regime = "NEUTRAL"
k.posts_24h = 14
k.bullish = 9
k.bearish = 1
k.divergences_24h = 1
k.sample_divergence = "ArthurHayes publicly bullish BTC, on-chain decreased"
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="🟡",
headline_text="1 KOL talks-vs-trades divergence today.")
out = format_digest(g)
assert "⚠️ ArthurHayes" in out
assert "9</b> bullish" in out
def test_pro_user_state_appended_for_wallet_bound():
m, k, t = _empty_blocks()
m.available = True
m.regime = "NEUTRAL"
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="", headline_text="Quiet day, nothing to act on.")
out = format_digest(g, user_state={
"wallet": "0xabc",
"auto_trade": True,
"trades_today": 2,
"open_pnl_summary": "1 open (+12.4 USD)",
})
assert "Your status" in out
assert "auto-trade ON" in out
assert "2 trades today" in out
assert "1 open (+12.4 USD)" in out
def test_pro_state_suppressed_for_free_user():
m, k, t = _empty_blocks()
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="", headline_text="Quiet day, nothing to act on.")
out_free = format_digest(g, user_state={})
out_no_wallet = format_digest(g, user_state={"wallet": None})
assert "Your status" not in out_free
assert "Your status" not in out_no_wallet
def test_html_safe_against_ampersand_in_label():
"""The 'F&G' label must always emit as F&amp;G so Telegram's HTML
parser doesn't reject the message."""
m, k, t = _empty_blocks()
m.available = True
m.regime = "NEUTRAL"
m.ahr999 = 0.62
m.fear_greed = 38
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="", headline_text="Quiet day, nothing to act on.")
out = format_digest(g)
assert "F&amp;G=38" in out
assert "F&G=38" not in out
def test_total_length_well_under_telegram_cap():
"""Even the busiest realistic day must fit in one Telegram message
(4096 chars)."""
m, k, t = _empty_blocks()
m.available = True
m.bottom_signal_firing = True
m.bottom_votes = 3
m.ahr999 = 0.41
m.fear_greed = 14
k.posts_24h = 30
k.bullish = 20
k.bearish = 8
k.divergences_24h = 4
k.sample_divergence = "Hayes publicly bullish ETH, on-chain decreased"
t.posts_24h = 12
t.actionable = [("BTC", "LONG", 95), ("SOL", "LONG", 88), ("ETH", "SHORT", 80)]
g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
headline_emoji="🟢", headline_text="Lots happening today.")
out = format_digest(g, user_state={
"wallet": "0xabc", "auto_trade": True, "trades_today": 4,
"open_pnl_summary": "3 open (+125.3 USD)",
})
assert len(out) < 4096