Files
trumpsignal-backend/app/api/user.py
T
k fc735f251a fix(trading): close five risk gaps surfaced by pre-launch audit
1. hyperliquid.open_position now returns effective_leverage. HL clips
   the requested leverage to each asset's max (e.g. memes capped at 3×)
   and silently applied the lower value; we were discarding that. For
   System-2 this meant sys2_protective_stop_pct was computed against the
   REQUESTED leverage, so the "inside-liquidation" full-close rung was
   actually well inside an 8.5% stop while the real liquidation sat ~33%
   away — strategy intent broken. bot_engine now reads the effective
   value back, recomputes the protective stop + derisk ladder against
   it, and freezes the correct values into BotTrade.eff_* / .leverage.

2. circuit_breaker.clear_trip now clears BOTH systems by default. The
   sys2 column was never reachable from any reset path — a sys2 trip
   forced users to wait the full 24h lockout even after explicit
   human re-arm.

3. /api/user/.../manual-window and /auto-trade re-arm endpoints now
   clear sys1 AND sys2 CB on the same human ack. Symmetry with
   check_and_trip / is_tripped which already supported both systems.

4. telegram.py deep-link map: btc_bottom_reversal + funding_reversal
   alerts now point at /en/macro instead of the now-404 /en/btc.

5. (Already landed: _sys2_mode UnboundLocalError fix in bot_engine —
   restated here for the audit trail.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:05:06 +08:00

427 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade, Subscription, iso_utc
from app.schemas import (
BotTrade as BotTradeSchema,
UserResponse,
UserSettings,
SetApiKeyRequest,
SetApiKeyResponse,
SetSettingsRequest,
)
from app.services.crypto import encrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
# Action names — must match frontend exactly (used in the signed message)
ACTION_SET_API_KEY = "set_hl_api_key"
ACTION_SET_SETTINGS = "set_settings"
ACTION_VIEW_USER = "view_user"
ACTION_SET_MANUAL_WINDOW = "set_manual_window"
ACTION_SET_AUTO_TRADE = "set_auto_trade"
async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> None:
"""Fail before storing an unusable Hyperliquid API wallet key."""
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=account_address,
)
await trader.get_balance()
except Exception as exc:
raise HTTPException(422, f"Hyperliquid rejected this API key: {exc}")
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
exit_price=trade.exit_price or 0.0,
pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0,
opened_at=iso_utc(trade.opened_at) or "",
closed_at=iso_utc(trade.closed_at) or "",
)
@router.put("/user/{wallet}/hl-api-key", response_model=SetApiKeyResponse)
async def set_hl_api_key(
wallet: str,
body: SetApiKeyRequest,
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch between path and body")
api_key = body.api_key.strip()
if not api_key.startswith("0x") or len(api_key) != 66:
raise HTTPException(422, "Invalid API key format — must be a 0x-prefixed 64-char hex string")
# Signature binds to the full payload (including api_key) so a leaked sig
# can't be reused to change the key.
verify_signed_request(
action=ACTION_SET_API_KEY,
wallet=wallet,
timestamp_ms=body.timestamp,
signature=body.signature,
body={"api_key": api_key},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed. Subscribe first.")
await verify_hl_api_key_can_trade(api_key=api_key, account_address=wallet)
sub.hl_api_key = encrypt_api_key(api_key)
await db.commit()
masked = f"...{api_key[-6:]}"
logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked)
return SetApiKeyResponse(status="ok", masked_key=masked, verified=True)
@router.get("/user/{wallet}/public")
async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
"""No-auth endpoint returning only boolean state. Safe to call on every
page load to decide whether to show the Subscribe/API-key UI."""
wallet = wallet.lower().strip()
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
return {
"wallet_address": wallet, "active": False, "hl_api_key_set": False,
"paper_mode": False, "manual_window_until": None,
"circuit_breaker_tripped_at": None, "circuit_breaker_reason": None,
"auto_trade": False,
}
return {
"wallet_address": wallet,
"active": sub.active,
"hl_api_key_set": bool(sub.hl_api_key),
# Operational state shown on /signals page. Not sensitive.
"paper_mode": bool(sub.paper_mode),
"manual_window_until": iso_utc(sub.manual_window_until),
"circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at),
"circuit_breaker_reason": sub.circuit_breaker_reason,
"auto_trade": bool(sub.auto_trade),
}
@router.get("/user/{wallet}", response_model=UserResponse)
async def get_user(
wallet: str,
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
# Require a fresh signed timestamp from the owner — stops wallet-enumeration
# and trade-history leakage. Frontend re-signs ~every 5 min via sessionStorage.
verify_signed_request(
action=ACTION_VIEW_USER,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True, # idempotent GET — allow sessionStorage caching for UX
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not found")
trades_result = await db.execute(
select(BotTrade)
.where(BotTrade.wallet_address == wallet)
.order_by(BotTrade.opened_at.desc())
.limit(50)
)
trades = trades_result.scalars().all()
hl_api_key_set = bool(sub.hl_api_key)
# Never return the encrypted blob or any decrypted key. The masked hint
# shown to the user is derived lazily and only from the last 6 chars of
# the stored blob's sha-hash, not the plaintext.
if hl_api_key_set:
import hashlib
masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:]
else:
masked = None
return UserResponse(
wallet_address=sub.wallet_address,
active=sub.active,
subscribed_at=iso_utc(sub.subscribed_at),
hl_api_key_set=hl_api_key_set,
hl_api_key_masked=masked,
trades=[_trade_to_schema(t) for t in trades],
settings=UserSettings(
leverage=sub.leverage,
position_size_usd=sub.position_size_usd,
take_profit_pct=sub.take_profit_pct,
stop_loss_pct=sub.stop_loss_pct,
min_confidence=sub.min_confidence,
daily_budget_usd=sub.daily_budget_usd,
sys2_leverage=sub.sys2_leverage,
sys2_mode=sub.sys2_mode,
active_from=iso_utc(sub.active_from),
active_until=iso_utc(sub.active_until),
),
manual_window_until=iso_utc(sub.manual_window_until),
)
@router.put("/user/{wallet}/settings", response_model=UserSettings)
async def set_user_settings(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
# Read raw JSON FIRST — the frontend signs the payload exactly as it's
# serialized in JSON (e.g. `5` stays `5`, not `5.0`). If we let Pydantic
# coerce ints→floats first, the server-side hash won't match the client's.
raw = await request.json()
raw_settings = raw.get("settings")
if not isinstance(raw_settings, dict):
raise HTTPException(422, "Missing settings block")
# Now validate with Pydantic
body = SetSettingsRequest(**raw)
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
s = body.settings
if not (1 <= s.leverage <= 50):
raise HTTPException(422, "leverage must be 150")
if not (5 <= s.position_size_usd <= 10000):
raise HTTPException(422, "position_size_usd must be $5$10,000")
# Take-profit, stop-loss and daily budget are now MANDATORY — the bot won't
# run without them, so we reject null here and force the client to supply values.
if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50):
raise HTTPException(422, "take_profit_pct is required (0.150)")
if s.stop_loss_pct is None or not (0.1 <= s.stop_loss_pct <= 50):
raise HTTPException(422, "stop_loss_pct is required (0.150)")
if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100")
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
raise HTTPException(422, "sys2_leverage must be 110")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive"):
raise HTTPException(422, "sys2_mode must be 'standard' or 'aggressive'")
if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)")
# Parse schedule (ISO strings → naive-UTC datetimes). Either both or neither.
from datetime import datetime as _dt, timezone as _tz
def _parse_iso_utc(v):
if v is None:
return None
try:
# Accept "...Z" or "+00:00" forms
dt = _dt.fromisoformat(v.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.astimezone(_tz.utc).replace(tzinfo=None)
return dt
except Exception:
raise HTTPException(422, f"invalid ISO datetime: {v!r}")
af = _parse_iso_utc(s.active_from)
au = _parse_iso_utc(s.active_until)
if (af is None) != (au is None):
raise HTTPException(422, "active_from and active_until must be set together or both empty")
if af is not None and au is not None and au <= af:
raise HTTPException(422, "active_until must be after active_from")
# Hash the RAW dict (matches what frontend signed)
verify_signed_request(
action=ACTION_SET_SETTINGS,
wallet=wallet,
timestamp_ms=body.timestamp,
signature=body.signature,
body=raw_settings,
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
sub.leverage = s.leverage
sub.position_size_usd = s.position_size_usd
sub.take_profit_pct = s.take_profit_pct
sub.stop_loss_pct = s.stop_loss_pct
sub.min_confidence = s.min_confidence
sub.daily_budget_usd = s.daily_budget_usd
sub.sys2_leverage = s.sys2_leverage
if s.sys2_mode is not None:
sub.sys2_mode = s.sys2_mode
sub.active_from = af
sub.active_until = au
await db.commit()
logger.info("Settings updated for %s: %s", wallet, s.model_dump())
return s
# ─── Manual window (convex-strategy "enable for N hours" override) ───────────
@router.post("/user/{wallet}/manual-window")
async def set_manual_window(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Arm the bot for the next N hours, overriding the active_from/until schedule.
Body: { wallet, timestamp, signature, hours }
hours: integer 0168. Pass 0 to clear (immediate disarm).
The bot's main gate (Subscription.active) still applies. This endpoint only
flips the schedule override; an inactive subscription stays paused.
"""
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
wallet = wallet.lower().strip()
raw = await request.json()
# Manual auth payload — keep deliberately minimal so the signed string is
# short and easy to reason about.
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
hours_raw = raw.get("hours")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if not isinstance(hours_raw, int) or hours_raw < 0 or hours_raw > 168:
raise HTTPException(422, "hours must be 0168")
verify_signed_request(
action=ACTION_SET_MANUAL_WINDOW,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"hours": hours_raw},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
if hours_raw == 0:
sub.manual_window_until = None
new_until_iso = None
logger.info("Manual window cleared for %s", wallet)
else:
until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw)
sub.manual_window_until = until
new_until_iso = iso_utc(until)
# Explicit re-arm clears any active circuit-breaker trip on BOTH
# systems — the human ack covers the whole wallet. (Earlier only the
# sys1 breaker was cleared, leaving the sys2 book locked for 24h.)
cb_cleared = False
for _col_at, _col_reason in (
("circuit_breaker_tripped_at", "circuit_breaker_reason"),
("sys2_cb_tripped_at", "sys2_cb_reason"),
):
if getattr(sub, _col_at) is not None:
setattr(sub, _col_at, None)
setattr(sub, _col_reason, None)
cb_cleared = True
logger.info("Manual window armed for %s: until %s (%dh)%s",
wallet, until, hours_raw, " — CB cleared" if cb_cleared else "")
await db.commit()
return {"manual_window_until": new_until_iso}
# ─── Master Auto-Trade switch (simplified operator model) ───────────────────
@router.post("/user/{wallet}/auto-trade")
async def set_auto_trade(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Flip the ONE persistent Auto-Trade gate.
Body: { wallet, timestamp, signature, enabled }
enabled=false → signals are still ingested/shown but NO trade opens.
enabled=true → qualifying signals auto-open trades. Turning it ON also
acknowledges + clears a tripped circuit breaker
(human-in-the-loop), mirroring the old manual-window arm.
"""
wallet = wallet.lower().strip()
raw = await request.json()
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
enabled = raw.get("enabled")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
verify_signed_request(
action=ACTION_SET_AUTO_TRADE,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"enabled": enabled},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
sub.auto_trade = enabled
cb_cleared = False
if enabled:
# Turning Auto-Trade ON = explicit human ack → clear BOTH breakers
# (sys1 Trump + sys2 reversal). Previously only sys1 was cleared,
# leaving the reversal book locked even after the user re-armed.
for _col_at, _col_reason in (
("circuit_breaker_tripped_at", "circuit_breaker_reason"),
("sys2_cb_tripped_at", "sys2_cb_reason"),
):
if getattr(sub, _col_at) is not None:
setattr(sub, _col_at, None)
setattr(sub, _col_reason, None)
cb_cleared = True
await db.commit()
logger.info("Auto-Trade %s for %s%s",
"ON" if enabled else "OFF", wallet,
" — CB cleared" if cb_cleared else "")
return {"auto_trade": enabled, "circuit_breaker_cleared": cb_cleared}