Files
trumpsignal-backend/app/services/reconciler.py
T
k 5fb1d52026 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>
2026-05-25 00:52:56 +08:00

192 lines
7.6 KiB
Python

"""
Hyperliquid <-> DB state reconciliation (P1.2).
Runs every RECONCILE_INTERVAL_SECONDS. For each subscription with a saved HL
API key (and NOT in paper mode), fetches actual open positions from HL and
compares them to BotTrade rows with closed_at IS NULL.
Two drift cases:
1. DB has open trade, HL has no matching position
→ User closed manually on HL UI, or HL liquidated, or our open_position
call failed silently after writing the row. Either way the row is stale.
Action: mark the DB row closed with pnl_usd=NULL, reason="closed_externally".
2. HL has open position, DB has no matching open trade
→ A trade exists on HL that we didn't open (legacy position, or stale
wallet shared across systems). We can't safely manage it — log a
warning + WS broadcast for the user to handle manually.
NOTE: this does NOT close stray HL positions. Auto-closing positions we didn't
open is exactly the kind of "helpful" behaviour that costs users money. We
report, the human decides.
Cost note:
HL's user_state endpoint is cheap. 1 call per active subscriber per
RECONCILE_INTERVAL. With 10 subscribers at 60s interval that's 600 calls/hr,
well inside HL's free tier.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription
from app.services.crypto import decrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
logger = logging.getLogger(__name__)
RECONCILE_INTERVAL_SECONDS = 60
# ─── Single-wallet reconcile ────────────────────────────────────────────────
async def _reconcile_wallet(sub: Subscription) -> dict:
"""Compare HL state vs DB state for one subscription.
Returns a small summary dict for logging / WS broadcast. Never raises —
network / HL errors are logged and counted but don't halt the cycle.
"""
summary = {
"wallet": sub.wallet_address, "orphan_hl": [], "orphan_db": [],
"marked_closed": 0, "error": None,
}
# Paper-mode subs aren't on HL at all — nothing to reconcile.
if sub.paper_mode:
return summary
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
summary["error"] = f"decrypt_failed: {exc}"
return summary
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=sub.wallet_address,
leverage=sub.leverage,
mainnet=settings.hl_mainnet,
)
hl_positions = await trader.get_open_positions()
except Exception as exc:
summary["error"] = f"hl_query_failed: {exc}"
return summary
hl_assets_open = {p["coin"]: p for p in hl_positions}
async with AsyncSessionLocal() as db:
# All DB rows we believe are still open for this wallet.
rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == sub.wallet_address,
BotTrade.closed_at.is_(None),
)
)
db_open_trades = rows.scalars().all()
db_assets_open = {t.asset: t for t in db_open_trades}
# Case 1: DB open trade, HL has nothing. Stale row → mark closed.
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
for asset, trade in db_assets_open.items():
# Paper trades never have HL positions; they're managed by the
# normal close path. Skip here.
if trade.hl_order_id == "paper":
continue
if asset not in hl_assets_open:
# Preserve any de-risk PnL ALREADY banked on this trade — the
# open remainder's PnL is unknown (closed externally) but the
# banked slice is real money on HL; setting pnl_usd=None would
# silently undercount realized PnL in analytics / KPIs.
banked = trade.realized_partial_pnl_usd or 0.0
preserved_pnl = round(banked, 2) if banked else None
# Atomic claim — same conditional UPDATE pattern as close_and_finalize.
claimed = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade.id)
.where(BotTrade.closed_at.is_(None))
.values(closed_at=now_naive, pnl_usd=preserved_pnl,
remaining_fraction=0.0)
)
if claimed.rowcount > 0:
summary["marked_closed"] += 1
summary["orphan_db"].append({"asset": asset, "trade_id": trade.id})
# Stop the TP/SL watcher for this trade
from app.services.tp_sl_monitor import unregister
unregister(trade.id)
logger.warning("Reconcile: trade %d (%s %s) closed externally — marked.",
trade.id, trade.side, asset)
await db.commit()
# Case 2: HL has position we don't know about. Report only — don't auto-trade.
for asset, hl_pos in hl_assets_open.items():
if asset not in db_assets_open:
summary["orphan_hl"].append({
"asset": asset, "szi": hl_pos["szi"],
"entry": hl_pos["entry_px"], "uPnL": hl_pos["unrealized_pnl"],
})
logger.warning("Reconcile: %s has unmanaged HL position %s (szi=%s, entry=%s)",
sub.wallet_address, asset, hl_pos["szi"], hl_pos["entry_px"])
return summary
# ─── Periodic top-level task ────────────────────────────────────────────────
async def reconcile_all_once() -> None:
"""One scan over every subscription. Wired into the APScheduler at startup."""
async with AsyncSessionLocal() as db:
rows = await db.execute(
select(Subscription).where(
Subscription.active == True, # noqa: E712
Subscription.hl_api_key.is_not(None),
)
)
subs = rows.scalars().all()
if not subs:
return
# Snapshot the fields we read so we don't hold the session across HL calls.
snapshots = list(subs)
n_changed = 0
n_orphans = 0
n_errors = 0
for sub in snapshots:
summary = await _reconcile_wallet(sub)
n_changed += summary["marked_closed"]
n_orphans += len(summary["orphan_hl"])
if summary["error"]:
n_errors += 1
# Broadcast significant events so the UI shows a warning banner.
if summary["marked_closed"] or summary["orphan_hl"]:
try:
from app.ws.manager import manager
await manager.broadcast({
"type": "reconcile_drift",
"wallet": summary["wallet"],
"orphan_hl": summary["orphan_hl"],
"marked_closed": summary["marked_closed"],
"at": datetime.now(timezone.utc).isoformat() + "Z",
})
except Exception as exc:
logger.warning("reconcile WS broadcast failed: %s", exc)
if n_changed or n_orphans or n_errors:
logger.info("Reconcile cycle: %d subs scanned, %d trades marked closed, %d HL orphans, %d errors",
len(snapshots), n_changed, n_orphans, n_errors)