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:
+116
-23
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user