5fb1d52026
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>
431 lines
21 KiB
Python
431 lines
21 KiB
Python
"""
|
||
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 6–18mo;
|
||
# 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 ~25–30% 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.
|