Files
trumpsignal-backend/app/services/hl_price_feed.py
T
k 6876c0c280 fix: de-risk launch_seed, monitor price feeds, enforce single-process
Three pre-launch audit findings:

launch_seed.py — destructive cleanup is now OPT-IN
  Previously `launch_seed.py --yes` truncated KOL history (divergence /
  holdings / posts older than 30d) by default — an accidental run erased
  unrecoverable data, and abort_if_live_user_state() only guards on
  subscriptions/bindings, not KOL history. Now nothing is deleted unless
  --wipe is passed; the safe path is --seed-only (pure fetch). Bare/--yes
  without --wipe refuses and prints guidance.

/api/health/deep — monitor the price feeds, not just scrapers
  The deep healthcheck only watched the (redundant) Trump scrapers + DB, so a
  dead Binance/HL price feed — which silently stops ALL tp_sl_monitor stop-loss
  / take-profit firing on live trades — left health green. Added per-feed
  liveness (binance.last_tick_at, hl_price_feed.last_tick_at) with a 180s boot
  grace so startup doesn't false-503. Body now includes price_feeds[].

Single-process enforcement (multi-worker safety)
  The backend is single-process by design (in-memory scheduler, replay cache,
  tp_sl table, price_store). systemd unit lacked the --workers 1 + rationale
  that supervisor.conf already had; added it. Added a runtime advisory file
  lock (app.main._acquire_singleton_lock): only the leader starts background
  tasks; extra workers serve HTTP reads only and log CRITICAL. health/deep now
  reports is_leader so the misconfig is visible to monitors.

72 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:29:24 +08:00

150 lines
5.4 KiB
Python

"""Supplemental price feed for HL-native assets not listed on Binance.
Polls HL allMids endpoint every 2 seconds and pumps price_store +
tp_sl_monitor for assets in HL_PRICE_ASSETS. Runs alongside run_binance_ws().
WHY THIS EXISTS:
tp_sl_monitor.on_price_tick(asset, price) is the sole mechanism that fires
stop-loss, take-profit, and trailing-stop for live bot trades.
HL-native assets (HYPE, PURR, …) are not listed on Binance, so the
Binance WebSocket in binance.py cannot provide their prices.
Without this feed, any bot trade on an HL-native asset silently loses ALL
TP/SL protection — only the max_hold asyncio timer remains as an exit.
COVERAGE:
• Binance WS → BTC, ETH, SOL, TRUMP, BNB, DOGE, LINK, AAVE (ASSET_MAP)
• This feed → HYPE, PURR (HL_PRICE_ASSETS)
Adding a new HL-native asset:
1. Add its symbol (uppercase) to HL_PRICE_ASSETS below.
2. Verify the symbol appears in HL allMids by running:
curl -s -X POST https://api.hyperliquid.xyz/info \\
-H 'Content-Type: application/json' \\
-d '{"type":"allMids"}' | python3 -m json.tool | grep HYPE
"""
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from app.services.price_store import price_store
from app.ws.manager import manager
logger = logging.getLogger(__name__)
HL_API_URL = "https://api.hyperliquid.xyz/info"
# HL-native assets not covered by the Binance WS. Add new ones here as needed.
HL_PRICE_ASSETS: frozenset[str] = frozenset({"HYPE", "PURR"})
# Liveness signal for /api/health/deep. Set on every successful allMids poll.
# Stale → HYPE/PURR trades lose TP/SL just like a dead Binance feed would.
last_tick_at: Optional[datetime] = None
# Poll cadence — 2 s gives ~same freshness as a Binance 1-min kline close
# without hammering the free HL REST endpoint (30 req/min well within limit).
_POLL_INTERVAL = 2.0
# Exponential backoff caps: start at 5 s, max at 60 s, reset on clean loop.
_BACKOFF_START = 5
_BACKOFF_MAX = 60
async def run_hl_price_feed() -> None:
"""Long-running loop. Retries with exponential backoff on connection failure.
Designed to be created once as an asyncio task at startup and cancelled at
shutdown — matches the run_binance_ws() lifecycle pattern."""
logger.info("HL supplemental price feed starting for %s", sorted(HL_PRICE_ASSETS))
backoff = _BACKOFF_START
while True:
try:
await _poll_loop()
except asyncio.CancelledError:
logger.info("HL price feed cancelled — shutting down.")
return
except Exception as exc:
logger.warning(
"HL price feed outer error: %s — retrying in %ds", exc, backoff
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, _BACKOFF_MAX)
else:
backoff = _BACKOFF_START # reset on clean exit (shouldn't happen normally)
async def _poll_loop() -> None:
"""Inner loop: tick every 2 s. Individual tick failures are swallowed so
a transient HTTP 5xx doesn't break the whole loop."""
while True:
try:
await _tick()
except asyncio.CancelledError:
raise
except Exception as exc:
# Transient: network blip, HL rate limit, malformed JSON.
logger.debug("HL price tick error (suppressed): %s", exc)
await asyncio.sleep(_POLL_INTERVAL)
async def _tick() -> None:
"""Single price fetch + dispatch cycle for all HL_PRICE_ASSETS."""
now_ms = int(time.time() * 1000)
async with httpx.AsyncClient(timeout=4.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
mids: dict = r.json() # {"BTC": "74541.0", "HYPE": "13.5", …}
# Feed is alive the moment we successfully fetch mids, even if a specific
# asset is momentarily absent from the response.
global last_tick_at
last_tick_at = datetime.now(timezone.utc)
for asset in HL_PRICE_ASSETS:
raw: Optional[str] = mids.get(asset)
if raw is None:
# Asset not listed on HL yet — skip silently.
continue
try:
price = float(raw)
except (TypeError, ValueError):
logger.warning("HL price feed: bad value for %s: %r", asset, raw)
continue
if price <= 0:
continue
# Synthetic 1-minute candle aligned to the current minute bucket.
# All OHLC set to the mid price (no spread data from allMids).
# price_store.update() handles the same-bucket replace logic.
candle = {
"time": (now_ms // 60_000) * 60_000,
"open": price,
"high": price,
"low": price,
"close": price,
"volume": 0.0,
}
price_store.update(asset, candle)
# TP/SL + trailing-stop evaluation on every tick.
from app.services.tp_sl_monitor import on_price_tick
on_price_tick(asset, price)
# Price-impact peak tracker for Trump-post windows.
from app.services.price_impact_monitor import on_price_tick as pi_tick
pi_tick(asset, price, price, price)
# Live price broadcast to the frontend dashboard.
await manager.broadcast({
"type": "price",
"asset": asset,
"price": price,
"time": now_ms,
})