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