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:
k
2026-05-26 12:53:16 +08:00
parent fc735f251a
commit 3754d2caf8
14 changed files with 2162 additions and 42 deletions
+481 -9
View File
@@ -86,21 +86,29 @@ def _consume_code(code: str) -> Optional[str]:
HELP_TEXT = (
"👋 <b>Trump Alpha bot</b>\n\n"
"Push alerts for high-conviction crypto signals — Trump posts, BTC bottom "
"reversals, funding extremes, KOL divergence.\n\n"
"Push alerts for high-conviction crypto signals — Trump posts, "
"Macro Vibes bottom signal, funding extremes, KOL divergence. "
"Plus a once-a-day 3-section brief.\n\n"
"<b>Just press /start</b> — you're subscribed. No wallet needed.\n\n"
"<b>Configure:</b>\n"
"<b>Real-time alerts:</b>\n"
" /trump on|off Trump · Truth Social posts\n"
" /btc on|off BTC · macro bottom\n"
" /funding on|off BTC · funding reversal\n"
" /btc on|off Macro Vibes · bottom signal\n"
" /funding on|off Macro Vibes · funding reversal\n"
" /kol on|off KOL · talks vs trades\n"
" /conf 0-100 min AI confidence (Trump only)\n"
" /quiet 23 7 mute hours UTC (or 'off' to disable)\n\n"
"<b>Daily brief:</b>\n"
" /digest send me a preview right now\n"
" /digest on|off toggle the daily auto-send\n"
" /digest_time HH set the UTC hour (default 12)\n\n"
"<b>Status &amp; control:</b>\n"
" /status — show your preferences\n"
" /stop — pause all alerts\n"
" /test — send a sample alert\n\n"
"<b>Pro features</b> (auto-trade on Hyperliquid):\n"
"<b>Manage HL positions (Pro):</b>\n"
" /adopt hand an HL position to the bot\n"
" /release stop managing (HL position stays open)\n\n"
"<b>Pro features</b> (manage + auto-trade on Hyperliquid):\n"
"Open the dashboard → <i>Settings</i> → <i>Connect Telegram</i> to link "
"your wallet."
)
@@ -114,6 +122,10 @@ _DEFAULT_PREFS = {
"alert_funding": True,
"alert_kol_divergence": False,
"min_confidence": 70,
# Daily digest defaults — on at 12 UTC (Asia evening / EU afternoon /
# US morning; only single hour reasonable for all three zones).
"digest_enabled": True,
"digest_hour_utc": 12,
}
@@ -398,19 +410,266 @@ async def _cmd_status(chat_id: int) -> None:
mute = ""
if b.mute_from_hour is not None and b.mute_until_hour is not None:
mute = f"\n🌙 Quiet hours: {b.mute_from_hour:02d}{b.mute_until_hour:02d} UTC"
digest_state = (
f"🟢 ON @ {b.digest_hour_utc:02d}:00 UTC"
if b.digest_enabled else "🔴 OFF"
)
await send_message(
chat_id,
f"📡 <b>Status</b>\n\n"
f"{tier_line}\n"
f"Alerts: {on}\n"
f"Sources: {src_line}\n"
f"Min confidence: {b.min_confidence}{mute}\n\n"
f"Min confidence: {b.min_confidence}{mute}\n"
f"Daily brief: {digest_state}\n\n"
f"Sent: {b.total_alerts_sent} · Failed: {b.total_alerts_failed}\n\n"
f"Toggle anything with /trump /btc /funding /kol /conf /quiet "
f"send /help for the full list.",
f"Toggle anything with /trump /btc /funding /kol /conf /quiet "
f"/digest /digest_time — send /help for the full list.",
)
async def _cmd_digest(chat_id: int, arg: str) -> None:
"""Three dispatches in one command:
/digest → send a preview RIGHT NOW (manual fetch)
/digest on|off → toggle daily auto-send
Anything else → usage hint
Time changes go through /digest_time HH (separate command — easier to
parse and discover than overloading /digest with a numeric arg)."""
arg_l = arg.strip().lower()
if not arg_l:
from app.services.telegram_digest import send_preview_for
ok = await send_preview_for(chat_id)
if not ok:
await send_message(chat_id,
"Couldn't render a preview right now. Send /start first if "
"you haven't, otherwise try again in a minute.")
return
v = _parse_on_off(arg_l)
if v is None:
await send_message(chat_id,
"Usage:\n"
" <code>/digest</code> — preview right now\n"
" <code>/digest on</code> or <code>/digest off</code>\n"
" <code>/digest_time 14</code> — set send hour (UTC)")
return
ok = await _set_pref(chat_id, "digest_enabled", v)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
state = "🟢 ON" if v else "🔴 OFF"
await send_message(chat_id, f"{state} · daily brief")
async def _cmd_digest_time(chat_id: int, arg: str) -> None:
try:
h = int(arg.strip())
if not 0 <= h <= 23:
raise ValueError
except ValueError:
await send_message(chat_id,
"Usage: <code>/digest_time 14</code> (UTC hour, 023).\n"
"Example: <code>/digest_time 0</code> = midnight UTC.")
return
ok = await _set_pref(chat_id, "digest_hour_utc", h)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id,
f"⏰ Daily brief now lands at <b>{h:02d}:00 UTC</b>.")
async def _wallet_for_chat(chat_id: int) -> Optional[str]:
"""Return the bound wallet address for this chat_id, or None if the
chat hasn't linked one yet (free tier)."""
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
return b.wallet_address if (b and b.wallet_address) else None
async def _cmd_adopt(chat_id: int, arg: str) -> None:
"""List the user's HL open positions and let them pick one to hand to
the bot. Pro-only — needs a wallet bound (we read HL state via the
subscription's API key).
/adopt — show picker with inline keyboard
/adopt BTC — skip the picker, jump straight to mode pick for BTC
"""
wallet = await _wallet_for_chat(chat_id)
if not wallet:
await send_message(
chat_id,
"⚠️ <b>/adopt is Pro-only.</b>\n"
"It needs to read your Hyperliquid open positions, so the "
"Telegram chat must be linked to a wallet first.\n\n"
"Open the dashboard → Settings → Connect Telegram → paste the "
"code with <code>/start CODE</code>.",
)
return
from app.services.adoption import list_hl_positions, AdoptionError
try:
positions = await list_hl_positions(wallet)
except AdoptionError as exc:
await send_message(chat_id, f"{exc.message}")
return
if not positions:
await send_message(
chat_id,
"🟦 You have no open positions on Hyperliquid right now.\n\n"
"Open the trade on HL first, then come back and send /adopt.",
)
return
arg_u = arg.strip().upper()
if arg_u:
# Jump straight to mode picker for the requested asset.
match = next((p for p in positions if p.asset == arg_u), None)
if not match:
await send_message(
chat_id,
f"❌ No open {arg_u} position on Hyperliquid. "
f"Send /adopt with no args to see the list.",
)
return
await _send_mode_picker(chat_id, match)
return
# Build the asset picker.
rows: list[list[dict]] = []
for p in positions:
if p.already_adopted:
label = f"{p.asset} {p.side} ${p.size_usd:.0f} (already managed)"
# Disabled-looking row — still tap-able so they get feedback.
rows.append([{"text": label,
"callback_data": f"adopt:dup:{p.asset}"}])
else:
arrow = "🟢" if p.side == "long" else "🔴"
label = (f"{arrow} {p.asset} {p.side} "
f"${p.size_usd:.0f} @ {p.entry_price:.2f} · {p.leverage}x")
rows.append([{"text": label,
"callback_data": f"adopt:pick:{p.asset}"}])
rows.append([{"text": "✖ Cancel", "callback_data": "adopt:cancel"}])
await send_message(
chat_id,
"<b>Pick a position to hand to the bot:</b>\n\n"
"Bot will then run the 5-rung stop ladder + de-risk + pyramid + "
"peak-trail against it. You can release any time with "
"<code>/release ID</code>.",
reply_markup={"inline_keyboard": rows},
)
async def _send_mode_picker(chat_id: int, position) -> None:
"""After an asset is picked, prompt for standard vs aggressive mode."""
arrow = "🟢" if position.side == "long" else "🔴"
text = (
f"<b>{arrow} {position.asset} {position.side} "
f"${position.size_usd:.0f} @ {position.entry_price:.2f} · "
f"{position.leverage}x</b>\n\n"
"Pick the risk mode the bot should use:\n\n"
"<b>📈 Standard</b> — conservative ladder, longer hold, slower pyramid. "
"Designed to survive bull-cycle corrections.\n"
"<b>🚀 Aggressive</b> — earlier + bigger pyramid adds, wider peak-trail. "
"Bigger reward on a clean multi-x, harder shakeouts.\n"
)
rows = [
[{"text": "📈 Standard",
"callback_data": f"adopt:mode:{position.asset}:standard"}],
[{"text": "🚀 Aggressive",
"callback_data": f"adopt:mode:{position.asset}:aggressive"}],
[{"text": "✖ Cancel", "callback_data": "adopt:cancel"}],
]
await send_message(chat_id, text, reply_markup={"inline_keyboard": rows})
async def _cmd_release(chat_id: int, arg: str) -> None:
"""Hand a managed trade back to the user.
/release — show picker with all currently managed trades
/release 42 — release trade_id 42 directly
"""
wallet = await _wallet_for_chat(chat_id)
if not wallet:
await send_message(
chat_id,
"⚠️ /release is Pro-only — link a wallet first via "
"Settings → Connect Telegram.")
return
from app.models import BotTrade
async with async_session() as db:
managed = (await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
BotTrade.released_at.is_(None),
).order_by(BotTrade.opened_at.desc())
)).scalars().all()
if not managed:
await send_message(
chat_id,
"🟦 No positions currently under bot management. "
"Nothing to release.",
)
return
arg_s = arg.strip()
if arg_s:
try:
tid = int(arg_s)
except ValueError:
await send_message(chat_id,
"Usage: <code>/release 42</code> (trade id) — or just "
"<code>/release</code> to pick from a list.")
return
target = next((t for t in managed if t.id == tid), None)
if not target:
await send_message(chat_id,
f"❌ Trade {tid} isn't under bot management for this wallet.")
return
await _confirm_release(chat_id, target)
return
# Picker.
rows = []
for t in managed:
arrow = "🟢" if t.side == "long" else "🔴"
label = (f"{arrow} #{t.id} {t.asset} {t.side} "
f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}")
rows.append([{"text": label,
"callback_data": f"release:pick:{t.id}"}])
rows.append([{"text": "✖ Cancel", "callback_data": "release:cancel"}])
await send_message(
chat_id,
"<b>Release a position from bot management:</b>\n\n"
"The HL position stays open — bot just stops driving stops / "
"de-risk / pyramid on it.",
reply_markup={"inline_keyboard": rows},
)
async def _confirm_release(chat_id: int, trade) -> None:
arrow = "🟢" if trade.side == "long" else "🔴"
text = (
f"<b>{arrow} #{trade.id} {trade.asset} {trade.side} "
f"${(trade.size_usd or 0):.0f} @ {trade.entry_price:.2f}</b>\n\n"
"Release this trade from bot management?\n"
"The HL position will stay open — you take back manual control."
)
rows = [
[{"text": "✅ Yes, release",
"callback_data": f"release:yes:{trade.id}"}],
[{"text": "✖ Cancel", "callback_data": "release:cancel"}],
]
await send_message(chat_id, text, reply_markup={"inline_keyboard": rows})
async def _cmd_test(chat_id: int) -> None:
async with async_session() as db:
b = (await db.execute(
@@ -477,6 +736,16 @@ async def _handle_message(msg: dict) -> None:
await _cmd_conf(chat_id, arg)
elif cmd == "/quiet":
await _cmd_quiet(chat_id, arg)
# ── daily brief ──────────────────────────────────────────────
elif cmd == "/digest":
await _cmd_digest(chat_id, arg)
elif cmd in ("/digest_time", "/digesttime"):
await _cmd_digest_time(chat_id, arg)
# ── manage-only flow (System-2 reversal) ─────────────────────
elif cmd == "/adopt":
await _cmd_adopt(chat_id, arg)
elif cmd == "/release":
await _cmd_release(chat_id, arg)
else:
if cmd.startswith("/"):
await send_message(chat_id, "Unknown command. Send /help for the list.")
@@ -484,6 +753,202 @@ async def _handle_message(msg: dict) -> None:
logger.exception("Telegram handler failed for chat=%s text=%r", chat_id, text[:80])
async def _handle_callback(cb: dict) -> None:
"""Route inline-button presses. Callback data is a tiny colon-delimited
payload (max 64 bytes per Telegram). All branches MUST end with an
answerCallbackQuery — otherwise the user's button spins forever."""
from app.services.telegram import answer_callback, edit_message
from app.services.adoption import (
list_hl_positions, adopt_position, release_management, AdoptionError,
)
cb_id = cb.get("id")
data = cb.get("data") or ""
msg = cb.get("message") or {}
chat = msg.get("chat") or {}
chat_id = chat.get("id")
msg_id = msg.get("message_id")
if not (cb_id and chat_id and msg_id):
return
parts = data.split(":")
domain = parts[0] if parts else ""
try:
# ── ADOPT flow ──────────────────────────────────────────────────
if domain == "adopt":
sub = parts[1] if len(parts) > 1 else ""
if sub == "cancel":
await edit_message(chat_id, msg_id, "✖ Adopt cancelled.")
await answer_callback(cb_id)
return
if sub == "dup":
await answer_callback(cb_id,
"This position is already managed.",
show_alert=True)
return
if sub == "pick" and len(parts) == 3:
asset = parts[2]
wallet = await _wallet_for_chat(chat_id)
if not wallet:
await answer_callback(cb_id,
"Wallet not bound.", show_alert=True)
return
try:
positions = await list_hl_positions(wallet)
except AdoptionError as exc:
await edit_message(chat_id, msg_id, f"{exc.message}")
await answer_callback(cb_id)
return
target = next((p for p in positions if p.asset == asset), None)
if not target:
await edit_message(chat_id, msg_id,
f"❌ No open {asset} on HL anymore — refresh /adopt.")
await answer_callback(cb_id)
return
# Replace the picker with the mode picker in place.
arrow = "🟢" if target.side == "long" else "🔴"
text = (
f"<b>{arrow} {target.asset} {target.side} "
f"${target.size_usd:.0f} @ {target.entry_price:.2f} · "
f"{target.leverage}x</b>\n\n"
"Pick the risk mode:\n\n"
"<b>📈 Standard</b> — conservative, designed to survive "
"bull-cycle corrections.\n"
"<b>🚀 Aggressive</b> — earlier + bigger pyramid adds, "
"wider peak-trail. Higher reward, harder shakeouts.\n"
)
rows = [
[{"text": "📈 Standard",
"callback_data": f"adopt:mode:{asset}:standard"}],
[{"text": "🚀 Aggressive",
"callback_data": f"adopt:mode:{asset}:aggressive"}],
[{"text": "✖ Cancel", "callback_data": "adopt:cancel"}],
]
await edit_message(chat_id, msg_id, text,
reply_markup={"inline_keyboard": rows})
await answer_callback(cb_id)
return
if sub == "mode" and len(parts) == 4:
asset, mode = parts[2], parts[3]
wallet = await _wallet_for_chat(chat_id)
if not wallet:
await answer_callback(cb_id,
"Wallet not bound.", show_alert=True)
return
try:
result = await adopt_position(wallet, asset, mode)
except AdoptionError as exc:
await edit_message(chat_id, msg_id,
f"{exc.message}")
await answer_callback(cb_id, exc.code, show_alert=False)
return
# Render success summary — mention the de-risk + ladder so
# the user understands what the bot will actually DO.
derisk_summary = "".join(
f"{thr:.0f}% close {int(frac * 100)}%"
for thr, frac, _ in (result.derisk_ladder or [])
)
ladder_summary = "".join(
f"peak {trig:.0f}% lock {floor:+.0f}%"
for trig, floor in (result.stop_ladder or [])
)
text = (
f"✅ <b>Adopted #{result.trade_id} · {result.asset} "
f"{result.side} · {result.leverage}x · "
f"{result.mode}</b>\n\n"
f"Entry: <code>{result.entry_price:.2f}</code> · "
f"Size: <code>${result.size_usd:.0f}</code>\n"
f"Protective stop: <b>-{result.protective_stop:.1f}%</b> "
f"(inside HL liquidation)\n\n"
f"<i>De-risk ladder (underwater):</i>\n{derisk_summary}\n\n"
f"<i>Stop ratchet (in profit):</i>\n{ladder_summary}\n\n"
f"Release any time: <code>/release {result.trade_id}</code>"
)
await edit_message(chat_id, msg_id, text)
await answer_callback(cb_id, "Adopted ✓")
return
# ── RELEASE flow ────────────────────────────────────────────────
if domain == "release":
sub = parts[1] if len(parts) > 1 else ""
if sub == "cancel":
await edit_message(chat_id, msg_id, "✖ Release cancelled.")
await answer_callback(cb_id)
return
if sub == "pick" and len(parts) == 3:
try:
tid = int(parts[2])
except ValueError:
await answer_callback(cb_id, "Bad trade id",
show_alert=True)
return
# Refetch + render the confirm sheet in place.
from app.models import BotTrade
async with async_session() as db:
t = (await db.execute(
select(BotTrade).where(BotTrade.id == tid)
)).scalar_one_or_none()
if t is None or t.closed_at is not None or t.released_at is not None:
await edit_message(chat_id, msg_id,
f"❌ Trade {tid} no longer managed.")
await answer_callback(cb_id)
return
arrow = "🟢" if t.side == "long" else "🔴"
text = (
f"<b>{arrow} #{t.id} {t.asset} {t.side} "
f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}</b>\n\n"
"Release this trade from bot management?\n"
"The HL position will stay open — you take back manual "
"control."
)
rows = [
[{"text": "✅ Yes, release",
"callback_data": f"release:yes:{tid}"}],
[{"text": "✖ Cancel", "callback_data": "release:cancel"}],
]
await edit_message(chat_id, msg_id, text,
reply_markup={"inline_keyboard": rows})
await answer_callback(cb_id)
return
if sub == "yes" and len(parts) == 3:
try:
tid = int(parts[2])
except ValueError:
await answer_callback(cb_id, "Bad trade id",
show_alert=True)
return
wallet = await _wallet_for_chat(chat_id)
if not wallet:
await answer_callback(cb_id, "Wallet not bound.",
show_alert=True)
return
try:
res = await release_management(wallet, tid)
except AdoptionError as exc:
await edit_message(chat_id, msg_id,
f"{exc.message}")
await answer_callback(cb_id)
return
await edit_message(chat_id, msg_id,
f"✅ <b>Released #{res['trade_id']} · {res['asset']}</b>\n\n"
"Bot is no longer managing this position. "
"HL state is untouched — you have manual control.\n\n"
"Re-adopt any time with /adopt.")
await answer_callback(cb_id, "Released ✓")
return
# Unknown callback — just ack so the button doesn't spin.
await answer_callback(cb_id)
except Exception:
logger.exception("Telegram callback handler failed for data=%r", data)
try:
await answer_callback(cb_id, "Internal error", show_alert=True)
except Exception:
pass
async def run_bot_loop() -> None:
"""Long-poll forever. Idempotent on offsets — Telegram serves the same
update twice only if we don't acknowledge it. Sleep on errors to avoid
@@ -517,6 +982,13 @@ async def run_bot_loop() -> None:
msg = upd.get("message") or upd.get("edited_message")
if msg:
await _handle_message(msg)
continue
cb = upd.get("callback_query")
if cb:
# Inline-keyboard button press (/adopt and /release
# picker flows). Acknowledged inside the handler so the
# button never spins forever on the user's client.
await _handle_callback(cb)
except asyncio.CancelledError:
logger.info("Telegram bot loop cancelled.")
raise