diff --git a/app/api/posts.py b/app/api/posts.py index b6549a9..03e5e06 100644 --- a/app/api/posts.py +++ b/app/api/posts.py @@ -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, ) diff --git a/app/schemas.py b/app/schemas.py index d508931..eff19ae 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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} diff --git a/app/services/hyperliquid.py b/app/services/hyperliquid.py index 6e394f9..8812d96 100644 --- a/app/services/hyperliquid.py +++ b/app/services/hyperliquid.py @@ -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, diff --git a/deploy/ENCRYPTION_KEY_BACKUP.md b/deploy/ENCRYPTION_KEY_BACKUP.md new file mode 100644 index 0000000..dfe6a77 --- /dev/null +++ b/deploy/ENCRYPTION_KEY_BACKUP.md @@ -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. diff --git a/scripts/rescore_v5.py b/scripts/rescore_v5.py index 0d55905..beb7b4a 100644 --- a/scripts/rescore_v5.py +++ b/scripts/rescore_v5.py @@ -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