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
+88 -8
View File
@@ -128,7 +128,21 @@ def format_post(post: Post) -> str:
}.get(post.source, "/en")
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
msg = f"{head}\n{sub}\n\n{body}{extra}{link}"
# System-2 (Macro Vibes bottom / funding reversal) is MANUAL-OPEN now:
# we no longer auto-trade these on behalf of subscribers. The signal is
# day-K level so an entry delay doesn't matter — but the alert needs
# to make the next user action obvious.
cta = ""
if post.source in ("btc_bottom_reversal", "funding_reversal"):
cta = (
"\n\n<b>Want bot to manage your trade?</b>\n"
"1. Open the position on Hyperliquid (your size / leverage).\n"
"2. Reply <code>/adopt</code> here to hand it to the bot.\n"
"Bot will run the 5-rung stop ladder, de-risk, pyramid, peak-trail. "
"You can take it back any time with <code>/release</code>."
)
msg = f"{head}\n{sub}\n\n{body}{extra}{cta}{link}"
return msg[:MAX_LEN]
@@ -137,20 +151,33 @@ def format_post(post: Post) -> str:
async def send_message(chat_id: int, text: str, *,
parse_mode: str = "HTML",
disable_preview: bool = True) -> bool:
disable_preview: bool = True,
reply_markup: Optional[dict] = None) -> bool:
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
any failure (caller decides whether to bump the failure counter)."""
any failure (caller decides whether to bump the failure counter).
`reply_markup` is the standard Bot-API inline keyboard / reply keyboard
payload, e.g.
{"inline_keyboard": [
[{"text": "Yes", "callback_data": "x:y"}],
[{"text": "No", "callback_data": "x:n"}],
]}
Callback_data is limited to 64 bytes by Telegram; keep payloads short.
"""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="sendMessage")
payload: dict = {
"chat_id": chat_id, "text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_preview,
}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json={
"chat_id": chat_id, "text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_preview,
})
r = await client.post(url, json=payload)
if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s",
chat_id, r.status_code, r.text[:200])
@@ -161,6 +188,59 @@ async def send_message(chat_id: int, text: str, *,
return False
async def edit_message(chat_id: int, message_id: int, text: str, *,
parse_mode: str = "HTML",
reply_markup: Optional[dict] = None) -> bool:
"""Edit an existing message (typically to swap out an inline keyboard
after the user pressed a button). Returns True on 200."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="editMessageText")
payload: dict = {
"chat_id": chat_id, "message_id": message_id, "text": text,
"parse_mode": parse_mode,
}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload)
if r.status_code != 200:
# Telegram returns 400 on "message is not modified" — harmless.
if "message is not modified" not in r.text:
logger.warning("Telegram editMessageText failed chat=%d msg=%d "
"status=%d body=%s",
chat_id, message_id, r.status_code, r.text[:200])
return False
return True
except Exception as exc:
logger.warning("Telegram editMessageText exception: %s", exc)
return False
async def answer_callback(callback_query_id: str, text: str = "",
show_alert: bool = False) -> bool:
"""Acknowledge an inline-button press. MUST be called within 30s of the
callback firing or the button stays in 'loading' state on the user's
client. Empty text → silent ack."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="answerCallbackQuery")
payload = {"callback_query_id": callback_query_id}
if text:
payload["text"] = text
payload["show_alert"] = show_alert
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(url, json=payload)
return True
except Exception as exc:
logger.debug("Telegram answerCallback exception: %s", exc)
return False
# ── Dispatcher ────────────────────────────────────────────────────────────