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
+430
View File
@@ -0,0 +1,430 @@
"""
Two trading systems, one execution layer.
This module is the formal boundary between:
SYSTEM 1 — Trump (event-driven scalp)
source == "truth". Immediate market reaction to a post. Tight stops,
short hold, time-stop if no move. Uses the USER's configured exit
params (take_profit_pct / stop_loss_pct / trailing_*). Goes through
the Trump-tuned regime filter (thin-liquidity hours, recent-move,
vol-contraction).
SYSTEM 2 — Bitcoin Bottom (low-frequency bottom-reversal long)
source == "btc_bottom_reversal". Multi-week holds, wide trailing,
signal-invalidation exits.
Exit params come from THIS module (per category), NOT the user.
BYPASSES the Trump regime filter — those gates actively reject valid
reversal setups (a reclaim day IS a >5% move; reversals happen on
volatility EXPANSION not contraction).
The two systems share only the low-level execution plumbing (HL connector,
position monitor, reconciler, DB). Everything strategic is separated here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# Sources that belong to System 2. Everything else is either System 1 ("truth")
# or unsupported for live trading.
SYSTEM_2_SOURCES = {
"btc_bottom_reversal",
}
SYSTEM_1_SOURCES = {"truth"}
SUPPORTED_TRADING_SOURCES = SYSTEM_1_SOURCES | SYSTEM_2_SOURCES
def is_system_2(source: str) -> bool:
"""True if this signal should run through the reversal pipeline."""
return (source or "").lower() in SYSTEM_2_SOURCES
def is_supported_trading_source(source: str) -> bool:
"""Fail closed for unknown ingest sources instead of treating them as Trump."""
return (source or "").lower() in SUPPORTED_TRADING_SOURCES
def system2_display_name() -> str:
return "Bitcoin Bottom"
def system2_min_confidence() -> int:
return 85
# ── System 1 (Trump) — REPOSITIONED ─────────────────────────────────────────
# Originally "high-frequency, many small bets". Repositioned per operator
# decision to: LOW frequency, SELECTIVE, tight stop, ≥30-min holds.
#
# - Don't trade every post. A Trump trade puts the system on cooldown so
# the next N hours of posts are ignored — only the FIRST qualifying
# high-conviction post in a quiet window gets acted on.
# - Raise the confidence floor: only the very top posts clear the bar.
# - Tight stop, ALWAYS active (capital protection — a post that triggers
# an immediate -1.5% means we read it wrong, get out).
# - But on the winning side, hold ≥30 min: suppress take-profit / trailing
# exits until the floor elapses so we don't scalp out of a developing
# move. Asymmetric by design: losers cut fast, winners get room.
TRUMP_MIN_CONFIDENCE = 88 # was effectively the user's 70-85 setting
TRUMP_COOLDOWN_HOURS = 12 # after a Trump trade, ignore further Trump
# signals this long (the "don't trade every
# opportunity" lever)
TRUMP_MIN_HOLD_MINUTES = 30 # no TP / trailing exit before this; hard SL
# stays active throughout
TRUMP_STOP_LOSS_PCT = 1.5 # tight, kept
TRUMP_MAX_HOLD_HOURS = 6 # backstop force-close
@dataclass(frozen=True)
class CategoryExit:
"""Exit profile for a System-2 signal category.
These OVERRIDE the user's per-subscription exit params. Rationale: the
user picks risk for the Trump scalp; the reversal system's risk is a
property of the SIGNAL TYPE (an RSI capitulation reversal needs 60 days
to play out no matter what the user set their Trump stop-loss to).
Fields:
stop_loss_pct : hard stop, % adverse in position direction
trailing_activate_at_pct : peak gain that arms the trailing stop
trailing_stop_pct : retrace-from-peak that closes once armed
max_hold_hours : backstop force-close
time_stop_hours : if position is still ~flat after this many
hours, the thesis is slow/dead — close.
None = no time stop (pure trend capture).
invalidation : symbolic fast-exit condition. Interpreted
by tp_sl_monitor. None = stop-loss only.
"below_entry" → exit if price crosses back
through the entry (signal thesis dead).
"""
stop_loss_pct: float
trailing_activate_at_pct: Optional[float]
trailing_stop_pct: Optional[float]
max_hold_hours: int
time_stop_hours: Optional[int] = None
invalidation: Optional[str] = None
# Staged stop-loss ladder (System-2, optional). A list of
# (peak_gain_trigger_pct, new_stop_floor_pct) rungs. As the trade's PEAK
# gain crosses each trigger, the stop FLOOR ratchets up to the rung's
# value (signed gain% vs entry: negative = still a loss, 0 = breakeven,
# positive = locked-in profit). The position is NEVER closed for hitting
# a profit target — it only ever exits when price falls back to the
# current staged floor (or max-hold). This is a pure staged stop, not a
# take-profit and not a from-peak trailing stop. When set, it OVERRIDES
# take_profit_pct + trailing_* for that trade.
stop_ladder: Optional[list] = None
# ── Per-category exit profiles ──────────────────────────────────────────────
# Tuned to each signal's natural time horizon. These are STARTING points —
# refine against forward-test data, not intuition.
_CATEGORY_EXITS: dict[str, CategoryExit] = {
# Weekly RSI capitulation reversal — slowest, biggest target.
"rsi_extreme_reversal": CategoryExit(
stop_loss_pct=8.0, trailing_activate_at_pct=15.0,
trailing_stop_pct=6.0, max_hold_hours=1440, # 60 days
),
# 200d SMA reclaim — trend change. Fast exit if it loses the SMA again.
"sma_reclaim": CategoryExit(
stop_loss_pct=6.0, trailing_activate_at_pct=12.0,
trailing_stop_pct=5.0, max_hold_hours=2160, # 90 days
invalidation="below_entry", # reclaim failed → thesis dead
),
# Funding extreme unwind — faster, days not weeks.
"funding_extreme_reversal": CategoryExit(
stop_loss_pct=4.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=5.0, max_hold_hours=720, # 30 days
time_stop_hours=240, # 10d flat → squeeze didn't materialise
),
# BTC bottom-reversal long — 2-of-3 price confluence (AHR999 + 200WMA +
# Pi Cycle Bottom). NO take-profit, NO from-peak trailing. The ONLY exit
# is a STAGED stop-loss ("阶段止损") that ratchets up as the trade's peak
# gain crosses each rung, plus the 120-day max-hold backstop.
#
# The BASE catastrophic floor is NOT fixed here — it is derived per-trade
# from the chosen System-2 leverage (sys2_protective_stop_pct), so it
# always sits inside the exchange liquidation line. These rungs only
# ratchet that floor UP as peak gain grows (they never loosen it):
# peak ≥ 20% → stop -12%
# peak ≥ 40% → stop 0% (breakeven — free trade)
# peak ≥ 70% → stop +25% (lock profit)
# peak ≥ 110% → stop +55%
# peak ≥ 160% → stop +95%
#
# Rungs are deliberately far apart so normal post-bottom volatility does
# not knock it out, and it never sells just because a target was hit.
"btc_bottom_reversal_long": CategoryExit(
stop_loss_pct=35.0, # fallback only; bot_engine overrides per-lev
trailing_activate_at_pct=None,
trailing_stop_pct=None,
max_hold_hours=12960, # 18 months — a cycle bull runs 618mo;
# the ratchet/peak-trail is the real exit,
# the clock is just a far backstop.
invalidation=None,
stop_ladder=[
(20.0, -12.0),
(40.0, 0.0),
(70.0, 25.0),
(110.0, 55.0),
(160.0, 95.0),
],
),
# VCP breakout — short-term continuation.
"vcp_breakout": CategoryExit(
stop_loss_pct=3.0, trailing_activate_at_pct=6.0,
trailing_stop_pct=2.5, max_hold_hours=168, # 7 days
time_stop_hours=48,
),
}
# Fallback for a System-2 signal whose category isn't explicitly mapped —
# conservative medium profile so an un-mapped scanner doesn't trade huge.
_SYSTEM_2_DEFAULT = CategoryExit(
stop_loss_pct=5.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=4.0, max_hold_hours=336, # 14 days
time_stop_hours=120,
)
def get_exit_profile(category: Optional[str]) -> CategoryExit:
"""Resolve a System-2 category to its exit profile. Unknown → default."""
if not category:
return _SYSTEM_2_DEFAULT
return _CATEGORY_EXITS.get(category.lower(), _SYSTEM_2_DEFAULT)
# ── System-2 dynamic leverage ───────────────────────────────────────────────
# The user picks System-2 leverage freely. The protective stop is then
# auto-scaled to stay INSIDE the exchange liquidation line, so the position
# is de-risked by our own monitor and is never liquidated by the exchange.
#
# liquidation distance (price move) ≈ 100 / leverage (%)
# protective full-exit stop = 85% of that (15% maint/fee/funding buffer)
# …but never wider than SYS2_MAX_STOP_PCT — the bottom thesis only needs to
# tolerate a ~30% wick; risking more buys nothing.
#
# Net effect:
# lev ≤ 2x → stop = 35% (full bottom-wick tolerance, the original design)
# lev = 3x → stop ≈ 28%
# lev = 5x → stop ≈ 17%
# lev = 10x→ stop ≈ 8.5% (you WILL get shaken out by a normal wick — shown
# to the user as the explicit cost of high leverage)
SYS2_DEFAULT_LEVERAGE = 2
SYS2_MIN_LEVERAGE = 1
SYS2_MAX_LEVERAGE = 10
SYS2_MAX_STOP_PCT = 35.0
SYS2_LIQ_BUFFER = 0.85 # exit at 85% of the way to liquidation
# ── System-2 risk MODE ──────────────────────────────────────────────────────
# Two opt-in profiles, frozen onto the trade at signal time. STANDARD is the
# tuned cycle-rider (low leverage, survive bull corrections). AGGRESSIVE is a
# separately-funded high-risk/high-explosiveness sleeve: high leverage, heavier
# + earlier pyramiding, wider peak-trail, lighter early de-risk. BOTH keep the
# two safety invariants: (1) final de-risk rung is a full close INSIDE the
# liquidation line — never exchange-liquidated; (2) post-pyramid breakeven
# floor — a winner can't become a loser.
SYS2_MODE_STANDARD = "standard"
SYS2_MODE_AGGRESSIVE = "aggressive"
SYS2_MODES = (SYS2_MODE_STANDARD, SYS2_MODE_AGGRESSIVE)
# Default leverage when the user hasn't set an explicit sys2_leverage.
SYS2_MODE_DEFAULT_LEV = {SYS2_MODE_STANDARD: 2, SYS2_MODE_AGGRESSIVE: 8}
def sys2_normalize_mode(mode: Optional[str]) -> str:
m = (mode or "").strip().lower()
return m if m in SYS2_MODES else SYS2_MODE_STANDARD
def sys2_effective_leverage(value: Optional[int],
mode: Optional[str] = SYS2_MODE_STANDARD) -> int:
"""Resolve + clamp System-2 leverage. None → the mode's default
(standard 2×, aggressive 8×). Always clamped to [1, 10]."""
m = sys2_normalize_mode(mode)
default = SYS2_MODE_DEFAULT_LEV[m]
if value is None:
return default
try:
v = int(value)
except (TypeError, ValueError):
return default
return max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, v))
def sys2_protective_stop_pct(leverage: int) -> float:
"""Protective full-exit distance (positive %), auto-scaled to leverage so
it always triggers INSIDE the exchange liquidation line."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
liq_distance_pct = 100.0 / lev
return round(min(SYS2_MAX_STOP_PCT, SYS2_LIQ_BUFFER * liq_distance_pct), 2)
def sys2_approx_liquidation_pct(leverage: int) -> float:
"""Rough exchange liquidation distance (positive %) for UX display."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
return round(100.0 / lev, 2)
# Staged de-risk ("分段式减仓"): instead of one full exit at the protective
# stop, scale OUT in three steps as the trade moves against us. The final
# step is exactly the Phase-1 protective level P (inside liquidation), so the
# never-exchange-liquidated guarantee is preserved — we just bleed out in
# thirds rather than all at once.
#
# 0.60·P → close 1/3 of the original notional
# 0.80·P → close another 1/3
# 1.00·P → close the remaining 1/3 (full exit; same safety as Phase 1)
SYS2_DERISK_FRACTIONS = (0.60, 0.80, 1.00)
# Pyramiding ("做对了往上加仓"): when the bottom call is RIGHT and the trend
# confirms, scale INTO the winner to amplify the move. Mirror of the de-risk
# ladder. Conservative sizing (user-selected): adds at most +0.6× the base
# notional. Each rung requires BOTH a peak-gain trigger AND a structural
# trend confirmation (price ≥ 200d SMA AND at a fresh local high), checked at
# execution time. Pyramiding is DISABLED once any de-risk step has fired
# (a trade that went underwater is not a clean uptrend to add into).
# Extended for a cycle bull: deeper continuation rungs so a multi-x move is
# actually scaled. Each add still needs structural confirmation (price ≥ 200d
# SMA AND a fresh high) so the deep rungs only fire in a genuine sustained
# uptrend, never on a chop fakeout. Total adds ≤ +0.75× base — still well
# inside the 8× notional cap; amplification stays conservative.
SYS2_ADDON_PEAK_TRIGGERS = (25.0, 50.0, 85.0, 140.0, 220.0) # peak-gain % vs blended entry
SYS2_ADDON_FRACTIONS = (0.30, 0.20, 0.10, 0.10, 0.05) # of ORIGINAL base notional
# AGGRESSIVE sleeve: earlier + heavier adds (≤ +1.50× base), so a clean
# multi-x run is meaningfully compounded. Still gated by the same structural
# confirmation (≥200d SMA + fresh high), still inside the per-trade 8×
# notional cap, still floored at breakeven once any add fills.
SYS2_AGGR_ADDON_PEAK_TRIGGERS = (15.0, 35.0, 60.0, 100.0, 160.0)
SYS2_AGGR_ADDON_FRACTIONS = (0.50, 0.40, 0.30, 0.20, 0.10)
# AGGRESSIVE de-risk: shed less early (¼/¼) so a normal correction doesn't
# gut the runner; final rung still a FULL close at the protective level.
SYS2_AGGR_DERISK_STEP_FRACS = (0.25, 0.25, 0.50)
SYS2_STD_DERISK_STEP_FRACS = (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
# Peak-% trailing for the parabolic top. Below the start threshold the fixed
# stop_ladder rungs govern; at/above it the floor also trails the PEAK PRICE
# by at most SYS2_PEAK_TRAIL_DD (price drawdown, scale-invariant) so a +500%
# move isn't capped at the +95% rung, while a normal ~2530% bull correction
# still doesn't knock it out. Floor = max(rung floor, peak-trail floor).
SYS2_PEAK_TRAIL_START_PCT = 80.0 # activate once peak gain ≥ +80%
SYS2_PEAK_TRAIL_DD = 0.30 # give back at most 30% of peak PRICE
# AGGRESSIVE: let it run longer before the trailing top kicks in, and give
# back more, so a multi-x parabolic isn't cut early by a mid-run shakeout.
SYS2_AGGR_PEAK_TRAIL_START_PCT = 60.0
SYS2_AGGR_PEAK_TRAIL_DD = 0.42
def sys2_addon_ladder(mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Pyramiding rungs: (peak_gain_trigger_pct, frac_of_base, is_last)."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
trigs, fracs = SYS2_AGGR_ADDON_PEAK_TRIGGERS, SYS2_AGGR_ADDON_FRACTIONS
else:
trigs, fracs = SYS2_ADDON_PEAK_TRIGGERS, SYS2_ADDON_FRACTIONS
n = len(trigs)
return [(trigs[i], fracs[i], i == n - 1) for i in range(n)]
def sys2_peak_trail(mode: Optional[str] = SYS2_MODE_STANDARD) -> tuple:
"""(activate_peak_gain_pct, price_drawdown_frac) for the parabolic-top
trailing floor. Scale-invariant: works the same at +120% or +900%."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
return (SYS2_AGGR_PEAK_TRAIL_START_PCT, SYS2_AGGR_PEAK_TRAIL_DD)
return (SYS2_PEAK_TRAIL_START_PCT, SYS2_PEAK_TRAIL_DD)
def sys2_derisk_ladder(leverage: int,
mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Downside staged de-risk rungs for the given leverage.
Returns a list of (threshold_signed_pct, frac_of_ORIGINAL_to_close,
is_final), ordered by increasing adversity. threshold is NEGATIVE
(a loss in position direction). The executor converts frac-of-original
into frac-of-current using the trade's remaining_fraction.
"""
p = sys2_protective_stop_pct(leverage)
steps = (SYS2_AGGR_DERISK_STEP_FRACS
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE
else SYS2_STD_DERISK_STEP_FRACS)
return [
(round(-SYS2_DERISK_FRACTIONS[0] * p, 4), steps[0], False),
(round(-SYS2_DERISK_FRACTIONS[1] * p, 4), steps[1], False),
(round(-SYS2_DERISK_FRACTIONS[2] * p, 4), steps[2], True),
]
def get_stop_ladder(category: Optional[str]) -> Optional[list]:
"""Staged stop-loss ladder for a category, or None if it uses trailing.
Sorted ascending by trigger so the monitor can walk it cheaply. The
ladder is a CODE constant (not frozen per-trade): if it's retuned, open
positions adopt the new rungs on the next price tick / restart. That is
intentional — staged-stop levels are a property of the strategy, not of
an individual fill.
"""
prof = get_exit_profile(category)
if not prof.stop_ladder:
return None
return sorted(prof.stop_ladder, key=lambda r: r[0])
# ── System-2 position sizing ────────────────────────────────────────────────
# CRITICAL: System 2 must NOT use regime_filter.calculate_size_multiplier.
# That function asks "is volatility contracted? has price NOT moved?" — both
# FALSE during a reversal (vol expands, price already moved), which would
# SHRINK our rarest, highest-conviction trades. Sizing here is a function of
# (category conviction × signal confidence), nothing else.
# Base conviction per category. Rarer + cleaner setup → bigger base bet.
_CATEGORY_SIZE_BASE: dict[str, float] = {
"rsi_extreme_reversal": 2.5, # ~1-2×/yr/asset, deepest capitulation
"sma_reclaim": 2.0, # clean trend-change marker
"funding_extreme_reversal": 1.4, # more frequent, choppier unwinds
"btc_bottom_reversal_long": 2.3, # 2-of-3 price confluence (AHR999/200WMA/Pi)
"vcp_breakout": 1.0, # continuation, lowest conviction
}
_SYSTEM_2_SIZE_BASE_DEFAULT = 1.2
SYSTEM_2_SIZE_CAP = 4.0
# ── System-2 correlation / concentration cap ────────────────────────────────
# Every asset in REVERSAL_BASKET is high-beta crypto. When the market bottoms,
# RSI/SMA/funding reversals fire on BTC + ETH + SOL in the SAME week — these
# are NOT independent bets, they're one "crypto reversed" thesis. With Fix #1
# sizing each up to 4x, 3 correlated positions = ~10x effective exposure to a
# single macro call. Treat the whole System-2 book as one correlated bucket
# and cap it.
#
# - SYS2_MAX_CONCURRENT: at most this many open System-2 positions at once.
# Beyond this you're not diversifying, just leveraging the same thesis.
# - SYS2_MAX_OPEN_NOTIONAL_MULT: total open System-2 notional must stay
# under this × the wallet's base position_size_usd × default-ish size.
# Acts as a $ ceiling independent of how many positions.
SYS2_MAX_CONCURRENT = 3
SYS2_MAX_OPEN_NOTIONAL_MULT = 8.0 # × base position_size_usd
def system2_size_multiplier(category: Optional[str], confidence: int) -> float:
"""Position multiplier for a System-2 signal.
base(category) × confidence_scale, capped at SYSTEM_2_SIZE_CAP.
confidence_scale: 70→1.0, 85→1.3, 95→1.5, 100→1.6 (linear, floored at 1.0).
A scanner that emits confidence 88 for an rsi_extreme_reversal therefore
sizes 2.5 × 1.36 ≈ 3.4×. Tune the base table against forward-test data.
"""
base = _CATEGORY_SIZE_BASE.get((category or "").lower(), _SYSTEM_2_SIZE_BASE_DEFAULT)
conf_scale = 1.0 + max(0, confidence - 70) / 50.0
return round(min(base * conf_scale, SYSTEM_2_SIZE_CAP), 2)
# ── BTC bottom-reversal ─────────────────────────────────────────────────────
# The old MVRV-Z / STH-SOPR / drawdown state machine was REMOVED. It depended
# on paid on-chain data and was over-engineered. The strategy is now a pure
# 2-of-3 price confluence (AHR999 + 200-Week MA + Pi Cycle Bottom), implemented
# in app/services/bottom_indicators.py and driven by the btc_bottom_reversal
# scanner. There is no state machine and no on-chain dependency.