455 lines
18 KiB
Python
455 lines
18 KiB
Python
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 (
|
||
SignedReadCreds, signed_read_creds, 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:
|
||
# Preserve None — do NOT coerce to 0 / "". See trades.py comment.
|
||
return BotTradeSchema(
|
||
id=trade.id,
|
||
asset=trade.asset,
|
||
side=trade.side,
|
||
entry_price=trade.entry_price,
|
||
exit_price=trade.exit_price,
|
||
pnl_usd=trade.pnl_usd,
|
||
hold_seconds=trade.hold_seconds,
|
||
trigger_post_id=trade.trigger_post_id,
|
||
opened_at=iso_utc(trade.opened_at) or "",
|
||
closed_at=iso_utc(trade.closed_at),
|
||
)
|
||
|
||
|
||
@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, "trump_enabled": False, "macro_enabled": 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),
|
||
# Per-system enable flags. bot_engine gates System-1 on trump_enabled
|
||
# and System-2 on macro_enabled (see _execute_for_subscriber). Without
|
||
# these, the UI claims "next Trump signal auto-opens" even when
|
||
# trump_enabled=0, where the backend actually skips the trade.
|
||
"trump_enabled": bool(sub.trump_enabled),
|
||
"macro_enabled": bool(sub.macro_enabled),
|
||
}
|
||
|
||
|
||
@router.get("/user/{wallet}", response_model=UserResponse)
|
||
async def get_user(
|
||
wallet: str,
|
||
creds: SignedReadCreds = Depends(signed_read_creds),
|
||
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=creds.ts,
|
||
signature=creds.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)
|
||
# Masked hint MUST match what set_hl_api_key returned at save time, i.e.
|
||
# the last 6 chars of the real key ("...abc123"). Previously this hashed
|
||
# the encrypted blob instead, so the suffix shown after a reload differed
|
||
# from the one shown right after saving — users thought their key changed.
|
||
# We decrypt only to take the last 6 chars (never return the full key).
|
||
if hl_api_key_set:
|
||
try:
|
||
from app.services.crypto import decrypt_api_key
|
||
masked = f"...{decrypt_api_key(sub.hl_api_key)[-6:]}"
|
||
except Exception:
|
||
# Decryption failed (rotated KEK, corrupt blob) — don't leak the
|
||
# ciphertext or crash settings load; show a neutral "set" marker.
|
||
masked = "…(set)"
|
||
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,
|
||
paper_mode=bool(sub.paper_mode),
|
||
auto_trade=bool(sub.auto_trade),
|
||
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),
|
||
trump_enabled=sub.trump_enabled,
|
||
macro_enabled=sub.macro_enabled,
|
||
),
|
||
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 1–50")
|
||
if not (5 <= s.position_size_usd <= 10000):
|
||
raise HTTPException(422, "position_size_usd must be $5–$10,000")
|
||
# TP/SL required only when Trump module is enabled.
|
||
if s.trump_enabled:
|
||
if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50):
|
||
raise HTTPException(422, "take_profit_pct is required when trump_enabled (0.1–50)")
|
||
if s.stop_loss_pct is None or not (0.1 <= s.stop_loss_pct <= 50):
|
||
raise HTTPException(422, "stop_loss_pct is required when trump_enabled (0.1–50)")
|
||
else:
|
||
if s.take_profit_pct is not None and not (0.1 <= s.take_profit_pct <= 50):
|
||
raise HTTPException(422, "take_profit_pct must be 0.1–50 if provided")
|
||
if s.stop_loss_pct is not None and not (0.1 <= s.stop_loss_pct <= 50):
|
||
raise HTTPException(422, "stop_loss_pct must be 0.1–50 if provided")
|
||
if not (0 <= s.min_confidence <= 100):
|
||
raise HTTPException(422, "min_confidence must be 0–100")
|
||
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
|
||
raise HTTPException(422, "sys2_leverage must be 1–10")
|
||
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive", ""):
|
||
raise HTTPException(422, "sys2_mode must be 'standard', 'aggressive', or '' (reset)")
|
||
if s.daily_budget_usd is not None and not (0 < s.daily_budget_usd <= 100000):
|
||
raise HTTPException(422, "daily_budget_usd must be >0 and ≤100,000 if provided")
|
||
|
||
# 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:
|
||
# Empty string = reset to default; otherwise store the chosen mode.
|
||
sub.sys2_mode = "standard" if s.sys2_mode == "" else s.sys2_mode
|
||
sub.active_from = af
|
||
sub.active_until = au
|
||
if s.trump_enabled is not None:
|
||
sub.trump_enabled = s.trump_enabled
|
||
if s.macro_enabled is not None:
|
||
sub.macro_enabled = s.macro_enabled
|
||
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 0–168. 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 0–168")
|
||
|
||
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}
|