Files
trumpsignal-backend/app/api/positions.py
T
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:16 +08:00

609 lines
23 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, 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": <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)