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