fix: complete v5 routing — API exposure, rescore persistence, leverage cap, key backup doc

Three plumbing fixes + one ops doc that close the gaps from the audit.

scripts/rescore_v5.py
  Was overwriting only signal/conf/reasoning/sentiment/relevant/
  prefilter_reason/analysis_version. Now also persists target_asset,
  category, expected_move_pct — without these the bot can't route
  rescored posts correctly (would silently fall back to BTC).

app/schemas.py + app/api/posts.py
  TrumpPost response model didn't expose target_asset/category/
  expected_move_pct, so the frontend had no way to display "this
  signal will trade SOL". Added the three fields + mapping in
  _post_to_schema(). Pre-v5 posts return null. No frontend changes
  yet — display work is a follow-up.

app/services/hyperliquid.py
  HL caps max leverage per asset (BTC/ETH 50×, SOL 20×, memes 3-5×).
  set_leverage() always tried to push self._leverage — if user set
  30× and bot routed to TRUMP, HL rejected the order and the trade
  silently dropped. Added _get_max_leverage() (queries meta()'s
  maxLeverage field) and _clip_leverage() that caps to HL's max.
  set_leverage now returns the effective leverage so callers can
  use it for notional sizing if needed.

deploy/ENCRYPTION_KEY_BACKUP.md
  Documented mandatory backup procedure for the symmetric key that
  encrypts every user's HL API key. Lost key = all users' bots dead
  with no recovery. Includes rotation procedure + quarterly test
  step + things-not-to-do list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-08 19:59:30 +08:00
parent 5d1bdc1ff2
commit b941223c88
5 changed files with 125 additions and 4 deletions
+4
View File
@@ -57,6 +57,10 @@ def _post_to_schema(post: Post) -> TrumpPost:
analysis_version=post.analysis_version,
relevant=post.relevant,
price_impact=price_impact,
# v5 routing fields — null for pre-v5 posts
target_asset=post.target_asset,
category=post.category,
expected_move_pct=post.expected_move_pct,
)
+7
View File
@@ -30,6 +30,13 @@ class TrumpPost(BaseModel):
analysis_version: Optional[str] = None
relevant: bool
price_impact: Optional[PriceImpact] = None
# v5 asset routing — what the bot will actually trade if signal != hold.
# target_asset = any HL perp ticker (BTC/ETH/SOL/TRUMP/...). category
# buckets the post's catalyst type. expected_move_pct = AI's own 1h-move
# estimate on target_asset. All three are null on pre-v5 posts.
target_asset: Optional[str] = None
category: Optional[str] = None
expected_move_pct: Optional[float] = None
model_config = {"from_attributes": True}
+35 -4
View File
@@ -74,6 +74,32 @@ class HyperliquidTrader:
pass
return SZ_DECIMALS_FALLBACK.get(coin, 4)
async def _get_max_leverage(self, coin: str) -> int:
"""Hyperliquid caps max leverage per asset (BTC/ETH 50×, SOL 20×,
memes typically 3-5×). Querying meta() returns each asset's
`maxLeverage`. We cache nothing — meta() is fast and HL can change
tiers. Returns a conservative 3 if lookup fails (memes default)."""
try:
meta = await self._run(self._info.meta)
for u in meta.get("universe", []):
if u.get("name") == coin:
ml = int(u.get("maxLeverage", 3))
return max(1, ml)
except Exception as exc:
logger.warning("_get_max_leverage failed for %s: %s", coin, exc)
return 3 # safe fallback for unknown / illiquid coin
async def _clip_leverage(self, coin: str, requested: int) -> int:
"""Cap user's requested leverage at the asset's HL max. Without
this, opening a 30× position on a meme (which caps at 3×) gets
rejected by HL and the trade is silently dropped."""
max_lev = await self._get_max_leverage(coin)
if requested > max_lev:
logger.info("Clipped leverage for %s: %d×%d× (HL max)",
coin, requested, max_lev)
return max_lev
return requested
async def _mid_price(self, coin: str) -> float:
mids = await self._run(self._info.all_mids)
price = float(mids.get(coin, 0))
@@ -113,18 +139,23 @@ class HyperliquidTrader:
logger.error("get_open_positions error: %s", exc)
raise
async def set_leverage(self, coin: str) -> None:
"""Set isolated leverage for the given coin."""
async def set_leverage(self, coin: str) -> int:
"""Set isolated leverage for the given coin, clipped to the asset's
HL maximum. Returns the actual leverage used (may be less than
self._leverage if asset capped). Caller should prefer this return
value when computing notional / position size."""
effective = await self._clip_leverage(coin, self._leverage)
try:
await self._run(
self._exchange.update_leverage,
self._leverage,
effective,
coin,
False, # is_cross=False → isolated margin
)
logger.info("Set leverage %dx for %s (isolated)", self._leverage, coin)
logger.info("Set leverage %dx for %s (isolated)", effective, coin)
except Exception as exc:
logger.warning("set_leverage error (non-fatal): %s", exc)
return effective
async def open_position(
self,