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:
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user