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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user