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>
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
rows = await db.execute(
|
||||
select(BotTrade).where(
|
||||
BotTrade.wallet_address == wallet,
|
||||
BotTrade.closed_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()
|
||||
|
||||
open_rows = await db.execute(
|
||||
select(BotTrade).where(
|
||||
BotTrade.wallet_address == wallet,
|
||||
BotTrade.closed_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"
|
||||
|
||||
|
||||
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
|
||||
await close_and_finalize(
|
||||
trade_id=trade.id,
|
||||
api_key=api_key,
|
||||
leverage=leverage,
|
||||
asset=trade.asset,
|
||||
wallet=wallet,
|
||||
reason="manual",
|
||||
)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user