7599d4952d
1. recovery.py rehydrated adopted trades with no System-2 ladder ───────────────────────────────────────────────────────────────── On backend restart, recovery resolved stop_ladder / derisk_ladder / addon_ladder / peak_trail from trigger_post.category. Adopted trades have trigger_post_id=NULL (the user opened on HL manually, there's no source post) so all four resolved to None and the trade restarted as a vanilla "stop_loss + max_hold" position — the entire System-2 ladder logic silently disappeared across any restart. Fix: detect System-2 from the row itself (sys2_mode IS NOT NULL falls back to the adopted-category constant), then rebuild the full ladder set from frozen leverage + mode. derisk_steps_done / addon_steps_done / peak_gain_pct on the row let us pick up exactly where we left off. 2. close_and_finalize raced with release_management ───────────────────────────────────────────────────────────────── on_price_tick snapshots _watched, evaluates per trade, appends to a triggered list, then spawns _fire_close as an async task. If release_management runs between the snapshot and _fire_close firing, close_and_finalize would still close the HL position the user just took back manual control of. Fix: atomic claim now requires released_at IS NULL by default. New `force=True` parameter on close_and_finalize bypasses the guard for the explicit user-close API (manual_close passes it) so a deliberate user click still works on a released trade. 3. partial_derisk + pyramid_add raced with release_management ───────────────────────────────────────────────────────────────── Same race shape: a price tick captured before release could still trigger a partial reduce-only close, or pyramid INTO a position the user is now driving themselves. Fix: both functions return idempotent-success if released_at IS NOT NULL — match the closed_at handling we already had. Tests still 64/64 + preflight + smoke all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
569 lines
21 KiB
Python
569 lines
21 KiB
Python
"""
|
|
Open positions + today's P&L — surface what every trader checks first.
|
|
|
|
The existing endpoints answer "what closed?" (trades) and "what's a signal?"
|
|
(posts). Neither answers "what do I currently hold?" — which is the very
|
|
first question on every trading dashboard.
|
|
|
|
This module exposes two reads for a connected wallet:
|
|
|
|
GET /api/positions/open?wallet=...
|
|
List every BotTrade with closed_at IS NULL for the wallet, enriched
|
|
with current price (from price_store) and unrealized PnL.
|
|
Works for both paper and real trades.
|
|
|
|
GET /api/positions/today?wallet=...
|
|
Realized P&L for trades closed since UTC midnight. Plus open count.
|
|
Cheap aggregate — drives the "today" tile on dashboards.
|
|
|
|
Both are no-auth (same trust model as /user/{wallet}/public). The wallet
|
|
address is the access key; anyone who knows it can read its state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
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.services.crypto import decrypt_api_key
|
|
from app.services.price_store import price_store
|
|
from app.services.signed_request import verify_signed_request
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ─── Schemas ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class OpenPosition(BaseModel):
|
|
trade_id: int
|
|
asset: str
|
|
side: str # "long" | "short"
|
|
entry_price: float
|
|
current_price: Optional[float] = None # None if price_store lacks the asset
|
|
size_usd: Optional[float] = None # OPEN notional (after de-risk)
|
|
leverage: Optional[int] = None
|
|
opened_at: str
|
|
hold_minutes: int
|
|
unrealized_pct: Optional[float] = None # signed in trade direction
|
|
unrealized_usd: Optional[float] = None # on the OPEN portion only
|
|
is_paper: bool = False
|
|
trigger_post_id: Optional[int] = None
|
|
# System-2 staged lifecycle (so the UI doesn't misreport a de-risked /
|
|
# pyramided trade). realized_usd = PnL already banked by partial de-risk.
|
|
realized_usd: Optional[float] = None
|
|
derisk_steps: int = 0
|
|
addon_steps: int = 0
|
|
grow_mode: bool = False
|
|
|
|
|
|
class OpenPositionsResponse(BaseModel):
|
|
wallet: str
|
|
count: int
|
|
positions: list[OpenPosition]
|
|
|
|
|
|
class TodayStatsResponse(BaseModel):
|
|
wallet: str
|
|
realized_pnl_usd: float
|
|
trades_closed: int
|
|
wins: int
|
|
losses: int
|
|
open_count: int
|
|
# PnL already banked by staged de-risk on positions that are STILL open
|
|
# (cumulative for those trades — not date-scoped, since per-step times
|
|
# aren't tracked). Surfaced separately so it's visible without corrupting
|
|
# the strict closed-trade realised figure.
|
|
open_realized_usd: float = 0.0
|
|
|
|
|
|
# ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _enrich(trade: BotTrade) -> OpenPosition:
|
|
"""Add current price + unrealized PnL to a BotTrade row.
|
|
|
|
Uses price_store (in-memory Binance ticks). For assets we don't stream
|
|
(TRUMP, HYPE, niche perps) current_price will be None and the UI shows
|
|
"live price unavailable".
|
|
"""
|
|
now_aware = datetime.now(timezone.utc)
|
|
opened_aware = trade.opened_at.replace(tzinfo=timezone.utc)
|
|
hold_min = int((now_aware - opened_aware).total_seconds() // 60)
|
|
|
|
# Only the STILL-OPEN fraction is on the book. After staged de-risk,
|
|
# remaining_fraction < 1.0 and the rest was already realised — marking
|
|
# the full size_usd to market would overstate both size and PnL.
|
|
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
|
|
open_notional = round((trade.size_usd or 0.0) * rem_frac, 2)
|
|
realized = round(trade.realized_partial_pnl_usd or 0.0, 2)
|
|
|
|
current = price_store.latest_price(trade.asset)
|
|
unrealized_pct: Optional[float] = None
|
|
unrealized_usd: Optional[float] = None
|
|
if current is not None and trade.entry_price:
|
|
raw_pct = (current - trade.entry_price) / trade.entry_price
|
|
signed_pct = (raw_pct if trade.side == "long" else -raw_pct) * 100
|
|
unrealized_pct = round(signed_pct, 3)
|
|
if open_notional:
|
|
unrealized_usd = round(open_notional * signed_pct / 100, 2)
|
|
|
|
return OpenPosition(
|
|
trade_id=trade.id,
|
|
asset=trade.asset,
|
|
side=trade.side,
|
|
entry_price=trade.entry_price,
|
|
current_price=current,
|
|
size_usd=open_notional,
|
|
leverage=trade.leverage,
|
|
opened_at=iso_utc(trade.opened_at) or "",
|
|
hold_minutes=hold_min,
|
|
unrealized_pct=unrealized_pct,
|
|
unrealized_usd=unrealized_usd,
|
|
is_paper=(trade.hl_order_id == "paper"),
|
|
trigger_post_id=trade.trigger_post_id,
|
|
realized_usd=(realized if realized else None),
|
|
derisk_steps=(trade.derisk_steps_done or 0),
|
|
addon_steps=(trade.addon_steps_done or 0),
|
|
grow_mode=bool(getattr(trade, "grow_mode", False)),
|
|
)
|
|
|
|
|
|
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/positions/open", response_model=OpenPositionsResponse)
|
|
async def get_open_positions(
|
|
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
|
|
ts: int = Query(..., description="Signed timestamp (ms)"),
|
|
sig: str = Query(..., description="EIP-191 signature"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Live open positions for the wallet, with mark-to-market PnL."""
|
|
wallet = wallet.lower().strip()
|
|
verify_signed_request(
|
|
action=ACTION_VIEW_POSITIONS,
|
|
wallet=wallet,
|
|
timestamp_ms=ts,
|
|
signature=sig,
|
|
body=None,
|
|
allow_replay=True,
|
|
)
|
|
# Exclude released trades — those rows are "closed_at IS NULL" but the
|
|
# user has explicitly taken back manual control of the HL position. The
|
|
# bot is no longer managing them, so they shouldn't appear in the live
|
|
# positions tile (which would suggest the bot is still driving stops).
|
|
rows = await db.execute(
|
|
select(BotTrade).where(
|
|
BotTrade.wallet_address == wallet,
|
|
BotTrade.closed_at.is_(None),
|
|
BotTrade.released_at.is_(None),
|
|
).order_by(BotTrade.opened_at.desc())
|
|
)
|
|
trades = rows.scalars().all()
|
|
positions = [_enrich(t) for t in trades]
|
|
return OpenPositionsResponse(wallet=wallet, count=len(positions), positions=positions)
|
|
|
|
|
|
@router.get("/positions/today", response_model=TodayStatsResponse)
|
|
async def get_today_stats(
|
|
wallet: str = Query(...),
|
|
ts: int = Query(..., description="Signed timestamp (ms)"),
|
|
sig: str = Query(..., description="EIP-191 signature"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Today's realized P&L (since UTC midnight) + open count.
|
|
|
|
Used for the "today" KPI tile. Cheap — one indexed range scan + a
|
|
one-shot count for opens.
|
|
"""
|
|
wallet = wallet.lower().strip()
|
|
verify_signed_request(
|
|
action=ACTION_VIEW_POSITIONS,
|
|
wallet=wallet,
|
|
timestamp_ms=ts,
|
|
signature=sig,
|
|
body=None,
|
|
allow_replay=True,
|
|
)
|
|
midnight = datetime.now(timezone.utc).replace(
|
|
hour=0, minute=0, second=0, microsecond=0, tzinfo=None
|
|
)
|
|
|
|
closed_rows = await db.execute(
|
|
select(BotTrade).where(
|
|
BotTrade.wallet_address == wallet,
|
|
BotTrade.closed_at >= midnight,
|
|
BotTrade.pnl_usd.is_not(None),
|
|
)
|
|
)
|
|
closed = closed_rows.scalars().all()
|
|
|
|
# See /positions/open: released trades aren't actively managed by the
|
|
# bot, so they shouldn't inflate the open-count tile.
|
|
open_rows = await db.execute(
|
|
select(BotTrade).where(
|
|
BotTrade.wallet_address == wallet,
|
|
BotTrade.closed_at.is_(None),
|
|
BotTrade.released_at.is_(None),
|
|
)
|
|
)
|
|
open_trades = open_rows.scalars().all()
|
|
open_count = len(open_trades)
|
|
open_realized = sum(t.realized_partial_pnl_usd or 0.0 for t in open_trades)
|
|
|
|
realized = sum(t.pnl_usd or 0 for t in closed)
|
|
wins = sum(1 for t in closed if (t.pnl_usd or 0) > 0)
|
|
losses = sum(1 for t in closed if (t.pnl_usd or 0) < 0)
|
|
|
|
return TodayStatsResponse(
|
|
wallet=wallet,
|
|
realized_pnl_usd=round(realized, 2),
|
|
trades_closed=len(closed),
|
|
wins=wins,
|
|
losses=losses,
|
|
open_count=open_count,
|
|
open_realized_usd=round(open_realized, 2),
|
|
)
|
|
|
|
|
|
# ─── Manual close (P0.1 safety valve) ───────────────────────────────────────
|
|
|
|
|
|
ACTION_CLOSE_TRADE = "close_trade"
|
|
ACTION_SET_GROW = "set_trade_grow"
|
|
ACTION_VIEW_POSITIONS = "view_positions"
|
|
ACTION_ADOPT_POSITION = "adopt_position"
|
|
ACTION_RELEASE_TRADE = "release_trade"
|
|
|
|
|
|
class CloseTradeResponse(BaseModel):
|
|
status: str
|
|
trade_id: int
|
|
exit_price: Optional[float] = None
|
|
pnl_usd: Optional[float] = None
|
|
reason: str
|
|
note: Optional[str] = None
|
|
|
|
|
|
@router.post("/positions/{trade_id}/close", response_model=CloseTradeResponse)
|
|
async def manual_close(
|
|
trade_id: int,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""User-initiated emergency close. Always available — bypasses CB, schedule,
|
|
even circuit breaker lockout. Wallet ownership is verified by signature.
|
|
|
|
Path: trade_id → look up wallet → verify signature against THAT wallet →
|
|
call close_and_finalize. Reason stamped as "manual" so it's distinguishable
|
|
in the trade log from TP / SL / trailing / max_hold exits.
|
|
|
|
The signed body is {"trade_id": <id>} so a leaked sig for trade X can't be
|
|
replayed to close trade Y.
|
|
"""
|
|
raw = await request.json()
|
|
wallet = (raw.get("wallet") or "").lower().strip()
|
|
timestamp = raw.get("timestamp")
|
|
signature = raw.get("signature")
|
|
|
|
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
|
|
raise HTTPException(422, "wallet, timestamp, signature required")
|
|
|
|
# Load the trade. Must be open AND owned by the signing wallet.
|
|
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
|
|
if trade is None:
|
|
raise HTTPException(404, f"trade {trade_id} not found")
|
|
if trade.wallet_address.lower() != wallet:
|
|
raise HTTPException(403, "trade belongs to a different wallet")
|
|
if trade.closed_at is not None:
|
|
raise HTTPException(409, f"trade {trade_id} is already closed")
|
|
|
|
verify_signed_request(
|
|
action=ACTION_CLOSE_TRADE,
|
|
wallet=wallet,
|
|
timestamp_ms=timestamp,
|
|
signature=signature,
|
|
body={"trade_id": trade_id},
|
|
)
|
|
|
|
# Find the API key from the subscription.
|
|
sub = (await db.execute(
|
|
select(Subscription).where(Subscription.wallet_address == wallet)
|
|
)).scalar_one_or_none()
|
|
if sub is None:
|
|
raise HTTPException(404, "subscription not found")
|
|
|
|
# Paper trades close synthetically; live trades need the decrypted HL key.
|
|
api_key = ""
|
|
if trade.hl_order_id != "paper":
|
|
if not sub.hl_api_key:
|
|
raise HTTPException(400, "wallet has no HL API key — cannot close live position")
|
|
try:
|
|
api_key = decrypt_api_key(sub.hl_api_key)
|
|
except Exception as exc:
|
|
raise HTTPException(500, f"key decryption failed: {exc}")
|
|
|
|
# close_and_finalize handles BOTH paper and live branches internally.
|
|
from app.services.bot_engine import close_and_finalize
|
|
leverage = trade.leverage if trade.leverage is not None else sub.leverage
|
|
# force=True so an explicit user close still works on a released trade
|
|
# (the released_at guard exists to stop AUTOMATED paths from racing
|
|
# past a release; a manual click is the user being explicit).
|
|
await close_and_finalize(
|
|
trade_id=trade.id,
|
|
api_key=api_key,
|
|
leverage=leverage,
|
|
asset=trade.asset,
|
|
wallet=wallet,
|
|
reason="manual",
|
|
force=True,
|
|
)
|
|
|
|
closed = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one()
|
|
return CloseTradeResponse(
|
|
status="ok",
|
|
trade_id=trade_id,
|
|
exit_price=closed.exit_price,
|
|
pnl_usd=closed.pnl_usd,
|
|
reason="manual",
|
|
)
|
|
|
|
|
|
class GrowResponse(BaseModel):
|
|
trade_id: int
|
|
grow_mode: bool
|
|
|
|
|
|
@router.post("/positions/{trade_id}/grow", response_model=GrowResponse)
|
|
async def set_trade_grow(
|
|
trade_id: int,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Flip the per-trade Grow switch (pyramiding). Signed + ownership-checked.
|
|
|
|
Body: { wallet, timestamp, signature, trade_id, enabled }
|
|
grow=on → this winner is scaled INTO on confirmed trend.
|
|
grow=off → hold + protective de-risk / ratchet only (de-risk + stop-loss
|
|
always run regardless — never user-toggleable).
|
|
Takes effect immediately on the live monitor (no restart needed).
|
|
"""
|
|
raw = await request.json()
|
|
wallet = (raw.get("wallet") or "").lower().strip()
|
|
timestamp = raw.get("timestamp")
|
|
signature = raw.get("signature")
|
|
enabled = raw.get("enabled")
|
|
body_tid = raw.get("trade_id")
|
|
|
|
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
|
|
raise HTTPException(422, "wallet, timestamp, signature required")
|
|
if not isinstance(enabled, bool):
|
|
raise HTTPException(422, "enabled (bool) required")
|
|
if body_tid != trade_id:
|
|
raise HTTPException(400, "trade_id mismatch (path vs signed body)")
|
|
|
|
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
|
|
if trade is None:
|
|
raise HTTPException(404, f"trade {trade_id} not found")
|
|
if trade.wallet_address.lower() != wallet:
|
|
raise HTTPException(403, "trade belongs to a different wallet")
|
|
if trade.closed_at is not None:
|
|
raise HTTPException(409, f"trade {trade_id} is already closed")
|
|
|
|
verify_signed_request(
|
|
action=ACTION_SET_GROW,
|
|
wallet=wallet,
|
|
timestamp_ms=timestamp,
|
|
signature=signature,
|
|
body={"trade_id": trade_id, "enabled": enabled},
|
|
)
|
|
|
|
trade.grow_mode = enabled
|
|
await db.commit()
|
|
|
|
# Apply to the live monitor immediately so it takes effect this tick.
|
|
try:
|
|
from app.services.tp_sl_monitor import _watched
|
|
wt = _watched.get(trade_id)
|
|
if wt is not None:
|
|
wt.grow_mode = enabled
|
|
except Exception as exc:
|
|
logger.warning("grow toggle: live monitor update skipped trade %d: %s",
|
|
trade_id, exc)
|
|
|
|
logger.info("Grow %s for trade %d by %s",
|
|
"ON" if enabled else "OFF", trade_id, wallet)
|
|
return GrowResponse(trade_id=trade_id, grow_mode=enabled)
|
|
|
|
|
|
# ─── Adopt / release (System-2 manage-only flow) ────────────────────────────
|
|
|
|
|
|
class HLPositionItem(BaseModel):
|
|
asset: str
|
|
side: str
|
|
size_coins: float
|
|
entry_price: float
|
|
leverage: int
|
|
size_usd: float
|
|
already_adopted: bool
|
|
|
|
|
|
class HLPositionsResponse(BaseModel):
|
|
wallet: str
|
|
count: int
|
|
positions: list[HLPositionItem]
|
|
|
|
|
|
@router.get("/positions/hl/{wallet}", response_model=HLPositionsResponse)
|
|
async def list_hl_positions_endpoint(wallet: str):
|
|
"""Read the wallet's CURRENT Hyperliquid open positions, annotated with
|
|
'already adopted' flag. Used by the Adopt picker on the frontend and by
|
|
the Telegram /adopt command.
|
|
|
|
No auth — same trust model as /positions/open (wallet address IS the
|
|
access key). Reading HL state is read-only and harmless.
|
|
"""
|
|
from app.services.adoption import list_hl_positions, AdoptionError
|
|
try:
|
|
items = await list_hl_positions(wallet)
|
|
except AdoptionError as exc:
|
|
raise HTTPException(400, exc.message)
|
|
return HLPositionsResponse(
|
|
wallet=wallet.lower().strip(),
|
|
count=len(items),
|
|
positions=[HLPositionItem(**vars(it)) for it in items],
|
|
)
|
|
|
|
|
|
class AdoptResponse(BaseModel):
|
|
status: str
|
|
trade_id: int
|
|
asset: str
|
|
side: str
|
|
entry_price: float
|
|
size_usd: float
|
|
leverage: int
|
|
mode: str
|
|
protective_stop: float
|
|
|
|
|
|
@router.post("/positions/adopt", response_model=AdoptResponse)
|
|
async def adopt_position_endpoint(request: Request,
|
|
db: AsyncSession = Depends(get_db)):
|
|
"""Hand a manually-opened HL position to the bot.
|
|
|
|
Body: { wallet, timestamp, signature, asset, mode }
|
|
asset : e.g. "BTC". Case-insensitive.
|
|
mode : "standard" | "aggressive" — picks ladder spacing.
|
|
|
|
On success: a BotTrade row is created, sys2 stop ladder + de-risk +
|
|
pyramid + peak-trail registered with the live watchdog. From that
|
|
moment, the bot drives the trade.
|
|
|
|
Failure surfaces an AdoptionError mapped to a 400 with .code in the
|
|
response body so the UI can switch on it (e.g. 'already_adopted',
|
|
'concurrency_cap', 'not_on_hl').
|
|
"""
|
|
raw = await request.json()
|
|
wallet = (raw.get("wallet") or "").lower().strip()
|
|
timestamp = raw.get("timestamp")
|
|
signature = raw.get("signature")
|
|
asset = (raw.get("asset") or "").upper().strip()
|
|
mode = (raw.get("mode") or "standard").strip()
|
|
|
|
if not wallet:
|
|
raise HTTPException(422, "wallet required")
|
|
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 asset:
|
|
raise HTTPException(422, "asset required (e.g. 'BTC')")
|
|
if mode not in ("standard", "aggressive"):
|
|
raise HTTPException(422, "mode must be 'standard' or 'aggressive'")
|
|
|
|
verify_signed_request(
|
|
action=ACTION_ADOPT_POSITION,
|
|
wallet=wallet, timestamp_ms=timestamp, signature=signature,
|
|
body={"asset": asset, "mode": mode},
|
|
)
|
|
|
|
from app.services.adoption import adopt_position, AdoptionError
|
|
try:
|
|
result = await adopt_position(wallet, asset, mode)
|
|
except AdoptionError as exc:
|
|
# 409 for state conflicts (already adopted / concurrency), 400 else.
|
|
status = 409 if exc.code in (
|
|
"already_adopted", "concurrency_cap", "already_closed",
|
|
) else 400
|
|
raise HTTPException(status, {"code": exc.code, "message": exc.message})
|
|
|
|
return AdoptResponse(
|
|
status="ok",
|
|
trade_id=result.trade_id,
|
|
asset=result.asset, side=result.side,
|
|
entry_price=result.entry_price, size_usd=result.size_usd,
|
|
leverage=result.leverage, mode=result.mode,
|
|
protective_stop=result.protective_stop,
|
|
)
|
|
|
|
|
|
class ReleaseResponse(BaseModel):
|
|
status: str
|
|
trade_id: int
|
|
asset: str
|
|
already_released: bool
|
|
|
|
|
|
@router.post("/positions/{trade_id}/release", response_model=ReleaseResponse)
|
|
async def release_trade_endpoint(trade_id: int, request: Request):
|
|
"""Hand control of trade_id back to the user.
|
|
|
|
Body: { wallet, timestamp, signature, trade_id }
|
|
|
|
Marks released_at on the BotTrade row, unregisters the watchdog.
|
|
The HL position itself is NOT touched — it keeps running under the
|
|
user's manual control. Idempotent: releasing a released trade returns
|
|
success.
|
|
"""
|
|
raw = await request.json()
|
|
wallet = (raw.get("wallet") or "").lower().strip()
|
|
timestamp = raw.get("timestamp")
|
|
signature = raw.get("signature")
|
|
body_tid = raw.get("trade_id")
|
|
|
|
if not wallet:
|
|
raise HTTPException(422, "wallet required")
|
|
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 body_tid != trade_id:
|
|
raise HTTPException(400, "trade_id mismatch (path vs signed body)")
|
|
|
|
verify_signed_request(
|
|
action=ACTION_RELEASE_TRADE,
|
|
wallet=wallet, timestamp_ms=timestamp, signature=signature,
|
|
body={"trade_id": trade_id},
|
|
)
|
|
|
|
from app.services.adoption import release_management, AdoptionError
|
|
try:
|
|
result = await release_management(wallet, trade_id)
|
|
except AdoptionError as exc:
|
|
status = 404 if exc.code == "not_found" else (
|
|
403 if exc.code == "not_owner" else 400)
|
|
raise HTTPException(status, {"code": exc.code, "message": exc.message})
|
|
return ReleaseResponse(**result)
|