fix: pre-launch hardening — HYPE price feed, KOL wallet cleanup, Telegram Trump alert, rate limiting, brittle test

Batch of the pre-launch audit campaign (BUG-01…14 plus three new features):

Pricing / TP-SL protection
- Add app/services/hl_price_feed.py: supplemental HL allMids poller for
  HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store +
  tp_sl_monitor.on_price_tick so bot trades on these assets keep full
  stop-loss / take-profit / trailing protection instead of max-hold only.
- Wire feed into main.py lifespan (startup task + graceful shutdown cancel).

Telegram
- Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump
  posts with no directional signal (relevant=True, signal=hold) now alert
  the public channel only (no per-subscriber noise).
- Rate limiter (slowapi) on the API; assorted bot/digest fixes.

KOL on-chain
- seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate
  orphaned wallets (handle not in KOL_FEEDS → can never produce divergence)
  so the scanner stops burning cycles on them.

Tests / misc
- Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses
  realistic ms timestamps so the in-progress-day drop fires, matching the
  fetcher's bar count (was 0.3179 vs 0.3178 off-by-one).
- Refresh stale notify_signal comment in truth_social.py.

Frontend reduce-action type fix lives in the sibling repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 11:57:19 +08:00
parent 6471e44aac
commit d6c802ef26
40 changed files with 1833 additions and 209 deletions
+116 -23
View File
@@ -46,12 +46,19 @@ _background_tasks: set = set()
# Per-wallet locks for the open-position critical section.
# Prevents two concurrent signals from both passing the daily-budget check
# before either trade is written to DB (TOCTOU race).
# Cap at 512 wallets; evict the oldest entry when full (simple FIFO approximation
# — a wallet that hasn't traded in a long time doesn't need a warm lock).
_WALLET_LOCK_MAX = 512
_wallet_open_locks: Dict[str, asyncio.Lock] = {}
def _wallet_lock(wallet: str) -> asyncio.Lock:
lock = _wallet_open_locks.get(wallet)
if lock is None:
if len(_wallet_open_locks) >= _WALLET_LOCK_MAX:
# Evict the first (oldest) key — dict preserves insertion order in Python 3.7+
oldest = next(iter(_wallet_open_locks))
del _wallet_open_locks[oldest]
lock = asyncio.Lock()
_wallet_open_locks[wallet] = lock
return lock
@@ -375,13 +382,21 @@ async def _execute_for_subscriber(
# trade is written to DB (TOCTOU race under asyncio.gather).
async with _wallet_lock(wallet):
# Re-check daily budget INSIDE the lock so it's atomic with the open.
# Budget is SPLIT between the two systems so a Trump scalp can't eat
# the allocation reserved for a once-a-year reversal signal.
# System-2 is now MANAGE-ONLY (no auto-open), so the budget split is
# only meaningful when a sys2 auto-open path could actually run. For
# System-1 (Trump), give the full daily budget — sys2_pct is no longer
# "reserved" for auto-opens that can't happen. Users who want a tighter
# Trump cap can lower daily_budget_usd directly.
total_cap = sub.get("daily_budget_usd")
if total_cap is not None and total_cap > 0:
sys2_pct = sub.get("sys2_budget_pct", 0.7)
is_s2 = bool(sub.get("_is_system_2"))
daily_cap = total_cap * (sys2_pct if is_s2 else (1.0 - sys2_pct))
# sys2 auto-opens are disabled; no System-2 trades will ever run
# this code path (process_post returns early for sys2 sources).
# Keep the split logic here so it would still work correctly if
# sys2 auto-open is ever re-enabled via an ADR, but for sys1
# (Trump) use the full cap rather than the 30% remainder.
daily_cap = total_cap * (sys2_pct if is_s2 else 1.0)
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
from app.models import Post as _P
from app.services.signal_categories import SYSTEM_2_SOURCES
@@ -404,7 +419,7 @@ async def _execute_for_subscriber(
if spent + sized_position_usd > daily_cap:
logger.info("Sub %s [%s] daily budget reached: spent=%.2f + new=%.2f > cap=%.2f (%.0f%% of %.2f)",
wallet, _system, spent, sized_position_usd, daily_cap,
(sys2_pct if is_s2 else 1.0 - sys2_pct) * 100, total_cap)
(sys2_pct if is_s2 else 100.0), total_cap)
return
# ── System-2 correlation / concentration cap ───────────────────────
@@ -685,12 +700,35 @@ async def partial_derisk(
# `frac_of_original` of the ORIGINAL notional.
frac_of_current = max(0.0, min(1.0, frac_of_original / rem_frac))
# ── Pre-claim the step BEFORE touching HL (BUG-03 analog) ───
# If we call HL first and THEN commit, a db.commit() failure
# leaves derisk_steps_done unchanged → next tick re-triggers
# reduce_position on a SMALLER position → double-reduce.
# Pre-claiming burns the step on DB failure but that's safer
# than over-reducing a live position. A burned step leaves
# the position slightly larger than the ladder intended but
# the NEXT rung still fires correctly.
claim = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.derisk_steps_done == step_idx)
.values(derisk_steps_done=step_idx + 1)
)
await db.commit()
if claim.rowcount == 0:
# Another coroutine already advanced past this step.
return True
if trade.hl_order_id == "paper":
from app.services.price_store import price_store
fill = price_store.latest_price(asset)
if not fill:
logger.error("Paper de-risk: no %s price, retry trade %d", asset, trade_id)
return False
logger.error("Paper de-risk: no %s price, skip trade %d step %d",
asset, trade_id, step_idx)
# Step already claimed — don't retry (would skip the
# next rung). Remaining_fraction stays at pre-reduce
# value; reconciler will catch the drift if material.
return True
closed_frac_of_current = frac_of_current
else:
trader = HyperliquidTrader(
@@ -708,7 +746,16 @@ async def partial_derisk(
fill = r.get("fill_price")
closed_frac_of_current = r.get("closed_fraction") or 0.0
if not fill or closed_frac_of_current <= 0:
return False # no fill — retry next tick
# HL returned no fill — position is unchanged on HL but
# derisk_steps_done is already incremented. Log loudly
# so the operator can investigate; returning True skips
# a double-reduce at the cost of this rung being silent.
logger.error(
"De-risk step %d trade %d: HL returned no fill "
"(step already claimed). Position unchanged on HL.",
step_idx, trade_id,
)
return True
# Fraction of ORIGINAL notional actually shed this step.
closed_frac_of_original = closed_frac_of_current * rem_frac
@@ -719,16 +766,27 @@ async def partial_derisk(
fees = slice_usd * HL_TAKER_FEE_RATE * 2
slice_pnl = slice_usd * signed_pct - fees
trade.realized_partial_pnl_usd = round((trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4)
trade.remaining_fraction = max(0.0, round(rem_frac - closed_frac_of_original, 6))
trade.derisk_steps_done = step_idx + 1
await db.commit()
# Second DB write: update PnL + remaining fraction now that
# we know the actual fill. derisk_steps_done is already committed.
async with AsyncSessionLocal() as post_db:
post_row = await post_db.execute(
select(BotTrade).where(BotTrade.id == trade_id)
)
post_trade = post_row.scalar_one_or_none()
if post_trade is not None and post_trade.closed_at is None:
post_trade.realized_partial_pnl_usd = round(
(post_trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4
)
post_trade.remaining_fraction = max(
0.0, round((post_trade.remaining_fraction or 1.0) - closed_frac_of_original, 6)
)
await post_db.commit()
logger.warning(
"De-risk step %d trade %d (%s): closed %.1f%% of original "
"@ %.2f, slice PnL %.2f, remaining %.1f%% (reason=%s)",
"@ %.2f, slice PnL %.2f (reason=%s)",
step_idx + 1, trade_id, asset, closed_frac_of_original * 100,
fill, slice_pnl, trade.remaining_fraction * 100, reason,
fill, slice_pnl, reason,
)
return True
except Exception as e:
@@ -799,6 +857,20 @@ async def pyramid_add(
if not trend_confirmed(closes, highs, px):
return (False, None, None) # not a confirmed uptrend yet
# Pre-claim the step BEFORE touching HL (BUG-03 analog).
# Prevents double-add when HL open_position succeeds but the
# subsequent DB commit fails — the next retrigger sees the step
# already claimed and returns early instead of re-opening.
claim = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.addon_steps_done == step_idx)
.values(addon_steps_done=step_idx + 1)
)
await db.commit()
if claim.rowcount == 0:
return (True, None, None) # already claimed by another coroutine
if trade.hl_order_id == "paper":
fill = px
actual_add_usd = add_usd # synthetic full fill
@@ -822,9 +894,15 @@ async def pyramid_add(
old_notional = trade.size_usd or base
new_notional = old_notional + actual_add_usd
blended = (old_notional * trade.entry_price + actual_add_usd * fill) / new_notional
trade.entry_price = round(blended, 6)
trade.size_usd = round(new_notional, 2)
trade.addon_steps_done = step_idx + 1
# addon_steps_done already committed via pre-claim above
await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.values(
entry_price=round(blended, 6),
size_usd=round(new_notional, 2),
)
)
await db.commit()
logger.warning(
@@ -884,6 +962,9 @@ async def close_and_finalize(
# Either someone else closed it, or (automated path) the
# user released it between the price-tick capture and
# this close attempt. Either way: bail without touching HL.
# Clean up the lock we just created so _close_locks doesn't
# accumulate one entry per losing-racer per trade_id.
_close_locks.pop(trade_id, None)
return
# Reload the row we just claimed
@@ -1001,15 +1082,27 @@ async def close_and_finalize(
# Run after PnL is written. If daily DD or consecutive-loss
# threshold hit, trip the breaker — that nulls manual_window
# and blocks new trades for CB_LOCKOUT_HOURS.
# Infer which system this trade belonged to from its trigger
# post's source, so the right (independent) breaker is checked.
# Infer which system this trade belonged to so the right
# independent circuit breaker is checked.
#
# Three hl_order_id shapes:
# - integer string → System-1 auto-open (Trump)
# - "paper" → paper-mode (System-1 or legacy sys2)
# - "adopted:<ts>" → System-2 adopted position
# Adopted trades have trigger_post_id=NULL, so querying
# Post.source returns nothing and would incorrectly identify
# them as sys1. Check the hl_order_id prefix FIRST.
from app.services.circuit_breaker import check_and_trip
from app.services.signal_categories import SYSTEM_2_SOURCES
_src_row = await db.execute(
select(Post.source).where(Post.id == trade.trigger_post_id)
)
_src = (_src_row.scalar_one_or_none() or "").lower()
_sys = "sys2" if _src in SYSTEM_2_SOURCES else "sys1"
_is_adopted = (trade.hl_order_id or "").startswith("adopted:")
if _is_adopted or trade.sys2_mode is not None:
_sys = "sys2"
else:
_src_row = await db.execute(
select(Post.source).where(Post.id == trade.trigger_post_id)
)
_src = (_src_row.scalar_one_or_none() or "").lower()
_sys = "sys2" if _src in SYSTEM_2_SOURCES else "sys1"
cb_reason = await check_and_trip(wallet, db, _sys)
if cb_reason:
logger.warning("Circuit breaker [%s] tripped for %s: %s",