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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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&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&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
|
||||
Reference in New Issue
Block a user