""" 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, verify_signed_request_any 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_any( actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER], 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_any( actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER], 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 ) # Exclude released trades — these were released back to user control and # closed by the user on HL directly, not by the bot. Including them would # inflate the bot's "today" P&L with trades the bot didn't manage. closed_rows = await db.execute( select(BotTrade).where( BotTrade.wallet_address == wallet, BotTrade.closed_at >= midnight, BotTrade.pnl_usd.is_not(None), BotTrade.released_at.is_(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_VIEW_USER = "view_user" 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": } 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") # Verify signature first — prevents trade_id enumeration via 403/409 before auth. verify_signed_request( action=ACTION_CLOSE_TRADE, wallet=wallet, timestamp_ms=timestamp, signature=signature, body={"trade_id": trade_id}, ) # 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") # 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, ) # B45: close_and_finalize uses its own AsyncSessionLocal session, so the # route's `db` session has a stale identity-map cache for this trade row. # `populate_existing=True` forces SQLAlchemy to overwrite the cached # instance with the freshly-committed values (exit_price, pnl_usd, etc.) # rather than returning the pre-close snapshot from the identity map. closed = (await db.execute( select(BotTrade).where(BotTrade.id == trade_id).execution_options(populate_existing=True) )).scalar_one() # B46: close_and_finalize returns silently on certain failures (no price # for paper close, HL returns no fill, etc.) without raising an exception. # Detect the failure by checking whether closed_at was actually written. if closed.closed_at is None: raise HTTPException( 500, "Close command issued but the position could not be closed " "(no price feed, HL fill failure, or another caller closed it first). " "Refresh open positions — if it still shows, retry or close manually on HL." ) 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)") verify_signed_request( action=ACTION_SET_GROW, wallet=wallet, timestamp_ms=timestamp, signature=signature, body={"trade_id": trade_id, "enabled": enabled}, ) 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") 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, ts: int = Query(..., description="Signed timestamp (ms)"), sig: str = Query(..., description="EIP-191 signature"), ): """Read the wallet's CURRENT Hyperliquid open positions, annotated with 'already adopted' flag. Used by the Adopt picker on the frontend. Signed read — a wallet's live HL positions + adoption status are private, so this requires the SAME view signature as /positions/open (knowing a wallet address alone must NOT expose its positions). The Telegram /adopt command calls the `list_hl_positions` service function directly and is unaffected by this HTTP-layer check. """ wallet = wallet.lower().strip() verify_signed_request_any( actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER], wallet=wallet, timestamp_ms=ts, signature=sig, body=None, allow_replay=True, ) 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, 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)