diff --git a/alembic/versions/023_telegram_digest_prefs.py b/alembic/versions/023_telegram_digest_prefs.py
new file mode 100644
index 0000000..a9497f0
--- /dev/null
+++ b/alembic/versions/023_telegram_digest_prefs.py
@@ -0,0 +1,53 @@
+"""Telegram per-user digest preferences.
+
+Adds three columns to telegram_bindings so each subscriber can opt in/out
+of the daily three-section brief (Macro Vibes / KOL / Trump) and pick
+which UTC hour it lands.
+
+ digest_enabled : bool, default True. Toggleable via /digest on|off.
+ digest_hour_utc : int 0–23, default 12. Toggleable via /digest_time HH.
+ 12 UTC ≈ Asia evening / EU afternoon / US morning —
+ the only single hour that's reasonable for all three
+ big crypto-active zones, so it's our default.
+ last_digest_sent_at : nullable timestamp. Guards against double-send if the
+ cron coalesces or the worker restarts mid-firing.
+ Used as "skip if < 23h ago" in send_daily_digest.
+
+Backfills are safe: every existing binding gets digest_enabled=True so users
+who subscribed before this feature start receiving the brief automatically.
+If they don't want it, one /digest off keeps them off.
+
+Revision ID: 023
+Revises: 022
+Create Date: 2026-05-26
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "023"
+down_revision = "022"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("telegram_bindings") as batch:
+ batch.add_column(sa.Column(
+ "digest_enabled", sa.Boolean, nullable=False,
+ server_default=sa.true(),
+ ))
+ batch.add_column(sa.Column(
+ "digest_hour_utc", sa.Integer, nullable=False,
+ server_default="12",
+ ))
+ batch.add_column(sa.Column(
+ "last_digest_sent_at", sa.DateTime, nullable=True,
+ ))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("telegram_bindings") as batch:
+ batch.drop_column("last_digest_sent_at")
+ batch.drop_column("digest_hour_utc")
+ batch.drop_column("digest_enabled")
diff --git a/alembic/versions/024_bot_trades_released_at.py b/alembic/versions/024_bot_trades_released_at.py
new file mode 100644
index 0000000..33eb93f
--- /dev/null
+++ b/alembic/versions/024_bot_trades_released_at.py
@@ -0,0 +1,44 @@
+"""BotTrade.released_at — user 'release from bot management' marker.
+
+Adds a single nullable timestamp column. When set:
+ - recovery.py rehydration SKIPS this row (no watchdog re-register on
+ restart) — the bot has stopped managing this position
+ - reconciler / close paths don't touch it
+ - the HL position itself is NOT closed; the user has taken back manual
+ control of the open trade
+
+This is the escape hatch for "adopt → bot manages → user wants control back
+without closing". The bot stops driving but the HL position keeps running
+under the user's own decisions.
+
+Distinct from `closed_at`: a closed trade has a known exit_price/pnl and is
+finalised. A released trade has neither — it's still open on HL, just no
+longer in our watchdog.
+
+Backfill: every existing row gets released_at = NULL. No semantic change to
+any historical or already-open trade.
+
+Revision ID: 024
+Revises: 023
+Create Date: 2026-05-26
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "024"
+down_revision = "023"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("bot_trades") as batch:
+ batch.add_column(sa.Column(
+ "released_at", sa.DateTime, nullable=True,
+ ))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("bot_trades") as batch:
+ batch.drop_column("released_at")
diff --git a/app/api/positions.py b/app/api/positions.py
index 42a3445..ff9e5c7 100644
--- a/app/api/positions.py
+++ b/app/api/positions.py
@@ -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)
diff --git a/app/main.py b/app/main.py
index e40b9fc..538d694 100644
--- a/app/main.py
+++ b/app/main.py
@@ -209,6 +209,22 @@ async def lifespan(app: FastAPI):
type(exc).__name__, exc)
asyncio.create_task(_macro_bootstrap())
+ # ── Telegram daily digest ─────────────────────────────────────────────
+ # Runs every hour at :00. Each binding picks its own digest_hour_utc
+ # (0–23, default 12); the sender only fans out to rows whose preferred
+ # hour matches the current one — so we get one cron job covering all 24
+ # delivery slots. The send function itself dedupes against
+ # last_digest_sent_at (skips if < 23h), so a coalesced cron or worker
+ # restart can't double-send a user in the same day.
+ from app.services.telegram_digest import send_daily_digest
+ _scheduler.add_job(
+ send_daily_digest, "cron", minute=0,
+ id="telegram_daily_digest",
+ max_instances=1, coalesce=True,
+ )
+ logger.info("Telegram daily digest scheduled hourly at :00 UTC "
+ "(per-user hour selection).")
+
_scheduler.start()
logger.info(
"Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).",
diff --git a/app/models.py b/app/models.py
index 5332714..e81ac3f 100644
--- a/app/models.py
+++ b/app/models.py
@@ -98,6 +98,12 @@ class BotTrade(Base):
wallet_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
+ # User-initiated "stop managing" marker. When set: recovery skips
+ # re-register on restart, monitor stops driving, HL position keeps
+ # running under the user's own control. NOT the same as closed_at —
+ # there's no exit_price / pnl on a released row. Used by /release in
+ # the adopt-then-release flow for System-2 positions.
+ released_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
hl_order_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
# Snapshot of user settings AT TIME OF ENTRY. Required so changes to
# Subscription.position_size_usd / leverage after-the-fact don't corrupt
@@ -414,6 +420,14 @@ class TelegramBinding(Base):
total_alerts_sent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_alerts_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ # Daily 3-section digest (Macro / KOL / Trump). Independent of the
+ # per-signal alerts above — a user can disable all real-time alerts but
+ # still want the once-a-day overview, or vice versa. See migration 023
+ # and app/services/telegram_digest.py.
+ digest_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ digest_hour_utc: Mapped[int] = mapped_column(Integer, nullable=False, default=12)
+ last_digest_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
+
class MacroSnapshot(Base):
"""Daily snapshot of all macro indicators surfaced on the BTC page.
diff --git a/app/services/adoption.py b/app/services/adoption.py
new file mode 100644
index 0000000..d4b1f54
--- /dev/null
+++ b/app/services/adoption.py
@@ -0,0 +1,439 @@
+"""Adopt / release flow for System-2 manage-only positions.
+
+The user opens a position MANUALLY on Hyperliquid (any size / leverage).
+They then ask the bot to manage it — either via the dashboard adoption
+button or the Telegram /adopt command. From that moment on, tp_sl_monitor
+runs the full System-2 lifecycle against it: 5-rung stop ladder, downside
+de-risk, pyramid, peak-trail, full close.
+
+`release` is the escape hatch. It marks released_at on the BotTrade row,
+unregisters the watchdog, and leaves the HL position untouched — the user
+takes back manual control without closing.
+
+The flow is intentionally LIGHT:
+ - We don't validate signal timing (any HL position can be adopted)
+ - We don't enforce direction (both long and short work; ladder is signed)
+ - We DO enforce the same concurrency cap as the old auto-open path
+ (SYS2_MAX_CONCURRENT) — adopting unlimited correlated crypto-beta is
+ the same disaster regardless of who pulled the trigger
+ - We DO block paper-mode subscribers (paper has no HL state to read)
+
+Race notes:
+ - Adopt creates the BotTrade row, then register_trade. If a price tick
+ arrives between those two events, no harm — register_trade is
+ idempotent on trade_id.
+ - Two concurrent adopts on the same wallet+asset are guarded by the
+ "already adopted?" check inside the wallet-keyed asyncio lock reused
+ from bot_engine.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Dict, Optional
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import AsyncSessionLocal
+from app.models import BotTrade, Subscription
+from app.config import settings
+from app.services.crypto import decrypt_api_key
+from app.services.hyperliquid import HyperliquidTrader
+from app.services.signal_categories import (
+ get_exit_profile, get_stop_ladder,
+ sys2_normalize_mode, sys2_protective_stop_pct,
+ sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
+ SYS2_MAX_CONCURRENT, SYS2_MODES,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Default category — the System-2 strategy is BTC bottom-reversal long.
+# An adopted short or non-BTC asset still gets the same generous ladder
+# (it's direction-agnostic), but we tag the category for telemetry.
+ADOPTED_CATEGORY = "btc_bottom_reversal_long"
+
+
+# Per-wallet asyncio lock to serialise adopt_position calls for the same
+# wallet. Without this, two concurrent /adopt requests on the same wallet
+# can both pass the duplicate-check and end up writing two BotTrade rows
+# pointing at the same HL position — corrupting the ladder / de-risk math
+# (each row would race to reduce the position). Process-local; with the
+# atomic duplicate check inside the lock, multi-process deploys are still
+# safe (losers would just see "already_adopted" on the second attempt).
+_adopt_locks: Dict[str, asyncio.Lock] = {}
+
+
+def _wallet_adopt_lock(wallet: str) -> asyncio.Lock:
+ lock = _adopt_locks.get(wallet)
+ if lock is None:
+ lock = asyncio.Lock()
+ _adopt_locks[wallet] = lock
+ return lock
+
+
+# ── Errors ──────────────────────────────────────────────────────────────────
+
+
+class AdoptionError(Exception):
+ """User-facing adoption failure. .code is a stable short token suitable
+ for switching on in the API / Telegram layers; .message is the
+ human-readable text."""
+ def __init__(self, code: str, message: str):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+
+
+# ── Reads ───────────────────────────────────────────────────────────────────
+
+
+@dataclass
+class HLPositionView:
+ """Lightweight projection of a Hyperliquid open position, for adoption
+ pickers. Kept thin so the UI / Telegram layers don't depend on the
+ HL SDK's dict shape."""
+ asset: str
+ side: str # "long" | "short"
+ size_coins: float # absolute (always positive)
+ entry_price: float
+ leverage: int
+ size_usd: float # size_coins * entry_price
+ already_adopted: bool # there's a BotTrade open + not released for it
+
+
+async def list_hl_positions(wallet: str) -> list[HLPositionView]:
+ """Read the wallet's current HL open positions, annotated with whether
+ each is already under bot management. Raises AdoptionError on missing
+ subscription / missing key / HL read failure."""
+ wallet_l = wallet.lower().strip()
+ async with AsyncSessionLocal() as db:
+ sub = (await db.execute(
+ select(Subscription).where(Subscription.wallet_address == wallet_l)
+ )).scalar_one_or_none()
+ if sub is None:
+ raise AdoptionError("no_subscription",
+ "Wallet not subscribed. Open Settings on the dashboard first.")
+ if not sub.hl_api_key:
+ raise AdoptionError("no_hl_key",
+ "This wallet has no Hyperliquid API key on file. "
+ "Add one in Settings before adopting positions.")
+ # Adopted positions only — closed/released rows don't block re-adopt.
+ existing = (await db.execute(
+ select(BotTrade.asset).where(
+ BotTrade.wallet_address == wallet_l,
+ BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
+ )
+ )).scalars().all()
+ adopted_assets = {a.upper() for a in existing}
+
+ try:
+ api_key = decrypt_api_key(sub.hl_api_key)
+ except Exception as exc:
+ raise AdoptionError("key_decrypt_failed",
+ f"Could not decrypt the HL API key: {exc}")
+
+ trader = HyperliquidTrader(
+ api_private_key=api_key,
+ account_address=wallet_l,
+ leverage=int(sub.leverage or 2),
+ mainnet=settings.hl_mainnet,
+ )
+ try:
+ raw = await trader.get_open_positions()
+ except Exception as exc:
+ raise AdoptionError("hl_read_failed",
+ f"Could not read HL positions: {exc}")
+
+ out: list[HLPositionView] = []
+ for p in raw:
+ coin = (p.get("coin") or "").upper()
+ szi = float(p.get("szi") or 0.0)
+ entry = float(p.get("entry_px") or 0.0)
+ lev_info = p.get("leverage") or {}
+ # HL position.leverage shape is {"type": "isolated"|"cross", "value": N}
+ lev = int(lev_info.get("value") or sub.leverage or 2)
+ if szi == 0 or entry <= 0:
+ continue
+ out.append(HLPositionView(
+ asset = coin,
+ side = "long" if szi > 0 else "short",
+ size_coins = abs(szi),
+ entry_price = entry,
+ leverage = lev,
+ size_usd = round(abs(szi) * entry, 2),
+ already_adopted = coin in adopted_assets,
+ ))
+ return out
+
+
+# ── Adopt ───────────────────────────────────────────────────────────────────
+
+
+@dataclass
+class AdoptionResult:
+ trade_id: int
+ asset: str
+ side: str
+ entry_price: float
+ size_usd: float
+ leverage: int
+ mode: str
+ protective_stop: float # positive % distance
+ derisk_ladder: list # the rungs we just registered
+ stop_ladder: list # the upside ratchet rungs
+
+
+async def adopt_position(wallet: str, asset: str, mode: str) -> AdoptionResult:
+ """Read the user's current HL position for `asset`, create a BotTrade
+ row with the System-2 ladder for `mode`, and register it with the
+ watchdog. Raises AdoptionError on any failure path.
+
+ Wallet-level locking serialises concurrent adopts for the same wallet
+ so the duplicate / concurrency-cap checks below are race-free.
+ """
+ wallet_l = wallet.lower().strip()
+ asset_u = asset.upper().strip()
+ mode_n = sys2_normalize_mode(mode)
+ if mode_n not in SYS2_MODES:
+ raise AdoptionError("bad_mode",
+ f"mode must be one of {SYS2_MODES}; got {mode!r}")
+
+ async with _wallet_adopt_lock(wallet_l):
+ return await _adopt_locked(wallet_l, asset_u, mode_n)
+
+
+async def _adopt_locked(wallet_l: str, asset_u: str, mode_n: str) -> AdoptionResult:
+ async with AsyncSessionLocal() as db:
+ sub = (await db.execute(
+ select(Subscription).where(Subscription.wallet_address == wallet_l)
+ )).scalar_one_or_none()
+ if sub is None:
+ raise AdoptionError("no_subscription",
+ "Wallet not subscribed. Open Settings on the dashboard first.")
+ if not sub.hl_api_key:
+ raise AdoptionError("no_hl_key",
+ "This wallet has no Hyperliquid API key on file.")
+ # Paper-mode wallets have no real HL position to manage. The HL read
+ # below would return empty and surface a confusing "not_on_hl" error
+ # — better to reject explicitly with a clear cause up front.
+ if getattr(sub, "paper_mode", False):
+ raise AdoptionError("paper_mode",
+ "Adopt isn't available in paper mode — paper trades have "
+ "no Hyperliquid position to manage. Turn off paper mode in "
+ "Settings to use /adopt.")
+
+ # System-2 circuit breaker. Same gate the auto-open path used to run:
+ # if recent losses tripped the sys2 breaker, block new adoptions for
+ # the lockout window. Otherwise the breaker would be useless under
+ # the manage-only model.
+ from app.services.circuit_breaker import is_tripped as _cb_tripped
+ sub_snapshot = {
+ "sys2_cb_tripped_at": sub.sys2_cb_tripped_at,
+ "sys2_cb_reason": sub.sys2_cb_reason,
+ }
+ tripped, cb_msg = _cb_tripped(sub_snapshot, "sys2")
+ if tripped:
+ raise AdoptionError("circuit_breaker",
+ f"Adopt blocked: {cb_msg}. Turning Auto-Trade ON or arming "
+ "Manual Window in Settings clears the breaker.")
+
+ # Already managing this wallet+asset?
+ dup = (await db.execute(
+ select(BotTrade).where(
+ BotTrade.wallet_address == wallet_l,
+ BotTrade.asset == asset_u,
+ BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
+ )
+ )).scalar_one_or_none()
+ if dup is not None:
+ raise AdoptionError("already_adopted",
+ f"{asset_u} is already managed by the bot (trade #{dup.id}). "
+ "Send /release first if you want to re-adopt.")
+
+ # Wallet-level concurrency cap. Same reasoning as the auto-open
+ # path: every System-2 asset is correlated crypto-beta and 3
+ # simultaneous positions ≈ 1 leveraged macro bet, not 3 bets.
+ open_count = (await db.execute(
+ select(BotTrade).where(
+ BotTrade.wallet_address == wallet_l,
+ BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
+ )
+ )).scalars().all()
+ if len(open_count) >= SYS2_MAX_CONCURRENT:
+ raise AdoptionError("concurrency_cap",
+ f"You already have {len(open_count)} managed positions "
+ f"(cap is {SYS2_MAX_CONCURRENT}). /release one first.")
+
+ try:
+ api_key = decrypt_api_key(sub.hl_api_key)
+ except Exception as exc:
+ raise AdoptionError("key_decrypt_failed",
+ f"Could not decrypt the HL API key: {exc}")
+
+ trader = HyperliquidTrader(
+ api_private_key=api_key,
+ account_address=wallet_l,
+ leverage=int(sub.leverage or 2),
+ mainnet=settings.hl_mainnet,
+ )
+ try:
+ raw = await trader.get_open_positions()
+ except Exception as exc:
+ raise AdoptionError("hl_read_failed",
+ f"Could not read HL positions: {exc}")
+
+ target = next((p for p in raw if (p.get("coin") or "").upper() == asset_u),
+ None)
+ if target is None:
+ raise AdoptionError("not_on_hl",
+ f"No open {asset_u} position found on Hyperliquid. "
+ "Make sure you've filled the order, then try again.")
+
+ szi = float(target.get("szi") or 0.0)
+ entry = float(target.get("entry_px") or 0.0)
+ if szi == 0 or entry <= 0:
+ raise AdoptionError("bad_hl_state",
+ f"HL returned a zero-size or zero-price {asset_u} position. "
+ "Refresh and try again in a moment.")
+
+ side = "long" if szi > 0 else "short"
+ size_coins = abs(szi)
+ size_usd = round(size_coins * entry, 2)
+ lev_info = target.get("leverage") or {}
+ leverage = int(lev_info.get("value") or sub.leverage or 2)
+
+ # Build the System-2 exit profile against the ACTUAL HL leverage so the
+ # protective stop is always inside the real liquidation line. Identical
+ # math to bot_engine's old sys2 open path; reused so behaviour stays
+ # consistent across the two entry points.
+ exit_profile = get_exit_profile(ADOPTED_CATEGORY)
+ prot_stop = sys2_protective_stop_pct(leverage)
+ derisk = sys2_derisk_ladder(leverage, mode_n)
+ addon = sys2_addon_ladder(mode_n)
+ peak_trail = sys2_peak_trail(mode_n)
+ stop_ladder = get_stop_ladder(ADOPTED_CATEGORY)
+
+ now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
+ async with AsyncSessionLocal() as db:
+ trade = BotTrade(
+ asset=asset_u,
+ side=side,
+ entry_price=entry,
+ wallet_address=wallet_l,
+ trigger_post_id=None, # adopted, not signal-triggered
+ opened_at=now_naive,
+ hl_order_id=f"adopted:{int(now_naive.timestamp())}",
+ size_usd=size_usd,
+ base_size_usd=size_usd,
+ sys2_mode=mode_n,
+ leverage=leverage,
+ # Freeze the System-2 exit profile on the row. No TP / no
+ # trailing-from-peak — pure staged stop + de-risk + peak-trail.
+ eff_take_profit_pct = None,
+ eff_stop_loss_pct = prot_stop,
+ eff_trailing_stop_pct = None,
+ eff_trailing_activate_pct = None,
+ eff_max_hold_hours = exit_profile.max_hold_hours,
+ eff_invalidation = None,
+ eff_invalidation_price = None,
+ eff_min_hold_until_ts = None,
+ )
+ db.add(trade)
+ await db.commit()
+ await db.refresh(trade)
+ trade_id = trade.id
+
+ # Register with the watchdog AFTER the row is committed so a tick fired
+ # in the next millisecond can find the trade on disk if it needs to.
+ from app.services.tp_sl_monitor import register_trade
+ register_trade(
+ trade_id=trade_id,
+ wallet=wallet_l,
+ api_key=api_key,
+ leverage=leverage,
+ asset=asset_u,
+ side=side,
+ entry_price=entry,
+ take_profit_pct=None,
+ stop_loss_pct=prot_stop,
+ trailing_stop_pct=None,
+ trailing_activate_at_pct=None,
+ invalidation=None,
+ invalidation_price=None,
+ min_hold_until_ts=None,
+ stop_ladder=stop_ladder,
+ derisk_ladder=derisk,
+ derisk_done=0,
+ addon_ladder=addon,
+ addon_done=0,
+ peak_trail=peak_trail,
+ grow_mode=False, # user opts a position into Grow explicitly
+ )
+
+ logger.warning(
+ "ADOPTED trade %d: wallet=%s asset=%s side=%s entry=%.4f size=%.2f "
+ "lev=%dx mode=%s prot_stop=%.2f%% derisk=%s",
+ trade_id, wallet_l, asset_u, side, entry, size_usd,
+ leverage, mode_n, prot_stop, derisk,
+ )
+
+ return AdoptionResult(
+ trade_id=trade_id, asset=asset_u, side=side, entry_price=entry,
+ size_usd=size_usd, leverage=leverage, mode=mode_n,
+ protective_stop=prot_stop, derisk_ladder=derisk,
+ stop_ladder=stop_ladder or [],
+ )
+
+
+# ── Release ─────────────────────────────────────────────────────────────────
+
+
+async def release_management(wallet: str, trade_id: int) -> dict:
+ """Stop managing trade_id without closing the HL position. Marks
+ released_at, unregisters the watchdog. Idempotent — re-releasing a
+ released trade is a no-op."""
+ wallet_l = wallet.lower().strip()
+ async with AsyncSessionLocal() as db:
+ trade = (await db.execute(
+ select(BotTrade).where(BotTrade.id == trade_id)
+ )).scalar_one_or_none()
+ if trade is None:
+ raise AdoptionError("not_found", f"trade {trade_id} not found")
+ if trade.wallet_address.lower() != wallet_l:
+ raise AdoptionError("not_owner",
+ f"trade {trade_id} belongs to a different wallet")
+ if trade.closed_at is not None:
+ raise AdoptionError("already_closed",
+ f"trade {trade_id} is already closed — nothing to release")
+ if trade.released_at is not None:
+ # Idempotent — return success.
+ return {"status": "ok", "trade_id": trade_id,
+ "asset": trade.asset, "already_released": True}
+
+ now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
+ await db.execute(
+ update(BotTrade)
+ .where(BotTrade.id == trade_id)
+ .values(released_at=now_naive)
+ )
+ await db.commit()
+
+ from app.services.tp_sl_monitor import unregister
+ unregister(trade_id)
+
+ logger.warning(
+ "RELEASED trade %d: wallet=%s asset=%s — bot stops managing, "
+ "HL position untouched.",
+ trade_id, wallet_l, trade.asset,
+ )
+ return {"status": "ok", "trade_id": trade_id,
+ "asset": trade.asset, "already_released": False}
diff --git a/app/services/bot_engine.py b/app/services/bot_engine.py
index 40f3cff..e6c37b0 100644
--- a/app/services/bot_engine.py
+++ b/app/services/bot_engine.py
@@ -117,29 +117,31 @@ async def process_post(post: Post, db: AsyncSession) -> None:
sys2 = is_system_2(post.source)
if sys2:
- # SYSTEM 2 — reversal/breakout. The Trump-tuned regime filter
- # (recent-move ≤5%, vol-contraction) actively REJECTS valid reversal
- # setups, so it's bypassed entirely. The signal's structural gates
- # (RSI<30 ×4wk, 200d reclaim, funding extreme) ARE the regime check.
- exit_profile = get_exit_profile(post.category)
- if exit_profile.stop_ladder:
- logger.info(
- "%s [%s/%s]: bypassing Trump regime filter. "
- "Exit = STAGED stop (no TP/trailing) base sl=%.1f%% "
- "ladder=%s maxhold=%dh",
- system2_display_name(), post.source, post.category,
- exit_profile.stop_loss_pct, exit_profile.stop_ladder,
- exit_profile.max_hold_hours,
- )
- else:
- logger.info(
- "%s [%s/%s]: bypassing Trump regime filter. "
- "Exit profile sl=%.1f%% trail=%s@%s maxhold=%dh inval=%s",
- system2_display_name(), post.source, post.category,
- exit_profile.stop_loss_pct, exit_profile.trailing_stop_pct,
- exit_profile.trailing_activate_at_pct, exit_profile.max_hold_hours,
- exit_profile.invalidation,
- )
+ # SYSTEM 2 — manual-open + auto-manage model.
+ #
+ # We do NOT auto-open System-2 positions any more. The strategy is
+ # day-K level (AHR999 / 200WMA / Pi Cycle Bottom take weeks to play
+ # out) so a 24h entry delay is irrelevant — but the auto-open path
+ # carried real risk: leverage-clip math, daily-budget split,
+ # concurrency caps, sys2-CB pre-open gate, paper branches, key
+ # handling. All of that is execution surface for ~0 alpha vs giving
+ # the user the alert + letting them open on HL themselves.
+ #
+ # The valuable part of System-2 — the multi-month exit management
+ # (5-rung stop ladder + downside de-risk + pyramid + peak-trail) —
+ # is preserved in tp_sl_monitor and runs against positions the user
+ # adopts via /api/positions/adopt (or the Telegram /adopt command).
+ #
+ # Here we just record the signal (already done by signals.ingest →
+ # Post row) and let the Telegram fan-out send the alert with the
+ # "open on HL and /adopt" CTA. No subscriber iteration, no open.
+ logger.info(
+ "%s [%s/%s] signal recorded — manual-open + /adopt model "
+ "(no auto-open). Conf=%d",
+ system2_display_name(), post.source, post.category,
+ post.ai_confidence or 0,
+ )
+ return
else:
# SYSTEM 1 — Trump. REPOSITIONED: low-frequency, selective, tight
# stop, ≥30-min holds. See signal_categories.py.
diff --git a/app/services/reconciler.py b/app/services/reconciler.py
index 3003fb8..6aee38e 100644
--- a/app/services/reconciler.py
+++ b/app/services/reconciler.py
@@ -87,11 +87,14 @@ async def _reconcile_wallet(sub: Subscription) -> dict:
hl_assets_open = {p["coin"]: p for p in hl_positions}
async with AsyncSessionLocal() as db:
- # All DB rows we believe are still open for this wallet.
+ # All DB rows we believe are still open AND not released for this
+ # wallet. A released trade left the HL position open but handed
+ # control back to the user — reconciler must not touch it.
rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == sub.wallet_address,
BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
)
)
db_open_trades = rows.scalars().all()
diff --git a/app/services/recovery.py b/app/services/recovery.py
index 4afe0d3..cb0288c 100644
--- a/app/services/recovery.py
+++ b/app/services/recovery.py
@@ -29,7 +29,15 @@ async def rehydrate_open_trades() -> None:
from app.services.tp_sl_monitor import register_trade
async with AsyncSessionLocal() as db:
- result = await db.execute(select(BotTrade).where(BotTrade.closed_at.is_(None)))
+ # Skip released trades: the user has taken back manual control of
+ # the HL position; re-registering would re-attach the watchdog and
+ # silently start managing again after a restart.
+ result = await db.execute(
+ select(BotTrade).where(
+ BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
+ )
+ )
open_trades = result.scalars().all()
if not open_trades:
diff --git a/app/services/telegram.py b/app/services/telegram.py
index e0bfd31..d7c9c02 100644
--- a/app/services/telegram.py
+++ b/app/services/telegram.py
@@ -128,7 +128,21 @@ def format_post(post: Post) -> str:
}.get(post.source, "/en")
link = f'\n\n→ open in dashboard'
- 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\nWant bot to manage your trade?\n"
+ "1. Open the position on Hyperliquid (your size / leverage).\n"
+ "2. Reply /adopt 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 /release."
+ )
+
+ 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 ────────────────────────────────────────────────────────────
diff --git a/app/services/telegram_bot.py b/app/services/telegram_bot.py
index 3c4b90f..c62ff8d 100644
--- a/app/services/telegram_bot.py
+++ b/app/services/telegram_bot.py
@@ -86,21 +86,29 @@ def _consume_code(code: str) -> Optional[str]:
HELP_TEXT = (
"👋 Trump Alpha bot\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"
"Just press /start — you're subscribed. No wallet needed.\n\n"
- "Configure:\n"
+ "Real-time alerts:\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"
+ "Daily brief:\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"
"Status & control:\n"
" /status — show your preferences\n"
" /stop — pause all alerts\n"
" /test — send a sample alert\n\n"
- "Pro features (auto-trade on Hyperliquid):\n"
+ "Manage HL positions (Pro):\n"
+ " /adopt hand an HL position to the bot\n"
+ " /release stop managing (HL position stays open)\n\n"
+ "Pro features (manage + auto-trade on Hyperliquid):\n"
"Open the dashboard → Settings → Connect Telegram 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"📡 Status\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"
+ " /digest — preview right now\n"
+ " /digest on or /digest off\n"
+ " /digest_time 14 — 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: /digest_time 14 (UTC hour, 0–23).\n"
+ "Example: /digest_time 0 = 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 {h:02d}:00 UTC.")
+
+
+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,
+ "⚠️ /adopt is Pro-only.\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 /start 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,
+ "Pick a position to hand to the bot:\n\n"
+ "Bot will then run the 5-rung stop ladder + de-risk + pyramid + "
+ "peak-trail against it. You can release any time with "
+ "/release ID.",
+ 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"{arrow} {position.asset} {position.side} "
+ f"${position.size_usd:.0f} @ {position.entry_price:.2f} · "
+ f"{position.leverage}x\n\n"
+ "Pick the risk mode the bot should use:\n\n"
+ "📈 Standard — conservative ladder, longer hold, slower pyramid. "
+ "Designed to survive bull-cycle corrections.\n"
+ "🚀 Aggressive — 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: /release 42 (trade id) — or just "
+ "/release 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,
+ "Release a position from bot management:\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"{arrow} #{trade.id} {trade.asset} {trade.side} "
+ f"${(trade.size_usd or 0):.0f} @ {trade.entry_price:.2f}\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"{arrow} {target.asset} {target.side} "
+ f"${target.size_usd:.0f} @ {target.entry_price:.2f} · "
+ f"{target.leverage}x\n\n"
+ "Pick the risk mode:\n\n"
+ "📈 Standard — conservative, designed to survive "
+ "bull-cycle corrections.\n"
+ "🚀 Aggressive — 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"✅ Adopted #{result.trade_id} · {result.asset} "
+ f"{result.side} · {result.leverage}x · "
+ f"{result.mode}\n\n"
+ f"Entry: {result.entry_price:.2f} · "
+ f"Size: ${result.size_usd:.0f}\n"
+ f"Protective stop: -{result.protective_stop:.1f}% "
+ f"(inside HL liquidation)\n\n"
+ f"De-risk ladder (underwater):\n{derisk_summary}\n\n"
+ f"Stop ratchet (in profit):\n{ladder_summary}\n\n"
+ f"Release any time: /release {result.trade_id}"
+ )
+ 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"{arrow} #{t.id} {t.asset} {t.side} "
+ f"${(t.size_usd or 0):.0f} @ {t.entry_price:.2f}\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"✅ Released #{res['trade_id']} · {res['asset']}\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
diff --git a/app/services/telegram_digest.py b/app/services/telegram_digest.py
new file mode 100644
index 0000000..f94a5ae
--- /dev/null
+++ b/app/services/telegram_digest.py
@@ -0,0 +1,472 @@
+"""Daily 3-section Telegram digest — Macro Vibes / KOL / Trump.
+
+Sent to every TelegramBinding where digest_enabled=True at their chosen
+digest_hour_utc. Independent of the per-signal real-time alert path
+(telegram._dispatch) — a user can turn one off and the other on.
+
+Architecture:
+
+ cron (hourly :00)
+ ↓
+ send_daily_digest(current_hour)
+ ↓
+ build_global_digest() ← runs ONCE, content is global
+ ↓
+ for each binding where digest_hour_utc == current_hour:
+ format_digest(global, user_state) ← appends per-user line
+ send_message(chat_id, text)
+ update last_digest_sent_at
+
+The body is rule-based (no LLM). Each section reads one or two structured
+fields and picks a templated sentence. If we later want richer prose, the
+single place to hook DeepSeek/Claude is at the bottom of
+`format_digest` — everything else stays the same.
+
+Idempotency: the SELECT filters on
+ last_digest_sent_at IS NULL OR last_digest_sent_at < now - 23h
+so a coalesced cron or worker restart can't double-send within a day.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from sqlalchemy import select, update, func, or_, and_
+
+from app.config import settings
+from app.database import AsyncSessionLocal as async_session
+from app.models import (
+ TelegramBinding, MacroSnapshot, KolPost, KolDivergence,
+ Post, BotTrade, Subscription,
+)
+from app.services.telegram import send_message
+
+logger = logging.getLogger(__name__)
+
+
+# ── Data model ────────────────────────────────────────────────────────────
+
+
+@dataclass
+class MacroBlock:
+ available: bool
+ composite: Optional[float]
+ regime: Optional[str] # BULL / BULLISH / NEUTRAL / BEARISH / BEAR
+ ahr999: Optional[float]
+ fear_greed: Optional[int]
+ bottom_signal_firing: bool # any Post(source=btc_bottom_reversal, last 24h)
+ bottom_votes: Optional[int] # 2 or 3 if firing, else None
+ funding_signal_firing: bool # any Post(source=funding_reversal, last 24h)
+
+
+@dataclass
+class KolBlock:
+ posts_24h: int
+ bullish: int
+ bearish: int
+ neutral: int
+ divergences_24h: int
+ sample_divergence: Optional[str] # one human-readable line, or None
+
+
+@dataclass
+class TrumpBlock:
+ posts_24h: int
+ actionable: list # list of (asset, side, confidence)
+
+
+@dataclass
+class GlobalDigest:
+ """Same content for everyone. Per-user state (auto-trade, your trades)
+ is appended in format_digest."""
+ date_label: str # "May 26 · Mon"
+ macro: MacroBlock
+ kol: KolBlock
+ trump: TrumpBlock
+ headline_emoji: str # 🟢 / 🟡 / ⚪
+ headline_text: str # "BTC bottom signal firing. Worth a look."
+
+
+# ── Builder ───────────────────────────────────────────────────────────────
+
+
+async def build_global_digest(now: Optional[datetime] = None) -> GlobalDigest:
+ """Single DB pass over the three sources. Pure read — never raises;
+ returns a degraded digest if any source is missing."""
+ now = now or datetime.now(timezone.utc)
+ now_naive = now.replace(tzinfo=None)
+ cutoff_24h = now_naive - timedelta(hours=24)
+ date_label = now.strftime("%b %-d · %a")
+
+ async with async_session() as db:
+ # ── Macro: today's row (or yesterday's if today's not in yet) ────
+ today = now.date()
+ yesterday = today - timedelta(days=1)
+ snap = (await db.execute(
+ select(MacroSnapshot)
+ .where(MacroSnapshot.snapshot_date.in_([today, yesterday]))
+ .order_by(MacroSnapshot.snapshot_date.desc())
+ )).scalars().first()
+
+ # Bottom + funding triggers in last 24h
+ bottom_post = (await db.execute(
+ select(Post)
+ .where(Post.source == "btc_bottom_reversal",
+ Post.created_at >= cutoff_24h)
+ .order_by(Post.created_at.desc())
+ )).scalars().first()
+ funding_post = (await db.execute(
+ select(Post)
+ .where(Post.source == "funding_reversal",
+ Post.created_at >= cutoff_24h)
+ )).scalars().first()
+
+ macro = MacroBlock(
+ available=snap is not None,
+ composite=snap.composite_score if snap else None,
+ regime=snap.regime_label if snap else None,
+ ahr999=snap.ahr999 if snap else None,
+ fear_greed=snap.fear_greed if snap else None,
+ bottom_signal_firing=bottom_post is not None,
+ bottom_votes=(bottom_post.ai_confidence and
+ (3 if bottom_post.ai_confidence >= 90 else 2))
+ if bottom_post else None,
+ funding_signal_firing=funding_post is not None,
+ )
+
+ # ── KOL: last 24h posts + divergences ────────────────────────────
+ kol_posts = (await db.execute(
+ select(KolPost).where(KolPost.published_at >= cutoff_24h)
+ )).scalars().all()
+ # action lives inside tickers_json; easier: classify on summary text.
+ # The structured action is on KolDivergence rows; for the bare count
+ # we just split posts by whether divergence rows reference them.
+ bullish = bearish = neutral = 0
+ divergence_rows = (await db.execute(
+ select(KolDivergence)
+ .where(KolDivergence.post_at >= cutoff_24h)
+ .order_by(KolDivergence.created_at.desc())
+ )).scalars().all()
+ for d in divergence_rows:
+ if d.direction == "long":
+ bullish += 1
+ elif d.direction == "short":
+ bearish += 1
+ else:
+ neutral += 1
+ # Posts not yet classified into divergences just count as neutral.
+ neutral += max(0, len(kol_posts) - (bullish + bearish + neutral))
+
+ divergences_24h = sum(
+ 1 for d in divergence_rows if d.signal_type == "divergence"
+ )
+ sample_divergence: Optional[str] = None
+ for d in divergence_rows:
+ if d.signal_type != "divergence":
+ continue
+ # "Hayes publicly bullish BTC, on-chain selling"
+ sample_divergence = (
+ f"{d.handle} publicly {d.post_action} {d.ticker}, "
+ f"on-chain {d.onchain_action}"
+ )
+ break
+
+ kol = KolBlock(
+ posts_24h=len(kol_posts),
+ bullish=bullish, bearish=bearish, neutral=neutral,
+ divergences_24h=divergences_24h,
+ sample_divergence=sample_divergence,
+ )
+
+ # ── Trump: last 24h posts, list actionable ones ──────────────────
+ trump_posts = (await db.execute(
+ select(Post)
+ .where(Post.source == "truth", Post.created_at >= cutoff_24h)
+ .order_by(Post.created_at.desc())
+ )).scalars().all()
+ actionable = []
+ for p in trump_posts:
+ if p.signal in ("buy", "short") and (p.ai_confidence or 0) >= 70:
+ actionable.append((
+ p.target_asset or "?",
+ "LONG" if p.signal == "buy" else "SHORT",
+ int(p.ai_confidence or 0),
+ ))
+ trump = TrumpBlock(posts_24h=len(trump_posts), actionable=actionable)
+
+ headline_emoji, headline_text = _headline(macro, kol, trump)
+
+ return GlobalDigest(
+ date_label=date_label,
+ macro=macro, kol=kol, trump=trump,
+ headline_emoji=headline_emoji, headline_text=headline_text,
+ )
+
+
+def _headline(macro: MacroBlock, kol: KolBlock, trump: TrumpBlock) -> tuple[str, str]:
+ """Pick a one-liner verdict + emoji from the three sections.
+
+ Priority: macro bottom > macro funding > Trump actionable > KOL
+ divergence > nothing.
+ """
+ if macro.bottom_signal_firing:
+ return "🟢", "BTC bottom signal firing. Worth a look."
+ if macro.funding_signal_firing:
+ return "🟢", "Funding extreme — mean-reversion setup forming."
+ if len(trump.actionable) > 0:
+ n = len(trump.actionable)
+ return "🟢", f"Trump posted {n} actionable crypto signal{'s' if n > 1 else ''} today."
+ if kol.divergences_24h > 0:
+ return "🟡", f"{kol.divergences_24h} KOL talks-vs-trades divergence today."
+ return "⚪", "Quiet day, nothing to act on."
+
+
+# ── Formatter ─────────────────────────────────────────────────────────────
+
+
+def format_digest(g: GlobalDigest, user_state: Optional[dict] = None) -> str:
+ """Render the global digest into a single HTML message.
+
+ `user_state` is a small dict built per-binding by send_daily_digest:
+ { "wallet": str | None,
+ "auto_trade": bool,
+ "trades_today": int,
+ "open_pnl_summary": str | None }
+ None or empty → free-tier user, the Pro status line is suppressed.
+ """
+ lines: list[str] = []
+ lines.append("📰 Trump Alpha · Daily Brief")
+ lines.append(f"{g.date_label}")
+ lines.append("")
+ lines.append(f"{g.headline_emoji} Today: {g.headline_text}")
+ lines.append("")
+
+ # ── Macro ────────────────────────────────────────────────────────────
+ lines.append("━ Macro ━")
+ m = g.macro
+ if not m.available:
+ lines.append("Macro snapshot unavailable today.")
+ elif m.bottom_signal_firing:
+ votes = m.bottom_votes or 2
+ lines.append(f"BTC bottom triggers: {votes}/3 firing.")
+ if m.ahr999 is not None:
+ lines.append(f"AHR999={m.ahr999:.2f}, F&G={m.fear_greed or '?'}.")
+ elif m.funding_signal_firing:
+ lines.append("Funding-rate extreme — mean-reversion alert active.")
+ else:
+ # Plain day: one sentence + 1-2 backing numbers
+ regime_phrase = {
+ "BULL": "strongly bullish",
+ "BULLISH": "leaning bullish",
+ "NEUTRAL": "in neutral zone, far from bottom",
+ "BEARISH": "leaning bearish",
+ "BEAR": "strongly bearish",
+ }.get((m.regime or "NEUTRAL").upper(), "in neutral zone")
+ lines.append(f"BTC {regime_phrase}.")
+ bits = []
+ if m.ahr999 is not None:
+ bits.append(f"AHR999={m.ahr999:.2f} (bottom <0.45)")
+ if m.fear_greed is not None:
+ bits.append(f"F&G={m.fear_greed}")
+ if bits:
+ lines.append(", ".join(bits) + ".")
+ lines.append("")
+
+ # ── KOL ──────────────────────────────────────────────────────────────
+ lines.append("━ KOL ━")
+ k = g.kol
+ if k.posts_24h == 0:
+ lines.append("No KOL posts in last 24h.")
+ else:
+ lines.append(
+ f"{k.posts_24h} posts today: {k.bullish} bullish / "
+ f"{k.bearish} bearish."
+ )
+ if k.divergences_24h > 0 and k.sample_divergence:
+ lines.append(f"⚠️ {k.sample_divergence}.")
+ elif k.divergences_24h == 0:
+ lines.append("No talks-vs-trades divergence.")
+ lines.append("")
+
+ # ── Trump ────────────────────────────────────────────────────────────
+ lines.append("━ Trump ━")
+ t = g.trump
+ if t.posts_24h == 0:
+ lines.append("No posts in last 24h.")
+ elif not t.actionable:
+ lines.append(f"{t.posts_24h} posts today, none crypto-actionable.")
+ else:
+ lines.append(
+ f"{t.posts_24h} posts today, "
+ f"{len(t.actionable)} actionable:"
+ )
+ for asset, side, conf in t.actionable[:3]:
+ emoji = "🟢" if side == "LONG" else "🔴"
+ lines.append(f" {emoji} {asset} · {side} · conf {conf}")
+ if len(t.actionable) > 3:
+ lines.append(f" …and {len(t.actionable) - 3} more.")
+ lines.append("")
+
+ # ── Per-user state (Pro only) ────────────────────────────────────────
+ if user_state and user_state.get("wallet"):
+ at = "ON" if user_state.get("auto_trade") else "OFF"
+ td = int(user_state.get("trades_today") or 0)
+ bits = [f"auto-trade {at}", f"{td} trade{'s' if td != 1 else ''} today"]
+ if user_state.get("open_pnl_summary"):
+ bits.append(user_state["open_pnl_summary"])
+ lines.append("Your status: " + " · ".join(bits))
+ lines.append("")
+
+ # ── Footer ───────────────────────────────────────────────────────────
+ fe = (settings.frontend_url or "").rstrip("/")
+ if fe:
+ lines.append(f'→ open dashboard')
+ lines.append("")
+ lines.append("Daily brief · reply /digest off to unsubscribe")
+
+ return "\n".join(lines).strip()
+
+
+# ── Sender ────────────────────────────────────────────────────────────────
+
+
+async def _build_user_state(db, binding: TelegramBinding,
+ now_naive: datetime) -> dict:
+ """Pro-only: fetch auto-trade flag + today's trade count for the
+ wallet attached to this binding. Returns {} for free users."""
+ if not binding.wallet_address:
+ return {}
+ sub = (await db.execute(
+ select(Subscription).where(Subscription.wallet_address == binding.wallet_address)
+ )).scalar_one_or_none()
+ if sub is None:
+ return {}
+ start_of_day = now_naive.replace(hour=0, minute=0, second=0, microsecond=0)
+ trades_today = (await db.execute(
+ select(func.count(BotTrade.id)).where(
+ BotTrade.wallet_address == binding.wallet_address,
+ BotTrade.opened_at >= start_of_day,
+ )
+ )).scalar() or 0
+ # Open positions' aggregated unrealised — best-effort, skip on any error.
+ open_pnl_summary: Optional[str] = None
+ try:
+ # Released trades aren't bot-managed any more; their unrealised
+ # PnL belongs to the user's manual control, not to "your bot status".
+ open_trades = (await db.execute(
+ select(BotTrade).where(
+ BotTrade.wallet_address == binding.wallet_address,
+ BotTrade.closed_at.is_(None),
+ BotTrade.released_at.is_(None),
+ )
+ )).scalars().all()
+ if open_trades:
+ from app.services.price_store import price_store
+ unreal = 0.0
+ for t in open_trades:
+ px = price_store.latest_price(t.asset)
+ if not px or not t.entry_price or not t.size_usd:
+ continue
+ pct = (px - t.entry_price) / t.entry_price
+ signed = pct if t.side == "long" else -pct
+ rem = t.remaining_fraction if t.remaining_fraction is not None else 1.0
+ unreal += t.size_usd * rem * signed
+ if open_trades:
+ sign = "+" if unreal >= 0 else ""
+ open_pnl_summary = f"{len(open_trades)} open ({sign}{unreal:.1f} USD)"
+ except Exception as exc:
+ logger.debug("digest user_state: skip open pnl: %s", exc)
+
+ return {
+ "wallet": binding.wallet_address,
+ "auto_trade": bool(sub.auto_trade),
+ "trades_today": int(trades_today),
+ "open_pnl_summary": open_pnl_summary,
+ }
+
+
+async def send_daily_digest(current_hour: Optional[int] = None) -> int:
+ """Send the digest to every binding whose digest_hour_utc matches the
+ current UTC hour AND who hasn't received one in the last 23 hours.
+
+ Returns the number of messages successfully sent. Never raises — the
+ cron firing must not bring the worker down.
+ """
+ if not settings.telegram_bot_token:
+ return 0
+
+ now = datetime.now(timezone.utc)
+ hour = current_hour if current_hour is not None else now.hour
+ now_naive = now.replace(tzinfo=None)
+ dedupe_cutoff = now_naive - timedelta(hours=23)
+
+ # Build the body ONCE — it's identical for every user (only per-user
+ # state line varies). One DB pass instead of one per binding.
+ try:
+ global_digest = await build_global_digest(now)
+ except Exception as exc:
+ logger.exception("Digest: build_global_digest failed: %s", exc)
+ return 0
+
+ sent = 0
+ async with async_session() as db:
+ bindings = (await db.execute(
+ select(TelegramBinding).where(
+ TelegramBinding.digest_enabled.is_(True),
+ TelegramBinding.alerts_enabled.is_(True),
+ TelegramBinding.digest_hour_utc == hour,
+ or_(
+ TelegramBinding.last_digest_sent_at.is_(None),
+ TelegramBinding.last_digest_sent_at < dedupe_cutoff,
+ ),
+ )
+ )).scalars().all()
+
+ if not bindings:
+ return 0
+
+ for b in bindings:
+ try:
+ user_state = await _build_user_state(db, b, now_naive)
+ text = format_digest(global_digest, user_state)
+ ok = await send_message(b.chat_id, text)
+ if ok:
+ await db.execute(
+ update(TelegramBinding)
+ .where(TelegramBinding.id == b.id)
+ .values(last_digest_sent_at=now_naive)
+ )
+ sent += 1
+ except Exception as exc:
+ logger.warning("Digest send failed for chat=%s: %s", b.chat_id, exc)
+ await db.commit()
+
+ logger.info("Daily digest: hour=%dz sent=%d/%d", hour, sent, len(bindings))
+ return sent
+
+
+# ── Manual preview (called from bot /digest command) ──────────────────────
+
+
+async def send_preview_for(chat_id: int) -> bool:
+ """Render and immediately send a digest to one chat. Does NOT update
+ last_digest_sent_at — preview is independent of the daily slot."""
+ if not settings.telegram_bot_token:
+ return False
+ try:
+ g = await build_global_digest()
+ except Exception as exc:
+ logger.exception("Digest preview: build failed: %s", exc)
+ return await send_message(
+ chat_id, "⚠️ Digest preview unavailable — check back shortly.")
+ async with async_session() as db:
+ b = (await db.execute(
+ select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
+ )).scalar_one_or_none()
+ user_state = (await _build_user_state(db, b, datetime.utcnow())
+ if b else {})
+ text = format_digest(g, user_state)
+ return await send_message(chat_id, text)
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
new file mode 100644
index 0000000..2fafc25
--- /dev/null
+++ b/tests/test_adoption.py
@@ -0,0 +1,169 @@
+"""Validation-path tests for the adopt / release flow.
+
+These cover the in-memory branches that don't need a real DB / real HL —
+mode normalisation, the error-code surface, and the structure of the
+returned objects. End-to-end (real HL state → BotTrade row written →
+watchdog registered) is verified by the manual /adopt Telegram flow,
+not in CI.
+
+Why so light: every interesting failure case is a DB or HL state issue
+that's better caught manually. The function shape + error codes are what
+the API + Telegram layer switch on, so those we lock down here.
+"""
+import pytest
+
+from app.services.adoption import (
+ AdoptionError, ADOPTED_CATEGORY,
+ HLPositionView, AdoptionResult,
+)
+from app.services.signal_categories import (
+ sys2_normalize_mode, get_exit_profile, get_stop_ladder,
+ sys2_protective_stop_pct, sys2_derisk_ladder, sys2_addon_ladder,
+ sys2_peak_trail, SYS2_MODES,
+)
+
+
+def test_adopted_category_resolves_to_btc_bottom_profile():
+ """The adopted-position category must point to a real exit profile —
+ otherwise adoption silently falls back to the System-2 default
+ (different stop / different max-hold)."""
+ profile = get_exit_profile(ADOPTED_CATEGORY)
+ assert profile.stop_ladder is not None, \
+ "adopted category must use the staged stop ladder"
+ assert profile.max_hold_hours == 12960, \
+ "BTC bottom-reversal is an 18-month-max-hold strategy"
+ # 5 upside rungs match the documented strategy.
+ ladder = get_stop_ladder(ADOPTED_CATEGORY)
+ assert len(ladder) == 5
+
+
+def test_adoption_error_carries_code_and_message():
+ """API + Telegram layers switch on .code; ensure both attrs are set."""
+ exc = AdoptionError("concurrency_cap", "You already have 3.")
+ assert exc.code == "concurrency_cap"
+ assert exc.message == "You already have 3."
+ assert "3" in str(exc)
+
+
+def test_mode_normalisation_maps_unknown_to_standard():
+ assert sys2_normalize_mode("standard") == "standard"
+ assert sys2_normalize_mode("aggressive") == "aggressive"
+ assert sys2_normalize_mode("STANDARD") == "standard"
+ assert sys2_normalize_mode("") == "standard"
+ assert sys2_normalize_mode(None) == "standard"
+ assert sys2_normalize_mode("yolo") == "standard"
+
+
+def test_mode_set_matches_canonical_list():
+ """SYS2_MODES is the source of truth for what adopt_position accepts;
+ if we add a third mode this test fails on purpose to remind us to
+ update the bot's mode picker buttons too."""
+ assert SYS2_MODES == ("standard", "aggressive")
+
+
+@pytest.mark.parametrize("lev,expected_max_prot", [
+ (1, 35.0), # 1× → 85% of 100% liq = 85%, capped at 35%
+ (2, 35.0), # 2× → 85% of 50% = 42.5%, capped at 35%
+ (3, 28.33), # 3× → 85% of 33.3% ≈ 28.33
+ (5, 17.0), # 5× → 85% of 20% = 17.0
+ (10, 8.5), # 10× → 85% of 10% = 8.5
+])
+def test_protective_stop_scales_with_leverage(lev, expected_max_prot):
+ """The whole point of recomputing protective stop against HL's actual
+ leverage is that this number must stay INSIDE the liquidation distance.
+ A regression here is a real-money risk regression."""
+ got = sys2_protective_stop_pct(lev)
+ assert got == pytest.approx(expected_max_prot, abs=0.1)
+
+
+def test_derisk_ladder_uses_three_rungs_summing_to_protective():
+ """At any leverage, the three de-risk rungs should land at
+ -0.60·P, -0.80·P, -1.00·P. The final rung IS the full protective
+ close — same level as a one-shot stop, but staged."""
+ for lev in (2, 5, 10):
+ prot = sys2_protective_stop_pct(lev)
+ ladder = sys2_derisk_ladder(lev, "standard")
+ assert len(ladder) == 3
+ thrs = [r[0] for r in ladder]
+ assert thrs[0] == pytest.approx(-0.60 * prot, abs=0.5)
+ assert thrs[1] == pytest.approx(-0.80 * prot, abs=0.5)
+ assert thrs[2] == pytest.approx(-1.00 * prot, abs=0.5)
+ # Last rung is the FULL close (is_final flag).
+ assert ladder[-1][2] is True
+
+
+def test_addon_ladder_differs_between_modes():
+ """Aggressive mode must fire earlier and add more — otherwise the mode
+ label is a lie."""
+ std = sys2_addon_ladder("standard")
+ agg = sys2_addon_ladder("aggressive")
+ assert std[0][0] > agg[0][0], \
+ "aggressive first rung must trigger at a LOWER peak gain"
+ assert agg[0][1] > std[0][1], \
+ "aggressive first rung must add MORE notional"
+
+
+def test_peak_trail_aggressive_more_permissive():
+ """Aggressive peak-trail must let the trade run longer + give back
+ more drawdown — that's the explicit trade-off vs standard."""
+ std_start, std_dd = sys2_peak_trail("standard")
+ agg_start, agg_dd = sys2_peak_trail("aggressive")
+ assert agg_start < std_start
+ assert agg_dd > std_dd
+
+
+def test_hl_position_view_dataclass_shape():
+ """The API layer rehydrates HLPositionView via **vars(it); guard the
+ field set so a silent rename doesn't break the HTTP shape."""
+ v = HLPositionView(asset="BTC", side="long", size_coins=0.02,
+ entry_price=72000.0, leverage=2, size_usd=1440.0,
+ already_adopted=False)
+ d = vars(v)
+ assert set(d.keys()) == {
+ "asset", "side", "size_coins", "entry_price",
+ "leverage", "size_usd", "already_adopted",
+ }
+
+
+def test_adoption_result_dataclass_shape():
+ r = AdoptionResult(
+ trade_id=1, asset="BTC", side="long", entry_price=72000.0,
+ size_usd=1440.0, leverage=2, mode="standard",
+ protective_stop=35.0, derisk_ladder=[], stop_ladder=[],
+ )
+ assert r.trade_id == 1
+ assert r.mode == "standard"
+
+
+def test_wallet_adopt_lock_is_per_wallet_and_reused():
+ """The lock cache must hand back the SAME lock for the same wallet
+ (otherwise two concurrent adopts would acquire different locks and
+ bypass serialisation), and a DIFFERENT lock for a different wallet."""
+ from app.services.adoption import _wallet_adopt_lock
+ a1 = _wallet_adopt_lock("0xaaa")
+ a2 = _wallet_adopt_lock("0xaaa")
+ b1 = _wallet_adopt_lock("0xbbb")
+ assert a1 is a2
+ assert a1 is not b1
+
+
+def test_adoption_error_codes_used_by_callers_are_all_defined():
+ """The API and Telegram layers switch on AdoptionError.code values.
+ If we ever silently rename one, the switch falls through to the
+ generic branch and the user loses the specific guidance. Lock the
+ set of codes we promise to emit."""
+ # These are the codes referenced from app/api/positions.py +
+ # app/services/telegram_bot.py. Update both sites if you add one.
+ promised_codes = {
+ "no_subscription", "no_hl_key", "paper_mode",
+ "key_decrypt_failed", "hl_read_failed",
+ "bad_mode", "circuit_breaker",
+ "already_adopted", "concurrency_cap",
+ "not_on_hl", "bad_hl_state",
+ "not_found", "not_owner", "already_closed",
+ }
+ # Just exercising the constructor for each — proves the codes
+ # round-trip cleanly through __init__.
+ for code in promised_codes:
+ err = AdoptionError(code, f"sample for {code}")
+ assert err.code == code
diff --git a/tests/test_telegram_digest.py b/tests/test_telegram_digest.py
new file mode 100644
index 0000000..f6b5c5c
--- /dev/null
+++ b/tests/test_telegram_digest.py
@@ -0,0 +1,176 @@
+"""Snapshot-style tests for the daily digest formatter.
+
+These hit format_digest with hand-rolled GlobalDigest objects so we exercise
+every branch (quiet day / bottom trigger / actionable Trump / divergence /
+empty all-around) without standing up a DB. The actual build_global_digest
+SQL is exercised by integration tests / manual /digest in the bot.
+
+The point of these tests is to lock the user-facing wording — if a future
+change accidentally turns "BTC bottom triggers: 3/3 firing" into a less
+clear phrasing, CI catches it.
+"""
+
+from app.services.telegram_digest import (
+ GlobalDigest, MacroBlock, KolBlock, TrumpBlock, format_digest,
+)
+
+
+def _empty_blocks():
+ return (
+ MacroBlock(available=False, composite=None, regime=None,
+ ahr999=None, fear_greed=None,
+ bottom_signal_firing=False, bottom_votes=None,
+ funding_signal_firing=False),
+ KolBlock(posts_24h=0, bullish=0, bearish=0, neutral=0,
+ divergences_24h=0, sample_divergence=None),
+ TrumpBlock(posts_24h=0, actionable=[]),
+ )
+
+
+def test_quiet_day_renders_compact_and_mentions_no_activity():
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.regime = "NEUTRAL"
+ m.ahr999 = 0.62
+ m.fear_greed = 38
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="⚪",
+ headline_text="Quiet day, nothing to act on.")
+ out = format_digest(g)
+ assert "Trump Alpha · Daily Brief" in out
+ assert "⚪" in out
+ assert "Quiet day" in out
+ assert "neutral zone" in out
+ assert "AHR999=0.62" in out
+ assert "No KOL posts" in out
+ assert "No posts in last 24h" in out
+ # No per-user Pro line for free users
+ assert "Your status" not in out
+ # Always ends with the unsubscribe hint
+ assert "/digest off" in out
+
+
+def test_bottom_trigger_day_promotes_macro_section_and_keeps_short():
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.bottom_signal_firing = True
+ m.bottom_votes = 3
+ m.ahr999 = 0.41
+ m.fear_greed = 14
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="🟢",
+ headline_text="BTC bottom signal firing. Worth a look.")
+ out = format_digest(g)
+ assert "🟢" in out
+ assert "BTC bottom signal firing" in out
+ # Macro section shows the trigger detail
+ assert "3/3 firing" in out
+ assert "AHR999=0.41" in out
+
+
+def test_trump_actionable_list_is_capped_at_three_with_summary_overflow():
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.regime = "NEUTRAL"
+ t.posts_24h = 6
+ t.actionable = [
+ ("BTC", "LONG", 92),
+ ("SOL", "LONG", 81),
+ ("ETH", "SHORT", 76),
+ ("DOGE", "LONG", 71), # 4th — should be summarised
+ ]
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="🟢",
+ headline_text="Trump posted 4 actionable crypto signals today.")
+ out = format_digest(g)
+ assert "BTC · LONG · conf 92" in out
+ assert "SOL · LONG · conf 81" in out
+ assert "ETH · SHORT · conf 76" in out
+ # The fourth must roll into the "and N more" line, not be listed verbatim
+ assert "DOGE" not in out
+ assert "1 more" in out
+
+
+def test_divergence_warning_surfaces_sample_line():
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.regime = "NEUTRAL"
+ k.posts_24h = 14
+ k.bullish = 9
+ k.bearish = 1
+ k.divergences_24h = 1
+ k.sample_divergence = "ArthurHayes publicly bullish BTC, on-chain decreased"
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="🟡",
+ headline_text="1 KOL talks-vs-trades divergence today.")
+ out = format_digest(g)
+ assert "⚠️ ArthurHayes" in out
+ assert "9 bullish" in out
+
+
+def test_pro_user_state_appended_for_wallet_bound():
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.regime = "NEUTRAL"
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="⚪", headline_text="Quiet day, nothing to act on.")
+ out = format_digest(g, user_state={
+ "wallet": "0xabc",
+ "auto_trade": True,
+ "trades_today": 2,
+ "open_pnl_summary": "1 open (+12.4 USD)",
+ })
+ assert "Your status" in out
+ assert "auto-trade ON" in out
+ assert "2 trades today" in out
+ assert "1 open (+12.4 USD)" in out
+
+
+def test_pro_state_suppressed_for_free_user():
+ m, k, t = _empty_blocks()
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="⚪", headline_text="Quiet day, nothing to act on.")
+ out_free = format_digest(g, user_state={})
+ out_no_wallet = format_digest(g, user_state={"wallet": None})
+ assert "Your status" not in out_free
+ assert "Your status" not in out_no_wallet
+
+
+def test_html_safe_against_ampersand_in_label():
+ """The 'F&G' label must always emit as F&G so Telegram's HTML
+ parser doesn't reject the message."""
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.regime = "NEUTRAL"
+ m.ahr999 = 0.62
+ m.fear_greed = 38
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="⚪", headline_text="Quiet day, nothing to act on.")
+ out = format_digest(g)
+ assert "F&G=38" in out
+ assert "F&G=38" not in out
+
+
+def test_total_length_well_under_telegram_cap():
+ """Even the busiest realistic day must fit in one Telegram message
+ (4096 chars)."""
+ m, k, t = _empty_blocks()
+ m.available = True
+ m.bottom_signal_firing = True
+ m.bottom_votes = 3
+ m.ahr999 = 0.41
+ m.fear_greed = 14
+ k.posts_24h = 30
+ k.bullish = 20
+ k.bearish = 8
+ k.divergences_24h = 4
+ k.sample_divergence = "Hayes publicly bullish ETH, on-chain decreased"
+ t.posts_24h = 12
+ t.actionable = [("BTC", "LONG", 95), ("SOL", "LONG", 88), ("ETH", "SHORT", 80)]
+ g = GlobalDigest(date_label="May 26 · Mon", macro=m, kol=k, trump=t,
+ headline_emoji="🟢", headline_text="Lots happening today.")
+ out = format_digest(g, user_state={
+ "wallet": "0xabc", "auto_trade": True, "trades_today": 4,
+ "open_pnl_summary": "3 open (+125.3 USD)",
+ })
+ assert len(out) < 4096