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,
+74
View File
@@ -0,0 +1,74 @@
# ENCRYPTION_KEY backup procedure — DO NOT SKIP
The `ENCRYPTION_KEY` env var encrypts every user's Hyperliquid API key
before it touches the database. If this key is **lost** or **rotated
without re-encrypting** the existing rows, every active user's bot
permanently stops trading — there is no recovery path. This is the
single most-load-bearing secret in the project.
## What it looks like
```
ENCRYPTION_KEY=1cf813d6790cc1b1c0981951703ca387d58321d81dc9b5b4982c07487edc478e
```
A 64-char hex string (32 bytes). Used by `app/services/crypto.py` via
Fernet-equivalent symmetric encryption.
## Backup requirements (mandatory before going live)
Store the production value in **at least two locations**, at least one
of which is **offline / non-cloud**:
### 1. Password manager (1Password / Bitwarden / KeePass)
- Item type: "Secure Note"
- Title: `TrumpSignal — ENCRYPTION_KEY (production)`
- Body: the 64-char hex value
- Add note: "Lost = every user's bot dead. No way to recover."
- Tag/folder for fast retrieval
### 2. Offline physical backup
Pick one (or do both):
- **Paper**: print the key on a single sheet, store in a locked drawer
/ safe / safety deposit box. Label: "TrumpSignal encryption key —
do not discard, do not photograph, do not type into computers."
- **Hardware key**: write to a USB drive that lives offline, kept in
the same safe. Label and date it.
### 3. (Optional) Co-founder / trusted-party split
If multiple humans run the project: split the key (Shamir's Secret
Sharing or just give halves to two people) so no single person can
walk off with it AND no single person can lose all copies.
## Before any rotation
Rotating the key is **not free**. To rotate:
1. Generate `NEW_KEY`.
2. **Decrypt every existing `subscriptions.hl_api_key` with the old
key**, re-encrypt with the new key, write back. (Script not
shipped — has to be written carefully.)
3. Only after the migration completes, swap env var and restart.
4. Update both backup locations with `NEW_KEY`.
5. Securely destroy the old key copies after a 30-day cool-off
(during which you confirm no users got broken).
If you skip step 2, every existing user's HL key becomes garbage on
the new server and their bot silently stops working.
## How to test backups (do this once a quarter)
1. Pull the key out of password manager → match against the env var
on the running server.
2. Pull the offline copy → match against the password-manager copy.
3. Confirm both match. If any drift, sync immediately — never assume
the running server has the "true" copy without verifying.
## What NOT to do
- ❌ Don't paste the key into Slack / Discord / email.
- ❌ Don't commit the key to git (`.env` is in `.gitignore`, but
double-check before any `git add -A`).
- ❌ Don't share via screenshot — keys can be OCR'd from anywhere.
- ❌ Don't store only on the server — if the server dies, you lose it.
- ❌ Don't rotate "just because" — it's expensive and risky.
+5
View File
@@ -78,6 +78,11 @@ async def update_post(post_id: int, new: dict) -> bool:
post.relevant = new["relevant"]
post.prefilter_reason = new.get("prefilter_reason")
post.analysis_version = new["analysis_version"]
# v5 routing fields — without these the bot can't route old posts
# correctly. analyze_post() always returns these (None for hold).
post.target_asset = new.get("target_asset")
post.category = new.get("category")
post.expected_move_pct = new.get("expected_move_pct")
# Note: do NOT touch price_impact_asset / price_at_post / m5/m15/m1h.
# Those represent actual market behavior (independent of AI's call)
# and stay accurate. The signals/accuracy endpoint will recompute