Files
trumpsignal-backend/app/services/adoption.py
T
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:16 +08:00

469 lines
19 KiB
Python
Raw 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.
"""Adopt / release flow for System-2 manage-only positions.
The user opens a position MANUALLY on Hyperliquid (any size / leverage).
They then ask the bot to manage it — either via the dashboard adoption
button or the Telegram /adopt command. From that moment on, tp_sl_monitor
runs the full System-2 lifecycle against it: 5-rung stop ladder, downside
de-risk, pyramid, peak-trail, full close.
`release` is the escape hatch. It marks released_at on the BotTrade row,
unregisters the watchdog, and leaves the HL position untouched — the user
takes back manual control without closing.
The flow is intentionally LIGHT:
- We don't validate signal timing (any HL position can be adopted)
- We don't enforce direction (both long and short work; ladder is signed)
- We DO enforce the same concurrency cap as the old auto-open path
(SYS2_MAX_CONCURRENT) — adopting unlimited correlated crypto-beta is
the same disaster regardless of who pulled the trigger
- We DO block paper-mode subscribers (paper has no HL state to read)
Race notes:
- Adopt creates the BotTrade row, then register_trade. If a price tick
arrives between those two events, no harm — register_trade is
idempotent on trade_id.
- Two concurrent adopts on the same wallet+asset are guarded by the
"already adopted?" check inside the wallet-keyed asyncio lock reused
from bot_engine.
"""
from __future__ import annotations
import asyncio
import logging
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription
from app.config import settings
from app.services.crypto import decrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
from app.services.signal_categories import (
get_exit_profile, get_stop_ladder,
sys2_normalize_mode, sys2_protective_stop_pct,
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
SYS2_MAX_CONCURRENT, SYS2_MODES, SYS2_MAX_LEVERAGE,
)
logger = logging.getLogger(__name__)
# Default category — the System-2 strategy is BTC bottom-reversal long.
# An adopted short or non-BTC asset still gets the same generous ladder
# (it's direction-agnostic), but we tag the category for telemetry.
ADOPTED_CATEGORY = "btc_bottom_reversal_long"
# Per-wallet asyncio lock to serialise adopt_position calls for the same
# wallet. Without this, two concurrent /adopt requests on the same wallet
# can both pass the duplicate-check and end up writing two BotTrade rows
# pointing at the same HL position — corrupting the ladder / de-risk math
# (each row would race to reduce the position). Process-local; with the
# atomic duplicate check inside the lock, multi-process deploys are still
# safe (losers would just see "already_adopted" on the second attempt).
#
# Capacity-capped at _ADOPT_LOCK_MAX (matches _WALLET_LOCK_MAX in bot_engine)
# to prevent unbounded growth if many unique wallets pass through. LRU
# eviction: evicting an in-flight lock is safe — the holder already holds a
# reference; new adopt for that wallet just gets a fresh lock.
_ADOPT_LOCK_MAX = 512
_adopt_locks: OrderedDict[str, asyncio.Lock] = OrderedDict()
def _wallet_adopt_lock(wallet: str) -> asyncio.Lock:
lock = _adopt_locks.get(wallet)
if lock is None:
if len(_adopt_locks) >= _ADOPT_LOCK_MAX:
# Evict LRU entry. The evicted lock object stays alive as long as
# any coroutine holds a reference to it — eviction only means it
# won't be reused, not that it's freed mid-acquire.
_adopt_locks.popitem(last=False)
lock = asyncio.Lock()
_adopt_locks[wallet] = lock
else:
# Promote to MRU position.
_adopt_locks.move_to_end(wallet)
return lock
# ── Errors ──────────────────────────────────────────────────────────────────
class AdoptionError(Exception):
"""User-facing adoption failure. .code is a stable short token suitable
for switching on in the API / Telegram layers; .message is the
human-readable text."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
# ── Reads ───────────────────────────────────────────────────────────────────
@dataclass
class HLPositionView:
"""Lightweight projection of a Hyperliquid open position, for adoption
pickers. Kept thin so the UI / Telegram layers don't depend on the
HL SDK's dict shape."""
asset: str
side: str # "long" | "short"
size_coins: float # absolute (always positive)
entry_price: float
leverage: int
size_usd: float # size_coins * entry_price
already_adopted: bool # there's a BotTrade open + not released for it
async def list_hl_positions(wallet: str) -> list[HLPositionView]:
"""Read the wallet's current HL open positions, annotated with whether
each is already under bot management. Raises AdoptionError on missing
subscription / missing key / HL read failure."""
wallet_l = wallet.lower().strip()
async with AsyncSessionLocal() as db:
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet_l)
)).scalar_one_or_none()
if sub is None:
raise AdoptionError("no_subscription",
"Wallet not subscribed. Open Settings on the dashboard first.")
if not sub.hl_api_key:
raise AdoptionError("no_hl_key",
"This wallet has no Hyperliquid API key on file. "
"Add one in Settings before adopting positions.")
# Adopted positions only — closed/released rows don't block re-adopt.
existing = (await db.execute(
select(BotTrade.asset).where(
BotTrade.wallet_address == wallet_l,
BotTrade.closed_at.is_(None),
BotTrade.released_at.is_(None),
)
)).scalars().all()
adopted_assets = {a.upper() for a in existing}
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
raise AdoptionError("key_decrypt_failed",
f"Could not decrypt the HL API key: {exc}")
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet_l,
leverage=int(sub.leverage or 2),
mainnet=settings.hl_mainnet,
)
try:
raw = await trader.get_open_positions()
except Exception as exc:
raise AdoptionError("hl_read_failed",
f"Could not read HL positions: {exc}")
out: list[HLPositionView] = []
for p in raw:
coin = (p.get("coin") or "").upper()
szi = float(p.get("szi") or 0.0)
entry = float(p.get("entry_px") or 0.0)
lev_info = p.get("leverage") or {}
# HL position.leverage shape is {"type": "isolated"|"cross", "value": N}
lev = int(lev_info.get("value") or sub.leverage or 2)
if szi == 0 or entry <= 0:
continue
out.append(HLPositionView(
asset = coin,
side = "long" if szi > 0 else "short",
size_coins = abs(szi),
entry_price = entry,
leverage = lev,
size_usd = round(abs(szi) * entry, 2),
already_adopted = coin in adopted_assets,
))
return out
# ── Adopt ───────────────────────────────────────────────────────────────────
@dataclass
class AdoptionResult:
trade_id: int
asset: str
side: str
entry_price: float
size_usd: float
leverage: int
mode: str
protective_stop: float # positive % distance
derisk_ladder: list # the rungs we just registered
stop_ladder: list # the upside ratchet rungs
async def adopt_position(wallet: str, asset: str, mode: str) -> AdoptionResult:
"""Read the user's current HL position for `asset`, create a BotTrade
row with the System-2 ladder for `mode`, and register it with the
watchdog. Raises AdoptionError on any failure path.
Wallet-level locking serialises concurrent adopts for the same wallet
so the duplicate / concurrency-cap checks below are race-free.
"""
wallet_l = wallet.lower().strip()
asset_u = asset.upper().strip()
mode_n = sys2_normalize_mode(mode)
if mode_n not in SYS2_MODES:
raise AdoptionError("bad_mode",
f"mode must be one of {SYS2_MODES}; got {mode!r}")
async with _wallet_adopt_lock(wallet_l):
return await _adopt_locked(wallet_l, asset_u, mode_n)
async def _adopt_locked(wallet_l: str, asset_u: str, mode_n: str) -> AdoptionResult:
async with AsyncSessionLocal() as db:
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet_l)
)).scalar_one_or_none()
if sub is None:
raise AdoptionError("no_subscription",
"Wallet not subscribed. Open Settings on the dashboard first.")
if not sub.hl_api_key:
raise AdoptionError("no_hl_key",
"This wallet has no Hyperliquid API key on file.")
# Paper-mode wallets have no real HL position to manage. The HL read
# below would return empty and surface a confusing "not_on_hl" error
# — better to reject explicitly with a clear cause up front.
if getattr(sub, "paper_mode", False):
raise AdoptionError("paper_mode",
"Adopt isn't available in paper mode — paper trades have "
"no Hyperliquid position to manage. Turn off paper mode in "
"Settings to use /adopt.")
# System-2 circuit breaker. Same gate the auto-open path used to run:
# if recent losses tripped the sys2 breaker, block new adoptions for
# the lockout window. Otherwise the breaker would be useless under
# the manage-only model.
from app.services.circuit_breaker import is_tripped as _cb_tripped
sub_snapshot = {
"sys2_cb_tripped_at": sub.sys2_cb_tripped_at,
"sys2_cb_reason": sub.sys2_cb_reason,
}
tripped, cb_msg = _cb_tripped(sub_snapshot, "sys2")
if tripped:
raise AdoptionError("circuit_breaker",
f"Adopt blocked: {cb_msg}. Turning Auto-Trade ON or arming "
"Manual Window in Settings clears the breaker.")
# Already managing this wallet+asset?
dup = (await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet_l,
BotTrade.asset == asset_u,
BotTrade.closed_at.is_(None),
BotTrade.released_at.is_(None),
)
)).scalar_one_or_none()
if dup is not None:
raise AdoptionError("already_adopted",
f"{asset_u} is already managed by the bot (trade #{dup.id}). "
"Send /release first if you want to re-adopt.")
# Wallet-level concurrency cap. Same reasoning as the auto-open
# path: every System-2 asset is correlated crypto-beta and 3
# simultaneous positions ≈ 1 leveraged macro bet, not 3 bets.
open_count = (await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet_l,
BotTrade.closed_at.is_(None),
BotTrade.released_at.is_(None),
)
)).scalars().all()
if len(open_count) >= SYS2_MAX_CONCURRENT:
raise AdoptionError("concurrency_cap",
f"You already have {len(open_count)} managed positions "
f"(cap is {SYS2_MAX_CONCURRENT}). /release one first.")
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
raise AdoptionError("key_decrypt_failed",
f"Could not decrypt the HL API key: {exc}")
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet_l,
leverage=int(sub.leverage or 2),
mainnet=settings.hl_mainnet,
)
try:
raw = await trader.get_open_positions()
except Exception as exc:
raise AdoptionError("hl_read_failed",
f"Could not read HL positions: {exc}")
target = next((p for p in raw if (p.get("coin") or "").upper() == asset_u),
None)
if target is None:
raise AdoptionError("not_on_hl",
f"No open {asset_u} position found on Hyperliquid. "
"Make sure you've filled the order, then try again.")
szi = float(target.get("szi") or 0.0)
entry = float(target.get("entry_px") or 0.0)
if szi == 0 or entry <= 0:
raise AdoptionError("bad_hl_state",
f"HL returned a zero-size or zero-price {asset_u} position. "
"Refresh and try again in a moment.")
side = "long" if szi > 0 else "short"
size_coins = abs(szi)
size_usd = round(size_coins * entry, 2)
lev_info = target.get("leverage") or {}
leverage = int(lev_info.get("value") or sub.leverage or 2)
# BUG-09 guard: sys2_protective_stop_pct clamps leverage to SYS2_MAX_LEVERAGE
# when computing the stop distance. If the actual position is above that cap
# the computed stop is WIDER than the real liquidation band — the user could
# get HL-liquidated before our stop fires. Reject up front with a clear
# error so the user knows to reduce leverage on HL before adopting.
if leverage > SYS2_MAX_LEVERAGE:
raise AdoptionError(
"leverage_too_high",
f"{asset_u} position is at {leverage}× leverage. "
f"The bot's System-2 strategy supports up to {SYS2_MAX_LEVERAGE}×. "
f"Reduce leverage on Hyperliquid to ≤{SYS2_MAX_LEVERAGE}× first, "
"then try /adopt again.",
)
# Build the System-2 exit profile against the ACTUAL HL leverage so the
# protective stop is always inside the real liquidation line. Identical
# math to bot_engine's old sys2 open path; reused so behaviour stays
# consistent across the two entry points.
exit_profile = get_exit_profile(ADOPTED_CATEGORY)
prot_stop = sys2_protective_stop_pct(leverage)
derisk = sys2_derisk_ladder(leverage, mode_n)
addon = sys2_addon_ladder(mode_n)
peak_trail = sys2_peak_trail(mode_n)
stop_ladder = get_stop_ladder(ADOPTED_CATEGORY)
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
async with AsyncSessionLocal() as db:
trade = BotTrade(
asset=asset_u,
side=side,
entry_price=entry,
wallet_address=wallet_l,
trigger_post_id=None, # adopted, not signal-triggered
opened_at=now_naive,
hl_order_id=f"adopted:{int(now_naive.timestamp())}",
size_usd=size_usd,
base_size_usd=size_usd,
sys2_mode=mode_n,
leverage=leverage,
# Freeze the System-2 exit profile on the row. No TP / no
# trailing-from-peak — pure staged stop + de-risk + peak-trail.
eff_take_profit_pct = None,
eff_stop_loss_pct = prot_stop,
eff_trailing_stop_pct = None,
eff_trailing_activate_pct = None,
eff_max_hold_hours = exit_profile.max_hold_hours,
eff_invalidation = None,
eff_invalidation_price = None,
eff_min_hold_until_ts = None,
)
db.add(trade)
await db.commit()
await db.refresh(trade)
trade_id = trade.id
# Register with the watchdog AFTER the row is committed so a tick fired
# in the next millisecond can find the trade on disk if it needs to.
from app.services.tp_sl_monitor import register_trade
register_trade(
trade_id=trade_id,
wallet=wallet_l,
api_key=api_key,
leverage=leverage,
asset=asset_u,
side=side,
entry_price=entry,
take_profit_pct=None,
stop_loss_pct=prot_stop,
trailing_stop_pct=None,
trailing_activate_at_pct=None,
invalidation=None,
invalidation_price=None,
min_hold_until_ts=None,
stop_ladder=stop_ladder,
derisk_ladder=derisk,
derisk_done=0,
addon_ladder=addon,
addon_done=0,
peak_trail=peak_trail,
grow_mode=False, # user opts a position into Grow explicitly
)
logger.warning(
"ADOPTED trade %d: wallet=%s asset=%s side=%s entry=%.4f size=%.2f "
"lev=%dx mode=%s prot_stop=%.2f%% derisk=%s",
trade_id, wallet_l, asset_u, side, entry, size_usd,
leverage, mode_n, prot_stop, derisk,
)
return AdoptionResult(
trade_id=trade_id, asset=asset_u, side=side, entry_price=entry,
size_usd=size_usd, leverage=leverage, mode=mode_n,
protective_stop=prot_stop, derisk_ladder=derisk,
stop_ladder=stop_ladder or [],
)
# ── Release ─────────────────────────────────────────────────────────────────
async def release_management(wallet: str, trade_id: int) -> dict:
"""Stop managing trade_id without closing the HL position. Marks
released_at, unregisters the watchdog. Idempotent — re-releasing a
released trade is a no-op."""
wallet_l = wallet.lower().strip()
async with AsyncSessionLocal() as db:
trade = (await db.execute(
select(BotTrade).where(BotTrade.id == trade_id)
)).scalar_one_or_none()
if trade is None:
raise AdoptionError("not_found", f"trade {trade_id} not found")
if trade.wallet_address.lower() != wallet_l:
raise AdoptionError("not_owner",
f"trade {trade_id} belongs to a different wallet")
if trade.closed_at is not None:
raise AdoptionError("already_closed",
f"trade {trade_id} is already closed — nothing to release")
if trade.released_at is not None:
# Idempotent — return success.
return {"status": "ok", "trade_id": trade_id,
"asset": trade.asset, "already_released": True}
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.values(released_at=now_naive)
)
await db.commit()
from app.services.tp_sl_monitor import unregister
unregister(trade_id)
logger.warning(
"RELEASED trade %d: wallet=%s asset=%s — bot stops managing, "
"HL position untouched.",
trade_id, wallet_l, trade.asset,
)
return {"status": "ok", "trade_id": trade_id,
"asset": trade.asset, "already_released": False}