""" 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.