Pre-launch hardening: KOL module, Telegram, scanners, WS resilience

Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
@@ -0,0 +1,425 @@
"""Tests for the current BTC bottom strategy:
- pure-price indicators (AHR999 / 200WMA / Pi Cycle) + 2-of-3 confluence
- leverage-aware dynamic System-2 leverage + protective stop
- staged stop-loss ladder math (no take-profit, ratchet-up only)
"""
import math
from app.services.bottom_indicators import (
ahr999, below_200wma, pi_cycle_bottom, bottom_confluence,
AHR999_BOTTOM,
)
from app.services.signal_categories import (
sys2_effective_leverage, sys2_protective_stop_pct,
sys2_approx_liquidation_pct, get_stop_ladder, sys2_derisk_ladder,
sys2_addon_ladder, sys2_peak_trail, get_exit_profile,
SYS2_DEFAULT_LEVERAGE, SYS2_MAX_STOP_PCT,
)
from app.services.bottom_indicators import trend_confirmed
# ── Indicators ──────────────────────────────────────────────────────────────
def test_ahr999_none_when_insufficient_history():
assert ahr999([100.0] * 199) is None
def test_ahr999_low_in_deep_decline():
# Long steady decline → price far below the geometric mean & growth model.
daily = [60000 * math.exp(-0.002 * i) for i in range(400)]
v = ahr999(daily)
assert v is not None and v < AHR999_BOTTOM
def test_below_200wma_uses_explicit_price_not_stale_weekly():
weekly = [100.0] * 199 + [130.0] # 200-wk mean ≈ 100.15, last close 130
# Using the stale weekly close → above band → False.
sig_stale, wma = below_200wma(weekly)
assert sig_stale is False and wma is not None
# Passing the fresh (lower) daily price → below band → True.
sig_fresh, _ = below_200wma(weekly, price=95.0)
assert sig_fresh is True
def test_pi_cycle_bottom_none_when_short():
assert pi_cycle_bottom([1.0] * 470)[0] is False
def test_confluence_fires_only_with_two_of_three():
# Deep decline: AHR999 deep-value + 200WMA both true → ≥2 → fired.
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
weekly = [60000 * math.exp(-0.01 * i) for i in range(210)]
c = bottom_confluence(daily, weekly)
assert c.votes >= 2 and c.fired is True
# Strong bull: AHR999 expensive (>0.45) and Pi not in bottom region →
# at most 1 vote → confluence requires ≥2 so it must NOT fire.
up_d = [20000 * math.exp(0.004 * i) for i in range(520)]
up_w = [20000 * math.exp(0.02 * i) for i in range(210)]
c2 = bottom_confluence(up_d, up_w)
assert c2.detail["signals"]["ahr999_value"] is False
assert c2.detail["signals"]["pi_cycle_bottom"] is False
assert c2.votes < 2 and c2.fired is False
def test_confluence_b_uses_latest_daily_price():
# weekly close stale-high but the latest daily close is at the lows.
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
weekly = [30000.0] * 209 + [90000.0] # last weekly close artificially high
c = bottom_confluence(daily, weekly)
# B must compare the 200wma against the latest DAILY close, not 90000.
assert c.detail["price"] == round(daily[-1], 2)
# ── Dynamic System-2 leverage ────────────────────────────────────────────────
def test_effective_leverage_clamps_and_defaults():
assert sys2_effective_leverage(None) == SYS2_DEFAULT_LEVERAGE
assert sys2_effective_leverage(0) == 1
assert sys2_effective_leverage(99) == 10
assert sys2_effective_leverage(5) == 5
assert sys2_effective_leverage("bad") == SYS2_DEFAULT_LEVERAGE
def test_protective_stop_always_inside_liquidation():
# The protective full-exit must trigger BEFORE the exchange liquidates,
# for every allowed leverage.
for lev in range(1, 11):
stop = sys2_protective_stop_pct(lev)
liq = sys2_approx_liquidation_pct(lev)
assert stop < liq, f"lev {lev}: stop {stop} not inside liq {liq}"
# Low leverage keeps the full bottom-wick tolerance.
assert sys2_protective_stop_pct(1) == SYS2_MAX_STOP_PCT
assert sys2_protective_stop_pct(2) == SYS2_MAX_STOP_PCT
# High leverage tightens automatically.
assert sys2_protective_stop_pct(5) < 20
assert sys2_protective_stop_pct(10) < 10
def test_stop_ladder_has_no_negative_base_rung_and_only_ratchets_up():
ladder = get_stop_ladder("btc_bottom_reversal_long")
assert ladder is not None
triggers = [t for t, _ in ladder]
floors = [f for _, f in ladder]
# Sorted ascending by trigger.
assert triggers == sorted(triggers)
# Floors strictly increase (pure ratchet-up: never loosens).
assert floors == sorted(floors)
# No 0%-trigger catastrophic rung — that floor is leverage-derived now.
assert triggers[0] > 0
# Reaches locked-in profit (a positive floor exists).
assert max(floors) > 0
def test_non_bottom_category_has_no_ladder():
assert get_stop_ladder("sma_reclaim") is None
assert get_stop_ladder(None) is None
# ── Staged-stop monitor math (mirror of tp_sl_monitor branch 0) ──────────────
def _eff_stop(peak: float, base_stop_pct: float, ladder) -> float:
eff = -base_stop_pct
for trig, floor in ladder:
if peak >= trig and floor > eff:
eff = floor
return eff
def test_staged_stop_no_take_profit_and_locks_profit():
ladder = get_stop_ladder("btc_bottom_reversal_long")
base = sys2_protective_stop_pct(2) # 35%
# Never exits for a big unrealised gain (no take-profit).
assert 300.0 > _eff_stop(peak=300.0, base_stop_pct=base, ladder=ladder)
# Catastrophic floor before any rung: -35% at 2x.
assert _eff_stop(peak=0.0, base_stop_pct=base, ladder=ladder) == -35.0
# After +70% peak the floor has ratcheted to a locked-in profit.
locked = _eff_stop(peak=75.0, base_stop_pct=base, ladder=ladder)
assert locked > 0
# High leverage → tighter base floor, ladder still ratchets identically.
base10 = sys2_protective_stop_pct(10)
assert _eff_stop(peak=0.0, base_stop_pct=base10, ladder=ladder) == -base10
assert _eff_stop(peak=75.0, base_stop_pct=base10, ladder=ladder) == locked
# ── Staged de-risk ladder (分段式减仓) ───────────────────────────────────────
def test_derisk_ladder_shape_and_safety():
for lev in (1, 2, 3, 5, 10):
p = sys2_protective_stop_pct(lev)
liq = sys2_approx_liquidation_pct(lev)
ladder = sys2_derisk_ladder(lev)
assert len(ladder) == 3
thrs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
finals = [fin for _, _, fin in ladder]
# All thresholds negative, increasing in adversity.
assert all(t < 0 for t in thrs)
assert thrs == sorted(thrs, reverse=True) # -21, -28, -35 …
# Exactly the last rung is the full close.
assert finals == [False, False, True]
# Fractions sum to the whole position (thirds of original).
assert abs(sum(fracs) - 1.0) < 1e-9
# Final rung == the protective level == inside liquidation.
assert abs(thrs[-1] - (-p)) < 1e-6
assert -thrs[-1] < liq, f"lev {lev}: final {thrs[-1]} not inside liq {liq}"
def test_derisk_pnl_accounting_matches_single_close():
"""Summing the staged slices + the remaining close must equal a single
full close at the same final price (the staged path must not create or
destroy PnL vs closing all at once)."""
notional = 1000.0
entry = 100.0
final_price = 70.0 # -30% (long)
def slice_pnl(frac, px):
return notional * frac * ((px - entry) / entry)
# Staged: 1/3 closed at 85, 1/3 at 78, final 1/3 at 70.
staged = (
slice_pnl(1/3, 85.0) +
slice_pnl(1/3, 78.0) +
slice_pnl(1/3, 70.0)
)
# If instead all three thirds were closed at the final price:
single = slice_pnl(1.0, final_price)
# Staged exits EARLIER on the way down, so it must lose LESS than a
# single close at the worst price (the whole point of de-risking).
assert staged > single
# And closing all at entry-price slices would be zero — sanity.
assert abs(slice_pnl(1/3, entry) * 3) < 1e-9
def test_derisk_regime_switch_underwater_vs_profit():
"""Mirror the monitor's regime decision: underwater → de-risk ladder,
in-profit (peak ≥ first upside rung) → ratchet stop."""
stop_ladder = get_stop_ladder("btc_bottom_reversal_long")
first_up = min(t for t, _ in stop_ladder) # 20
def regime(peak):
return "profit" if peak >= first_up else "derisk"
assert regime(0.0) == "derisk"
assert regime(19.9) == "derisk"
assert regime(20.0) == "profit"
assert regime(150.0) == "profit"
# ── Pyramiding (做对了往上加仓) ──────────────────────────────────────────────
def test_addon_ladder_shape_conservative():
ladder = sys2_addon_ladder()
trigs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
lasts = [x for _, _, x in ladder]
assert trigs == sorted(trigs) and all(t > 0 for t in trigs) # peak-gain rungs
assert fracs[:3] == [0.30, 0.20, 0.10] # conservative base
assert sum(fracs) <= 0.80 # still modest total
assert lasts[-1] is True and lasts.count(True) == 1 # exactly one final
def test_trend_confirmed_requires_sma_and_new_high():
# Uptrend: price above 200d SMA and at a fresh 20d high → confirmed.
up = [100.0 + i for i in range(260)]
highs = [c + 1 for c in up]
price = up[-1] + 1
assert trend_confirmed(up, highs, price) is True
# Below the 200d SMA → not confirmed even at a local high.
assert trend_confirmed(up, highs, price=50.0) is False
# Above SMA but well below the recent high (still chopping) → not confirmed.
mid = sum(up[-200:]) / 200
assert trend_confirmed(up, highs, price=mid + 1) is False
# Too little history → fail closed.
assert trend_confirmed([1.0] * 50, [1.0] * 50, 1.0) is False
def test_pyramiding_blended_entry_math():
"""Blended entry after an add must be the notional-weighted average, and
it must sit BELOW the add price for a long that added higher (so the
aggregate is still in profit)."""
base = 1000.0
entry = 100.0
add_usd = base * 0.30
fill = 130.0 # added after +30%
new_notional = base + add_usd
blended = (base * entry + add_usd * fill) / new_notional
assert entry < blended < fill # weighted average
# Aggregate still profitable at the add price.
assert (fill - blended) / blended > 0
# Conservative sizing only modestly lifts the average (< 7% here).
assert (blended - entry) / entry < 0.07
def test_recovery_restores_peak_keeps_profit_regime():
"""A pyramided / in-profit trade rehydrated after a restart must seed the
monitor's peak from the persisted value so it stays in the profit regime
(not fall back to the underwater de-risk regime)."""
from app.services.tp_sl_monitor import register_trade, _watched, unregister
from app.services.signal_categories import (
sys2_derisk_ladder, sys2_addon_ladder, get_stop_ladder,
)
tid = 990011
try:
register_trade(
trade_id=tid, wallet="0xabc", api_key="k", leverage=2,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=35.0,
stop_ladder=get_stop_ladder("btc_bottom_reversal_long"),
derisk_ladder=sys2_derisk_ladder(2),
addon_ladder=sys2_addon_ladder(), addon_done=1,
initial_peak=90.0,
)
wt = _watched[tid]
assert wt.peak_gain_pct == 90.0
assert wt.peak_persisted == 90.0
first_up = min(t for t, _ in wt.stop_ladder)
# peak 90 ≥ first upside rung → profit regime, NOT underwater de-risk.
assert wt.peak_gain_pct >= first_up
finally:
unregister(tid)
def test_addon_ladder_extended_for_cycle_bull():
ladder = sys2_addon_ladder()
trigs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
lasts = [x for _, _, x in ladder]
assert len(ladder) == 5 # deeper continuation rungs
assert trigs == sorted(trigs) and trigs[-1] >= 200
assert lasts == [False, False, False, False, True]
assert abs(sum(fracs) - 0.75) < 1e-9 # ≤ +0.75× base, still modest
def test_btc_bottom_maxhold_is_18_months():
p = get_exit_profile("btc_bottom_reversal_long")
assert p.max_hold_hours == 12960 # 540 days ≈ 18 months
def _peak_trail_floor(peak_pp: float):
start, dd = sys2_peak_trail()
if peak_pp < start:
return None
return ((1.0 + peak_pp / 100.0) * (1.0 - dd) - 1.0) * 100.0
def test_peak_trail_scale_invariant_and_never_loosens():
start, dd = sys2_peak_trail()
# Inactive below the start threshold.
assert _peak_trail_floor(start - 1) is None
# Self-scales: a +500% move locks far above the old fixed +95% top rung.
f500 = _peak_trail_floor(500.0)
assert f500 is not None and f500 > 300.0 # ≈ +320%
# A +900% move scales further still (not capped).
assert _peak_trail_floor(900.0) > f500
# Survives a normal bull pullback: at peak +120% the floor is a ≤30%
# PRICE drawdown from the peak, i.e. price 2.2→1.54 (gain +54%), so a
# typical 2025% dip (still >+54%) does NOT trip it.
f120 = _peak_trail_floor(120.0)
assert 50.0 < f120 < 60.0 # ((2.2*0.7)-1)*100 = 54
# Combined with the fixed rungs the floor only ratchets UP (max of both).
rung_at_160 = 95.0
assert max(rung_at_160, _peak_trail_floor(160.0)) == rung_at_160
assert max(95.0, _peak_trail_floor(400.0)) == _peak_trail_floor(400.0)
def test_sys2_risk_mode_params():
from app.services.signal_categories import (
sys2_normalize_mode, sys2_addon_ladder, sys2_derisk_ladder,
sys2_peak_trail,
)
assert sys2_normalize_mode("AGGRESSIVE") == "aggressive"
assert sys2_normalize_mode("garbage") == "standard"
assert sys2_normalize_mode(None) == "standard"
# Leverage default depends on mode; explicit value still clamped.
assert sys2_effective_leverage(None, "standard") == 2
assert sys2_effective_leverage(None, "aggressive") == 8
assert sys2_effective_leverage(99, "aggressive") == 10
# Aggressive pyramiding: earlier + heavier, ≤ +1.5× base, one final.
ag = sys2_addon_ladder("aggressive")
st = sys2_addon_ladder("standard")
assert [t for t, _, _ in ag] == [15.0, 35.0, 60.0, 100.0, 160.0]
assert abs(sum(f for _, f, _ in ag) - 1.50) < 1e-9
assert [x for _, _, x in ag][-1] is True
assert ag != st # genuinely different
assert abs(sum(f for _, f, _ in st) - 0.75) < 1e-9 # standard unchanged
# Aggressive de-risk keeps a bigger runner (¼/¼/½) but final still FULL.
agd = sys2_derisk_ladder(8, "aggressive")
assert [round(f, 4) for _, f, _ in agd] == [0.25, 0.25, 0.5]
assert agd[-1][2] is True # final = full close
std_d = sys2_derisk_ladder(8, "standard")
assert abs(std_d[0][1] - 1.0 / 3.0) < 1e-9 # standard unchanged
assert std_d[-1][2] is True
# Aggressive peak-trail: earlier start, wider give-back.
assert sys2_peak_trail("aggressive") == (60.0, 0.42)
assert sys2_peak_trail("standard") == (80.0, 0.30)
assert sys2_peak_trail() == (80.0, 0.30) # default = standard
# Safety invariant holds in BOTH modes: final de-risk rung is the
# protective level (inside liquidation) and is a full close.
for m in ("standard", "aggressive"):
for lev in (2, 5, 8, 10):
lad = sys2_derisk_ladder(lev, m)
assert lad[-1][2] is True
assert -lad[-1][0] < sys2_approx_liquidation_pct(lev)
def test_register_trade_default_peak_is_zero():
from app.services.tp_sl_monitor import register_trade, _watched, unregister
tid = 990012
try:
register_trade(
trade_id=tid, wallet="0xabc", api_key="k", leverage=3,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=6.0,
)
assert _watched[tid].peak_gain_pct == 0.0
finally:
unregister(tid)
def test_register_trade_grow_mode_default_off_and_settable():
"""Per-trade Grow defaults OFF (pyramiding opt-in) and is settable."""
from app.services.tp_sl_monitor import register_trade, _watched, unregister
a, b = 990021, 990022
try:
register_trade(
trade_id=a, wallet="0xabc", api_key="k", leverage=3,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=6.0,
)
assert _watched[a].grow_mode is False # default OFF
register_trade(
trade_id=b, wallet="0xabc", api_key="k", leverage=2,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=35.0,
grow_mode=True,
)
assert _watched[b].grow_mode is True
finally:
unregister(a); unregister(b)
def test_auto_trade_gate_skips_when_off():
"""The master gate: process_post must NOT open a trade when the sub has
auto_trade falsy (the signal Post is still created upstream by ingest)."""
import inspect
from app.services import bot_engine
# The per-subscriber executor holds the gate: keyed off auto_trade,
# returns (no trade opened) when off.
gate = inspect.getsource(bot_engine._execute_for_subscriber)
assert 'sub.get("auto_trade")' in gate
assert "Auto-Trade OFF" in gate
assert "return" in gate.split('sub.get("auto_trade")')[1][:260]
# process_post builds the snapshot carrying auto_trade so the gate reads it.
pp = inspect.getsource(bot_engine.process_post)
assert "auto_trade=bool(s.auto_trade)" in pp