Files
k 5fb1d52026 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>
2026-05-25 00:52:56 +08:00

431 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.