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
+172
View File
@@ -158,10 +158,15 @@ async def get_open_positions(
body=None,
allow_replay=True,
)
# Exclude released trades — those rows are "closed_at IS NULL" but the
# user has explicitly taken back manual control of the HL position. The
# bot is no longer managing them, so they shouldn't appear in the live
# positions tile (which would suggest the bot is still driving stops).
rows = 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())
)
trades = rows.scalars().all()
@@ -203,10 +208,13 @@ async def get_today_stats(
)
closed = closed_rows.scalars().all()
# See /positions/open: released trades aren't actively managed by the
# bot, so they shouldn't inflate the open-count tile.
open_rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
BotTrade.released_at.is_(None),
)
)
open_trades = open_rows.scalars().all()
@@ -234,6 +242,8 @@ async def get_today_stats(
ACTION_CLOSE_TRADE = "close_trade"
ACTION_SET_GROW = "set_trade_grow"
ACTION_VIEW_POSITIONS = "view_positions"
ACTION_ADOPT_POSITION = "adopt_position"
ACTION_RELEASE_TRADE = "release_trade"
class CloseTradeResponse(BaseModel):
@@ -390,3 +400,165 @@ async def set_trade_grow(
logger.info("Grow %s for trade %d by %s",
"ON" if enabled else "OFF", trade_id, wallet)
return GrowResponse(trade_id=trade_id, grow_mode=enabled)
# ─── Adopt / release (System-2 manage-only flow) ────────────────────────────
class HLPositionItem(BaseModel):
asset: str
side: str
size_coins: float
entry_price: float
leverage: int
size_usd: float
already_adopted: bool
class HLPositionsResponse(BaseModel):
wallet: str
count: int
positions: list[HLPositionItem]
@router.get("/positions/hl/{wallet}", response_model=HLPositionsResponse)
async def list_hl_positions_endpoint(wallet: str):
"""Read the wallet's CURRENT Hyperliquid open positions, annotated with
'already adopted' flag. Used by the Adopt picker on the frontend and by
the Telegram /adopt command.
No auth — same trust model as /positions/open (wallet address IS the
access key). Reading HL state is read-only and harmless.
"""
from app.services.adoption import list_hl_positions, AdoptionError
try:
items = await list_hl_positions(wallet)
except AdoptionError as exc:
raise HTTPException(400, exc.message)
return HLPositionsResponse(
wallet=wallet.lower().strip(),
count=len(items),
positions=[HLPositionItem(**vars(it)) for it in items],
)
class AdoptResponse(BaseModel):
status: str
trade_id: int
asset: str
side: str
entry_price: float
size_usd: float
leverage: int
mode: str
protective_stop: float
@router.post("/positions/adopt", response_model=AdoptResponse)
async def adopt_position_endpoint(request: Request,
db: AsyncSession = Depends(get_db)):
"""Hand a manually-opened HL position to the bot.
Body: { wallet, timestamp, signature, asset, mode }
asset : e.g. "BTC". Case-insensitive.
mode : "standard" | "aggressive" — picks ladder spacing.
On success: a BotTrade row is created, sys2 stop ladder + de-risk +
pyramid + peak-trail registered with the live watchdog. From that
moment, the bot drives the trade.
Failure surfaces an AdoptionError mapped to a 400 with .code in the
response body so the UI can switch on it (e.g. 'already_adopted',
'concurrency_cap', 'not_on_hl').
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
asset = (raw.get("asset") or "").upper().strip()
mode = (raw.get("mode") or "standard").strip()
if not wallet:
raise HTTPException(422, "wallet required")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if not asset:
raise HTTPException(422, "asset required (e.g. 'BTC')")
if mode not in ("standard", "aggressive"):
raise HTTPException(422, "mode must be 'standard' or 'aggressive'")
verify_signed_request(
action=ACTION_ADOPT_POSITION,
wallet=wallet, timestamp_ms=timestamp, signature=signature,
body={"asset": asset, "mode": mode},
)
from app.services.adoption import adopt_position, AdoptionError
try:
result = await adopt_position(wallet, asset, mode)
except AdoptionError as exc:
# 409 for state conflicts (already adopted / concurrency), 400 else.
status = 409 if exc.code in (
"already_adopted", "concurrency_cap", "already_closed",
) else 400
raise HTTPException(status, {"code": exc.code, "message": exc.message})
return AdoptResponse(
status="ok",
trade_id=result.trade_id,
asset=result.asset, side=result.side,
entry_price=result.entry_price, size_usd=result.size_usd,
leverage=result.leverage, mode=result.mode,
protective_stop=result.protective_stop,
)
class ReleaseResponse(BaseModel):
status: str
trade_id: int
asset: str
already_released: bool
@router.post("/positions/{trade_id}/release", response_model=ReleaseResponse)
async def release_trade_endpoint(trade_id: int, request: Request):
"""Hand control of trade_id back to the user.
Body: { wallet, timestamp, signature, trade_id }
Marks released_at on the BotTrade row, unregisters the watchdog.
The HL position itself is NOT touched — it keeps running under the
user's manual control. Idempotent: releasing a released trade returns
success.
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
body_tid = raw.get("trade_id")
if not wallet:
raise HTTPException(422, "wallet required")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if body_tid != trade_id:
raise HTTPException(400, "trade_id mismatch (path vs signed body)")
verify_signed_request(
action=ACTION_RELEASE_TRADE,
wallet=wallet, timestamp_ms=timestamp, signature=signature,
body={"trade_id": trade_id},
)
from app.services.adoption import release_management, AdoptionError
try:
result = await release_management(wallet, trade_id)
except AdoptionError as exc:
status = 404 if exc.code == "not_found" else (
403 if exc.code == "not_owner" else 400)
raise HTTPException(status, {"code": exc.code, "message": exc.message})
return ReleaseResponse(**result)