Files
trumpsignal-backend/app/api/user.py
T
k 5fb1d52026 Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:52:56 +08:00

415 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 — human in
# the loop has acknowledged the risk and chosen to resume.
cb_cleared = False
if sub.circuit_breaker_tripped_at is not None:
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_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 and sub.circuit_breaker_tripped_at is not None:
# Turning Auto-Trade ON = explicit human ack → clear the breaker.
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_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}