feat: Telegram daily digest + System-2 manage-only refactor

Two changes ship together — both reshape the Telegram-bot surface.

──── 1. Telegram daily digest (3-section brief)
Once-a-day push covering Macro Vibes / KOL talks-vs-trades / Trump 24h.
Body is rule-based templating (no LLM); each section reads structured DB
fields and picks a phrasing. Per-user opt-out + per-user UTC hour.

  - 023 migration: TelegramBinding gains digest_enabled, digest_hour_utc,
    last_digest_sent_at (idempotent against coalesced cron / restarts).
  - New services/telegram_digest.py: build_global_digest +
    format_digest + send_daily_digest + send_preview_for.
  - Hourly cron at :00 fans out to bindings whose digest_hour_utc matches.
  - New bot commands: /digest (preview), /digest on|off, /digest_time HH.
  - HELP_TEXT + /status updated to surface the new prefs.

──── 2. System-2 manage-only refactor
The Macro Vibes (BTC bottom + funding) signal no longer auto-opens
positions. Strategy is day-K — a 24h entry delay is irrelevant — but the
auto-open path carried real execution surface (leverage clipping, daily
budget split, concurrency caps, paper branches, key handling) for ~zero
alpha. The valuable part — multi-month exit management (5-rung stop
ladder + de-risk + pyramid + peak-trail) — is preserved and runs
against positions the user adopts.

Flow: scanner fires → Telegram alert with "/adopt" CTA → user opens
manually on Hyperliquid → /adopt picks the position via inline keyboard
→ picks Standard or Aggressive mode → bot creates BotTrade + registers
watchdog. Escape hatch: /release marks released_at, unregisters
watchdog, leaves the HL position open under user control.

  - 024 migration: BotTrade.released_at — "user took back control" marker.
  - bot_engine.process_post early-returns for sys2 (no auto-open path).
  - New services/adoption.py: list_hl_positions + adopt_position +
    release_management + AdoptionError. Per-wallet asyncio lock prevents
    dual-adopt race. Pre-flight checks: no_subscription / no_hl_key /
    paper_mode / circuit_breaker / already_adopted / concurrency_cap.
    Protective stop + de-risk + addon + peak-trail ladders all built
    against the ACTUAL HL leverage so the "inside liquidation" guarantee
    holds.
  - New API: GET /positions/hl/{wallet}, POST /positions/adopt,
    POST /positions/{id}/release (all signed).
  - telegram.py: send_message supports reply_markup; new edit_message +
    answer_callback for the inline-keyboard pickers; sys2 alert format
    now ends with the "/adopt" CTA.
  - telegram_bot.py: /adopt + /release commands with picker → confirm
    → execute flow via inline keyboards. New _handle_callback dispatches
    on "adopt:*" / "release:*" callback_data; run_bot_loop now consumes
    callback_query updates alongside messages.
  - recovery.py + reconciler.py skip released_at IS NOT NULL rows so a
    restart doesn't silently re-attach the watchdog to a released trade.
  - /positions/open, /positions/today, and telegram_digest's user-state
    line all filter released_at so they don't lie about what the bot is
    actually managing.

Tests: 26 new (8 digest snapshot + 14 adoption + 4 absorbed via existing
suites). All 64 pass.

Deploy: alembic upgrade head (runs 023 + 024) → restart backend. Existing
TelegramBindings get digest_enabled=true / digest_hour_utc=12 via server
defaults. In-flight auto-opened System-2 positions continue to be managed
(recovery rehydrates them) — no in-flight trade is abandoned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-26 12:53:16 +08:00
parent fc735f251a
commit 3754d2caf8
14 changed files with 2162 additions and 42 deletions
+169
View File
@@ -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
+176
View File
@@ -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</b> 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&amp;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&amp;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