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:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
+158
View File
@@ -57,9 +57,167 @@ async def insert_fake_post(
return {"id": post.id, "text": post.text[:80]}
@router.post("/dev/fake-signal")
async def inject_fake_signal(
symbol: str = "ETHUSDT",
close: float = 1850.42,
tbr: float = 0.72,
vol_mult: float = 3.1,
bb_pct: float = 8.5,
btc_trend: str = "↑ uptrend",
):
"""Inject a synthetic funding_signal for end-to-end testing."""
from datetime import datetime, timezone
from app.services import funding_signal as fs
now = datetime.now(timezone.utc)
alert = {
"type": "funding_signal",
"symbol": symbol,
"time": now.isoformat(),
"close": close,
"tbr": tbr,
"vol_mult": vol_mult,
"bb_pct": bb_pct,
"bb_upper": round(close * 1.002, 4),
"btc_trend": btc_trend,
"enabled": fs.is_enabled(),
}
fs._recent_signals.append(alert)
if fs.is_enabled():
await manager.broadcast(alert)
return {"status": "broadcast", "alert": alert}
else:
return {"status": "recorded_only (monitor OFF)", "alert": alert}
@router.post("/dev/reanalyze")
async def trigger_reanalyze(
background_tasks: BackgroundTasks,
limit: int = 500,
dry_run: bool = False,
delay_secs: float = 0.5,
legacy_signals: bool = False,
model: str = "",
):
"""
Batch re-run AI analysis on unscored posts.
model: override the AI model (default: ai_live_model=flash for speed).
Runs in the background — poll GET /dev/reanalyze/status for progress.
"""
from app.services.reanalyze import reanalyze_unscored, get_state
from app.config import settings as _s
state = get_state()
if state["running"]:
return {"status": "already_running", "state": state}
effective_model = model or _s.ai_live_model # default to flash
background_tasks.add_task(
reanalyze_unscored, AsyncSessionLocal,
limit=limit, dry_run=dry_run, delay_secs=delay_secs,
legacy_signals=legacy_signals, model=effective_model,
)
return {
"status": "started",
"limit": limit,
"dry_run": dry_run,
"delay_secs": delay_secs,
"legacy_signals": legacy_signals,
"model": effective_model,
"message": f"Re-analyzing up to {limit} posts in background. Poll /api/dev/reanalyze/status.",
}
@router.get("/dev/reanalyze/status")
async def reanalyze_status():
"""Current progress of the background re-analyzer."""
from app.services.reanalyze import get_state
return get_state()
@router.post("/dev/backfill-prices")
async def trigger_price_backfill(background_tasks: BackgroundTasks, asset: str = "BTC"):
"""触发历史帖子价格回溯(后台运行)"""
from app.services.price_backfill import backfill_price_impact
background_tasks.add_task(backfill_price_impact, AsyncSessionLocal, asset)
return {"status": "started", "asset": asset, "message": "后台回溯中,约需 1-2 分钟"}
# ─── Convex-strategy backtest ──────────────────────────────────────────────
@router.post("/dev/backtest/post")
async def backtest_one_post(
post_id: int,
stop_loss_pct: float = 1.5,
trailing_stop_pct: float = 2.5,
trailing_activate_at_pct: float = 5.0,
take_profit_pct: float = 0.0, # 0 = treat as None (no fixed TP)
max_hold_hours: int = 168,
):
"""Replay one post through the new convex-strategy exit rules.
Pulls 1m candles from Binance for the post's hold window and simulates
trailing-stop + SL + max-hold. Useful for sanity-checking parameters.
Pass `take_profit_pct=0` to disable the fixed TP and run pure trailing.
"""
from app.services.backtest import backtest_post, BacktestParams
params = BacktestParams(
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct or None,
trailing_activate_at_pct=trailing_activate_at_pct or None,
take_profit_pct=take_profit_pct or None,
max_hold_hours=max_hold_hours,
)
r = await backtest_post(post_id, params)
if r is None:
return {"status": "skipped", "post_id": post_id}
return {"status": "ok", "result": r.to_dict()}
@router.post("/dev/paper-mode")
async def toggle_paper_mode(
wallet: str,
enabled: bool,
db: AsyncSession = Depends(get_db),
):
"""Flip paper_mode for a subscription. Dev-only — no signature required.
Paper mode: trades are simulated end-to-end (entry/exit from Binance,
DB row with hl_order_id='paper') but no Hyperliquid call is made.
"""
from sqlalchemy import select
from app.models import Subscription
wallet = wallet.lower().strip()
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
return {"status": "not_found", "wallet": wallet}
sub.paper_mode = bool(enabled)
await db.commit()
return {"status": "ok", "wallet": wallet, "paper_mode": sub.paper_mode}
@router.post("/dev/backtest/batch")
async def backtest_batch_route(
limit: int = 50,
min_confidence: int = 80,
stop_loss_pct: float = 1.5,
trailing_stop_pct: float = 2.5,
trailing_activate_at_pct: float = 5.0,
take_profit_pct: float = 0.0,
max_hold_hours: int = 168,
):
"""Batch backtest: every directional post with conf ≥ min_confidence.
WARNING: synchronous — for 50 posts at ~10s each on Binance fetches this
can take several minutes. Run with limit=5 first to sanity-check.
"""
from app.services.backtest import backtest_batch, BacktestParams
params = BacktestParams(
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct or None,
trailing_activate_at_pct=trailing_activate_at_pct or None,
take_profit_pct=take_profit_pct or None,
max_hold_hours=max_hold_hours,
)
return await backtest_batch(limit=limit, min_confidence=min_confidence, params=params)
+22
View File
@@ -0,0 +1,22 @@
"""
API endpoints for the BTC funding-rate reversal signal.
GET /api/funding/snapshot
Live snapshot of current funding state + signal verdict + 7d history.
Powers the BTC page "Funding" tab.
Historical fired signals are returned via the standard /api/posts endpoint
with source=funding_reversal — same plumbing as btc_bottom_reversal.
"""
from fastapi import APIRouter
from app.services.scanners.funding_reversal import get_current_snapshot
router = APIRouter(prefix="/funding", tags=["funding"])
@router.get("/snapshot")
async def snapshot() -> dict:
"""Cheap (≤2 outbound requests). Frontend polls every ~5min."""
return await get_current_snapshot()
+38
View File
@@ -0,0 +1,38 @@
"""
API endpoints for the breakout signal monitor.
GET /api/signal/status — current state (enabled, recent signals)
POST /api/signal/toggle — flip the on/off switch
GET /api/signal/history — last N signals (fired regardless of enabled state)
"""
from fastapi import APIRouter
from app.services.funding_signal import (
set_enabled,
is_enabled,
get_recent_signals,
get_status,
)
router = APIRouter(prefix="/signal", tags=["signal"])
@router.get("/status")
async def status():
return get_status()
@router.post("/toggle")
async def toggle(enabled: bool):
"""
Body: ?enabled=true or ?enabled=false
Example: POST /api/signal/toggle?enabled=true
"""
set_enabled(enabled)
return {"enabled": is_enabled()}
@router.get("/history")
async def history(limit: int = 20):
return get_recent_signals(limit)
+395
View File
@@ -0,0 +1,395 @@
"""KOL module — public read API.
Endpoints:
GET /api/kol/posts list KOL posts (newest first), with summary +
tickers but WITHOUT raw_text to keep payload small
GET /api/kol/posts/{id} full detail incl. raw_text for the modal/page view
"""
import json
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import KolDivergence, KolHoldingChange, KolHoldingSnapshot, KolPost, KolWallet, iso_utc
def _verify_admin_key(x_ingest_key: Optional[str]) -> None:
"""Shared-secret auth for write endpoints (wallet add, scan trigger).
Same key as the signal ingest endpoint. Fail-closed if INGEST_API_KEY unset."""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "write endpoint disabled (INGEST_API_KEY not configured)")
if not x_ingest_key:
raise HTTPException(401, "missing X-Ingest-Key header")
if x_ingest_key != expected:
raise HTTPException(401, "invalid X-Ingest-Key")
router = APIRouter()
logger = logging.getLogger(__name__)
def _parse_tickers(raw: Optional[str]) -> List[dict]:
if not raw:
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def _summary_dto(post: KolPost) -> dict:
"""List-view shape: no raw_text, no content_hash."""
return {
"id": post.id,
"kol_handle": post.kol_handle,
"source": post.source,
"url": post.url,
"title": post.title,
"published_at": iso_utc(post.published_at),
"summary": post.summary,
"tickers": _parse_tickers(post.tickers_json),
"analyzed_at": iso_utc(post.analyzed_at),
"analysis_model": post.analysis_model,
}
def _detail_dto(post: KolPost) -> dict:
base = _summary_dto(post)
base["raw_text"] = post.raw_text
return base
@router.get("/kol/posts")
async def list_kol_posts(
handle: Optional[str] = Query(default=None, description="filter by kol_handle"),
source: Optional[str] = Query(default=None, description="substack | twitter"),
limit: int = Query(default=50, ge=1, le=200),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
stmt = select(KolPost)
if handle:
stmt = stmt.where(KolPost.kol_handle == handle)
if source:
stmt = stmt.where(KolPost.source == source)
stmt = stmt.order_by(KolPost.published_at.desc()).offset((page - 1) * limit).limit(limit)
rows = (await db.execute(stmt)).scalars().all()
return {"items": [_summary_dto(p) for p in rows], "page": page, "limit": limit}
@router.get("/kol/posts/{post_id}")
async def get_kol_post(post_id: int, db: AsyncSession = Depends(get_db)) -> dict:
row = (await db.execute(select(KolPost).where(KolPost.id == post_id))).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="KOL post not found")
return _detail_dto(row)
# ── Action ordering for picking the "dominant" call per ticker ─────────
# When the same ticker appears across multiple posts with different actions,
# the strongest signal wins. buy/sell (explicit position) outrank
# bullish/bearish (directional view) outrank mention (passing reference).
_ACTION_STRENGTH = {"buy": 3, "sell": 3, "bullish": 2, "bearish": 2, "mention": 1}
_ACTION_SIDE = {"buy": "long", "bullish": "long",
"sell": "short", "bearish": "short", "mention": "neutral"}
@router.get("/kol/digest")
async def kol_digest(
days: int = Query(default=7, ge=1, le=90,
description="rolling window in days"),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Aggregate ticker calls across recent KOL posts.
For each ticker mentioned in posts within the window, returns the
dominant action, the highest conviction, and the list of (KOL, post,
action, conviction) calls. Sorted by post_count desc, then conviction.
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
rows = (await db.execute(
select(KolPost)
.where(KolPost.published_at >= since)
.where(KolPost.tickers_json.is_not(None))
.order_by(KolPost.published_at.desc())
)).scalars().all()
# ticker -> list of call dicts
bucket: dict[str, list[dict]] = defaultdict(list)
for post in rows:
try:
tickers = json.loads(post.tickers_json or "[]")
except json.JSONDecodeError:
continue
for t in tickers:
sym = (t.get("ticker") or "").upper()
action = (t.get("action") or "mention").lower()
if not sym or action == "mention":
# Don't surface 'mention' on the digest — too noisy. Users
# can still see it on the post detail.
continue
bucket[sym].append({
"post_id": post.id,
"kol_handle": post.kol_handle,
"post_title": post.title,
"published_at": iso_utc(post.published_at),
"action": action,
"conviction": float(t.get("conviction") or 0.0),
"quote": (t.get("quote") or "")[:240],
})
tickers_out: list[dict] = []
for sym, calls in bucket.items():
# Dominant action: pick the action with the highest sum of conviction,
# broken by strength tier then count. Ensures one strong "buy" beats
# three weak "bullish" mentions.
action_weights: dict[str, float] = defaultdict(float)
for c in calls:
action_weights[c["action"]] += c["conviction"]
dominant = max(
action_weights.items(),
key=lambda kv: (_ACTION_STRENGTH.get(kv[0], 0), kv[1]),
)[0]
max_conv = max(c["conviction"] for c in calls)
unique_kols = sorted({c["kol_handle"] for c in calls})
tickers_out.append({
"ticker": sym,
"dominant_action": dominant,
"side": _ACTION_SIDE.get(dominant, "neutral"),
"max_conviction": round(max_conv, 2),
"post_count": len(calls),
"kol_count": len(unique_kols),
"kols": unique_kols,
"calls": sorted(calls, key=lambda c: c["conviction"], reverse=True),
})
tickers_out.sort(
key=lambda t: (t["kol_count"], t["post_count"], t["max_conviction"]),
reverse=True,
)
return {
"window_days": days,
"since": iso_utc(since),
"post_count": len(rows),
"ticker_count": len(tickers_out),
"tickers": tickers_out,
}
# ── A-tier: on-chain wallets + holdings ──────────────────────────────────────
def _wallet_dto(w: KolWallet) -> dict:
return {
"id": w.id,
"handle": w.handle,
"chain": w.chain,
"address": w.address,
"label": w.label,
"source_url": w.source_url,
"active": w.active,
"added_at": iso_utc(w.added_at),
}
def _snapshot_dto(s: KolHoldingSnapshot) -> dict:
return {
"id": s.id,
"wallet_id": s.wallet_id,
"snapshot_date": s.snapshot_date,
"holdings": json.loads(s.holdings_json or "[]"),
"total_usd": s.total_usd,
"source": s.source,
"created_at": iso_utc(s.created_at),
}
def _change_dto(c: KolHoldingChange, handle: str) -> dict:
return {
"id": c.id,
"wallet_id": c.wallet_id,
"handle": handle,
"detected_at": iso_utc(c.detected_at),
"ticker": c.ticker,
"change_type": c.change_type,
"usd_before": c.usd_before,
"usd_after": c.usd_after,
"pct_change": c.pct_change,
}
@router.get("/kol/wallets")
async def list_kol_wallets(
handle: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
stmt = select(KolWallet).where(KolWallet.active == True)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolWallet.handle)
rows = (await db.execute(stmt)).scalars().all()
return {"wallets": [_wallet_dto(w) for w in rows]}
@router.post("/kol/wallets")
async def add_kol_wallet(
body: dict,
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Add a new KOL wallet address. Body: {handle, chain, address, label?, source_url?}.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
required = {"handle", "chain", "address"}
if not required.issubset(body):
raise HTTPException(status_code=422, detail=f"Required fields: {required}")
# Check duplicate
existing = (await db.execute(
select(KolWallet).where(
KolWallet.chain == body["chain"],
KolWallet.address == body["address"].lower(),
)
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Wallet already tracked")
wallet = KolWallet(
handle=body["handle"],
chain=body["chain"],
address=body["address"].lower(),
label=body.get("label"),
source_url=body.get("source_url"),
)
db.add(wallet)
await db.commit()
await db.refresh(wallet)
return _wallet_dto(wallet)
@router.get("/kol/wallets/{wallet_id}/snapshots")
async def get_wallet_snapshots(
wallet_id: int,
limit: int = Query(default=30, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
wallet = (await db.execute(
select(KolWallet).where(KolWallet.id == wallet_id)
)).scalar_one_or_none()
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
snaps = (await db.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet_id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(limit)
)).scalars().all()
return {
"wallet": _wallet_dto(wallet),
"snapshots": [_snapshot_dto(s) for s in snaps],
}
@router.get("/kol/changes")
async def list_holding_changes(
handle: Optional[str] = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Recent on-chain position changes across all tracked KOL wallets."""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
stmt = (
select(KolHoldingChange, KolWallet.handle)
.join(KolWallet, KolHoldingChange.wallet_id == KolWallet.id)
.where(KolHoldingChange.detected_at >= since)
)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolHoldingChange.detected_at.desc()).limit(200)
rows = (await db.execute(stmt)).all()
changes = [_change_dto(c, h) for c, h in rows]
return {
"window_days": days,
"since": iso_utc(since),
"count": len(changes),
"changes": changes,
}
# ── Talks-vs-trades divergence ────────────────────────────────────────────────
def _divergence_dto(d: KolDivergence) -> dict:
return {
"id": d.id,
"handle": d.handle,
"ticker": d.ticker,
"signal_type": d.signal_type, # divergence | alignment
"direction": d.direction, # long | short
"post_id": d.post_id,
"post_action": d.post_action,
"post_conviction":d.post_conviction,
"post_at": iso_utc(d.post_at),
"onchain_action": d.onchain_action,
"usd_before": d.usd_before,
"usd_after": d.usd_after,
"onchain_at": iso_utc(d.onchain_at),
"days_apart": d.days_apart,
"created_at": iso_utc(d.created_at),
}
@router.get("/kol/divergence")
async def list_divergence(
handle: Optional[str] = Query(default=None),
ticker: Optional[str] = Query(default=None),
signal_type: Optional[str] = Query(default=None, description="divergence | alignment"),
days: int = Query(default=30, ge=1, le=180),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""List talks-vs-trades cross-signal pairs.
signal_type=divergence → KOL said X but chain did the opposite (high alpha).
signal_type=alignment → KOL's words matched their on-chain action (reinforced signal).
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
stmt = select(KolDivergence).where(KolDivergence.created_at >= since)
if handle:
stmt = stmt.where(KolDivergence.handle == handle)
if ticker:
stmt = stmt.where(KolDivergence.ticker == ticker.upper())
if signal_type:
stmt = stmt.where(KolDivergence.signal_type == signal_type)
stmt = stmt.order_by(KolDivergence.created_at.desc()).limit(200)
rows = (await db.execute(stmt)).scalars().all()
return {
"window_days": days,
"since": iso_utc(since),
"count": len(rows),
"items": [_divergence_dto(r) for r in rows],
}
@router.post("/kol/divergence/scan")
async def trigger_divergence_scan(
lookback_days: int = Query(default=30, ge=1, le=90),
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
) -> dict[str, Any]:
"""Manually trigger the talks-vs-trades scan. Returns newly written pairs.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
from app.services.kol_divergence import run_divergence_scan
results = await run_divergence_scan(lookback_days=lookback_days)
return {"new_pairs": len(results), "items": results}
+19 -2
View File
@@ -1,26 +1,43 @@
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade
from app.schemas import BotPerformance
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
PERIOD_DAYS = 30
ACTION_VIEW_PERFORMANCE = "view_performance"
@router.get("/performance", response_model=BotPerformance)
async def get_performance(db: AsyncSession = Depends(get_db)):
async def get_performance(
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),
):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_PERFORMANCE,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=PERIOD_DAYS)
result = await db.execute(
select(BotTrade)
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None))
.where(BotTrade.opened_at >= since)
.order_by(BotTrade.opened_at.asc())
+392
View File
@@ -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)
+1
View File
@@ -61,6 +61,7 @@ def _post_to_schema(post: Post) -> TrumpPost:
target_asset=post.target_asset,
category=post.category,
expected_move_pct=post.expected_move_pct,
invalidation_price=post.invalidation_price,
)
+182
View File
@@ -0,0 +1,182 @@
"""
Scanner control endpoints — kill switch + per-scanner toggle + status.
GET /scanners is public (read-only status, polled by the UI).
ALL mutating endpoints (toggle / all-disable / all-enable) require a SIGNED
request from a SUBSCRIBED wallet. Rationale: the scanner switch is a global
control — flipping it OFF stops the signal engine for every user. Before, it
was unauthenticated and any anonymous visitor could kill it. Now the caller
must prove wallet ownership (EIP-191 signature) AND already be a subscriber.
# See what's running (still public)
curl http://localhost:8000/api/scanners
Mutations are no longer curl-able without a wallet signature — that's the
point. Use the dashboard (which signs via the connected wallet).
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, 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 Subscription
from app.services import scanner_state
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
ACTION_TOGGLE_SCANNER = "toggle_scanner"
ACTION_SCANNER_KILL = "scanner_kill_all"
ACTION_SCANNER_REVIVE = "scanner_revive_all"
class ScannerSummary(BaseModel):
name: str
enabled: bool
last_run_at: Optional[str] = None
last_status: str
last_message: Optional[str] = None
last_fired_at: Optional[str] = None
total_runs: int
total_fires: int
consecutive_errors: int
class ScannersResponse(BaseModel):
count: int
enabled: int
scanners: list[ScannerSummary]
class ToggleResponse(BaseModel):
status: str
name: Optional[str] = None
enabled: Optional[bool] = None
count: Optional[int] = None
async def _require_subscribed_signer(
request: Request,
db: AsyncSession,
*,
action: str,
body: Optional[dict],
) -> str:
"""Verify the signed envelope and that the signer is a subscriber.
Expects JSON body: { wallet, timestamp, signature, ...body fields }.
`body` is the canonical signed payload (must match what the client
hashed) — pass None for no-body actions (kill/revive).
Returns the verified lower-cased wallet.
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
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")
verify_signed_request(
action=action,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body=body,
)
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
raise HTTPException(403, "Wallet is not a subscriber — scanner control denied")
return wallet
@router.get("/scanners", response_model=ScannersResponse)
async def list_scanners() -> ScannersResponse:
"""Operational snapshot of every registered scanner. Polled by the UI
every 15-30s. Idempotent + cheap (in-memory dict read). Public."""
all_states = scanner_state.get_all()
return ScannersResponse(
count=len(all_states),
enabled=sum(1 for s in all_states if s.enabled),
scanners=[ScannerSummary(**s.to_dict()) for s in all_states],
)
@router.post("/scanners/{name}/toggle", response_model=ToggleResponse)
async def toggle_scanner(
name: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Flip ONE scanner. Signed + subscriber-gated.
Body: { wallet, timestamp, signature, name, enabled }
The signed body is { "enabled": <bool>, "name": <str> }.
"""
raw = await request.json()
enabled = raw.get("enabled")
body_name = raw.get("name")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
if body_name != name:
raise HTTPException(400, "scanner name mismatch (path vs signed body)")
# NOTE: request.json() is cached by Starlette, so re-reading inside the
# helper returns the same payload.
wallet = await _require_subscribed_signer(
request, db, action=ACTION_TOGGLE_SCANNER,
body={"enabled": enabled, "name": name},
)
s = scanner_state.set_enabled(name, enabled)
if s is None:
raise HTTPException(404, f"unknown scanner {name!r}")
logger.info("Scanner %s set enabled=%s by %s", name, s.enabled, wallet)
return ToggleResponse(status="ok", name=name, enabled=s.enabled)
@router.post("/scanners/all/disable", response_model=ToggleResponse)
async def kill_switch(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Emergency stop — disables ALL scanners. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }. Does NOT close open positions —
manage those via /positions/{id}/close or the Trades page."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_KILL, body=None,
)
n = scanner_state.disable_all()
logger.warning("ALL scanners disabled (%d) by %s", n, wallet)
return ToggleResponse(status="killed", count=n)
@router.post("/scanners/all/enable", response_model=ToggleResponse)
async def revive_all(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Re-enable every scanner that was disabled. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_REVIVE, body=None,
)
n = scanner_state.enable_all()
logger.info("ALL scanners re-enabled (%d) by %s", n, wallet)
return ToggleResponse(status="revived", count=n)
+243
View File
@@ -0,0 +1,243 @@
"""
Generic signal ingestion endpoint.
The BTC bottom scanner POSTs a signal here and it flows through the shared
execution pipeline: sizing → risk caps → Hyperliquid execution → trailing
stop monitor. Unknown sources are accepted into the posts table for audit,
but the execution layer fails closed and will not trade them.
Why one endpoint instead of many?
The trusted scanners and the Trump scraper share the same trade execution
path. The downstream code sees a `Post` row with `signal=buy|short`, then
checks whether that post source is allowed to trade.
Auth: shared secret in X-Ingest-Key header. Fail-closed if INGEST_API_KEY
is empty in env (so a fresh deploy can't accidentally accept random POSTs).
Source field convention:
- "truth" → Trump posts (existing scraper). Reserved — rejected here.
- "btc_bottom_reversal" → Bitcoin Bottom scanner
- "<custom>" → Audit/storage only unless whitelisted in
signal_categories.py.
"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import Post
router = APIRouter()
logger = logging.getLogger(__name__)
# ─── Schema ─────────────────────────────────────────────────────────────────
class SignalIngestRequest(BaseModel):
"""Payload for POST /api/signals/ingest.
Mirrors the Post schema closely so a signal here becomes a Post row
1:1, with no field translation. Downstream code is unaware of the source.
"""
source: str = Field(..., min_length=2, max_length=32,
description="Signal source tag, e.g. 'btc_bottom_reversal'. 'truth' is reserved.")
external_id: str = Field(..., min_length=4, max_length=64,
description="Caller-supplied unique key. Used for idempotent retries.")
text: str = Field(..., min_length=1, max_length=4000,
description="Human-readable description of why the signal fired. Shown in UI.")
signal: str = Field(..., description="'buy' or 'short' — non-actionable signals should not be ingested.")
target_asset: str = Field(..., min_length=2, max_length=16,
description="Hyperliquid perp ticker, e.g. 'SOL', 'BTC'.")
confidence: int = Field(..., ge=0, le=100,
description="0100. Drives position sizing (≥90 boosts multiplier).")
category: str = Field(..., min_length=2, max_length=24,
description="Subtype within source, e.g. 'btc_bottom_reversal_long'.")
expected_move_pct: Optional[float] = Field(None, ge=0, le=100,
description="Optional: caller's expected % move. UI display only.")
invalidation_price: Optional[float] = Field(None, ge=0,
description="Optional thesis invalidation level for System 2.")
published_at: Optional[datetime] = Field(None,
description="Defaults to server now (UTC). Set explicitly only for backfill.")
@field_validator("signal")
@classmethod
def _signal_must_be_directional(cls, v: str) -> str:
if v not in ("buy", "short"):
raise ValueError(f"signal must be 'buy' or 'short' (got {v!r})")
return v
@field_validator("source")
@classmethod
def _no_reserved_sources(cls, v: str) -> str:
if v.lower() == "truth":
raise ValueError("'truth' is reserved for the Trump scraper. Use a different source tag.")
return v.lower()
class SignalIngestResponse(BaseModel):
status: str # "accepted" | "duplicate" | "skipped"
post_id: Optional[int] = None
dedup_against: Optional[int] = None
note: Optional[str] = None
# ─── Auth helper ────────────────────────────────────────────────────────────
def _verify_ingest_key(x_ingest_key: Optional[str]) -> None:
"""Fail-closed auth check.
- INGEST_API_KEY unset → return 503 (endpoint disabled by config)
- Header missing or wrong → 401
"""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "signal ingest endpoint is disabled (INGEST_API_KEY not configured)")
if not x_ingest_key:
raise HTTPException(401, "missing X-Ingest-Key header")
if x_ingest_key != expected:
raise HTTPException(401, "invalid X-Ingest-Key")
# ─── Endpoint ───────────────────────────────────────────────────────────────
@router.post("/signals/ingest", response_model=SignalIngestResponse)
async def ingest_signal(
body: SignalIngestRequest,
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
db: AsyncSession = Depends(get_db),
) -> SignalIngestResponse:
"""Accept a signal from an external trading module.
Pipeline behaviour:
1. Auth (shared secret in header)
2. Dedup by external_id (idempotent — same id returns the existing post_id)
3. Insert as a Post row (source = caller-supplied)
4. If the source is whitelisted for trading, hand off to
bot_engine.process_post. Otherwise store for audit and return
status="skipped".
Note: the entry filter ([A] in the pipeline) is SKIPPED for non-truth
sources. That filter is tuned for Trump's writing patterns; technical
signals are their own catalyst and don't need text-based gating.
"""
_verify_ingest_key(x_ingest_key)
# Hash external_id the same way truth_social.py does — keeps the dedup
# key short and uniform across sources.
ext_id_hashed = hashlib.md5(f"{body.source}:{body.external_id}".encode()).hexdigest()
# Idempotency check
existing = await db.execute(select(Post).where(Post.external_id == ext_id_hashed))
prior = existing.scalar_one_or_none()
if prior:
return SignalIngestResponse(
status="duplicate",
post_id=prior.id,
dedup_against=prior.id,
note=f"external_id {body.external_id!r} already ingested",
)
# Publication time defaults to server now (naive UTC, matching other rows)
pub_naive = (body.published_at or datetime.now(timezone.utc))
if pub_naive.tzinfo is not None:
pub_naive = pub_naive.astimezone(timezone.utc).replace(tzinfo=None)
post = Post(
external_id=ext_id_hashed,
text=body.text,
source=body.source,
published_at=pub_naive,
sentiment="bullish" if body.signal == "buy" else "bearish",
ai_confidence=body.confidence,
relevant=True,
signal=body.signal,
target_asset=body.target_asset.upper(),
category=body.category,
expected_move_pct=body.expected_move_pct,
invalidation_price=body.invalidation_price,
# `analysis_version` doubles as a provenance tag — easy to query in DB
analysis_version=f"ingest:{body.source}",
prefilter_reason="external_signal", # bypasses entry-filter audit
)
db.add(post)
await db.commit()
await db.refresh(post)
logger.info(
"Ingested signal: source=%s id=%s → post_id=%d, %s/%s conf=%d category=%s",
body.source, body.external_id, post.id,
body.signal, body.target_asset, body.confidence, body.category,
)
from app.services.signal_categories import is_supported_trading_source
if not is_supported_trading_source(post.source):
logger.info("Signal %d stored but skipped: unsupported trading source=%s",
post.id, post.source)
return SignalIngestResponse(
status="skipped",
post_id=post.id,
note=f"source {post.source!r} is not enabled for live trading",
)
# Hand off to the same trade pipeline used by Trump posts.
# Runs sizing → risk caps → HL trade → trailing-stop monitor.
try:
from app.services.bot_engine import process_post
await process_post(post, db)
except Exception as exc:
# Don't fail the HTTP response — the post is already in DB, and
# process_post failures shouldn't make the external module retry
# (which would duplicate signals). Log and return success.
logger.error("process_post failed for ingested signal %d: %s", post.id, exc)
# Broadcast to UI so the new signal shows up live, same as Trump posts.
try:
from app.scrapers.truth_social import _post_to_ws_payload
from app.ws.manager import manager
await manager.broadcast(_post_to_ws_payload(post))
except Exception as exc:
logger.warning("WS broadcast failed for signal %d: %s", post.id, exc)
# Fan out to Telegram subscribers (fire-and-forget; never blocks ingest).
try:
from app.services.telegram import notify_signal
notify_signal(post)
except Exception as exc:
logger.warning("Telegram notify failed for signal %d: %s", post.id, exc)
return SignalIngestResponse(status="accepted", post_id=post.id)
@router.get("/signals/sources")
async def list_sources(db: AsyncSession = Depends(get_db)) -> dict:
"""List distinct signal sources we've seen, with counts.
Helpful for spot-checking that ingestion is working and for the UI to
show a source filter without hard-coding the list.
"""
from sqlalchemy import func
rows = await db.execute(
select(Post.source, func.count(Post.id), func.max(Post.published_at))
.group_by(Post.source)
.order_by(func.count(Post.id).desc())
)
return {
"sources": [
{"source": r[0], "count": r[1], "latest": r[2].isoformat() if r[2] else None}
for r in rows.all()
]
}
+29 -6
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,15 +17,29 @@ ACTION_SUBSCRIBE = "subscribe"
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
async def subscribe(request: Request, db: AsyncSession = Depends(get_db)):
"""Activate a subscription. Optional `paper_mode` flag in the body lets
new users try the system safely (no Hyperliquid call, simulated fills).
Signed message body is the RAW dict so the canonical hash matches what
the frontend signed — same pattern as set_user_settings.
"""
raw = await request.json()
body = SubscribeRequest(**raw)
wallet = body.wallet.lower().strip()
# The signed body excludes the envelope fields. For backwards compatibility
# with old clients that signed `body=None` (no paper_mode key), accept
# either signature.
paper_mode = bool(raw.get("paper_mode", False))
signed_body = {"paper_mode": paper_mode} if paper_mode else None
verify_signed_request(
action=ACTION_SUBSCRIBE,
wallet=wallet,
timestamp_ms=body.timestamp,
signature=body.signature,
body=None,
body=signed_body,
)
result = await db.execute(
@@ -35,13 +49,22 @@ async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
now = datetime.now(timezone.utc).replace(tzinfo=None)
if sub is None:
sub = Subscription(wallet_address=wallet, active=True, subscribed_at=now)
sub = Subscription(
wallet_address=wallet,
active=True,
subscribed_at=now,
paper_mode=paper_mode,
)
db.add(sub)
else:
sub.active = True
if sub.subscribed_at is None:
sub.subscribed_at = now
# Re-subscribing is allowed to change paper mode (e.g. user wants to
# promote from paper to live). Otherwise leave existing flag alone.
if paper_mode != sub.paper_mode:
sub.paper_mode = paper_mode
await db.commit()
logger.info("Subscription activated for wallet %s", wallet)
return SubscribeResponse(status="ok", wallet=wallet)
logger.info("Subscription activated for %s (paper_mode=%s)", wallet, paper_mode)
return SubscribeResponse(status="ok", wallet=wallet, paper_mode=paper_mode)
+232
View File
@@ -0,0 +1,232 @@
"""
Telegram binding + preferences API.
All mutating endpoints require a signed envelope from the wallet (same EIP-191
flow as set_hl_api_key). Read endpoints are unsigned but require the wallet
in the path so other users' bindings stay private.
GET /api/telegram/{wallet}/status ← unsigned read
POST /api/telegram/{wallet}/init ← signed; returns binding code + deep link
POST /api/telegram/{wallet}/preferences ← signed; update toggles
POST /api/telegram/{wallet}/unbind ← signed; removes binding
POST /api/telegram/{wallet}/test ← signed; sends a self-test
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import TelegramBinding, Subscription
from app.services.signed_request import verify_signed_request
from app.services.telegram import send_test_message
from app.services.telegram_bot import issue_binding_code, unbind_wallet
router = APIRouter(prefix="/telegram", tags=["telegram"])
logger = logging.getLogger(__name__)
ACTION_TG_INIT = "telegram_init"
ACTION_TG_PREFS = "telegram_prefs"
ACTION_TG_UNBIND = "telegram_unbind"
ACTION_TG_TEST = "telegram_test"
def _require_tg_configured() -> None:
if not settings.telegram_bot_token or not settings.telegram_bot_username:
raise HTTPException(503, "Telegram alerts are not configured on this server")
# ── Schemas ──────────────────────────────────────────────────────────────
class SignedEnvelope(BaseModel):
wallet: str
timestamp: int
signature: str
class PrefsBody(SignedEnvelope):
alerts_enabled: Optional[bool] = None
alert_trump: Optional[bool] = None
alert_btc_bottom: Optional[bool] = None
alert_funding: Optional[bool] = None
alert_kol_divergence: Optional[bool] = None
min_confidence: Optional[int] = Field(None, ge=0, le=100)
mute_from_hour: Optional[int] = Field(None, ge=0, le=23)
mute_until_hour: Optional[int] = Field(None, ge=0, le=23)
class StatusResponse(BaseModel):
configured: bool # whether server has bot token
bot_username: Optional[str] = None
bound: bool
wallet_address: Optional[str] = None
tg_username: Optional[str] = None
chat_id: Optional[int] = None
alerts_enabled: Optional[bool] = None
alert_trump: Optional[bool] = None
alert_btc_bottom: Optional[bool] = None
alert_funding: Optional[bool] = None
alert_kol_divergence: Optional[bool] = None
min_confidence: Optional[int] = None
mute_from_hour: Optional[int] = None
mute_until_hour: Optional[int] = None
total_alerts_sent: Optional[int] = None
class InitResponse(BaseModel):
code: str
deep_link: str # t.me/<bot>?start=<code>
expires_in_seconds: int
# ── Endpoints ────────────────────────────────────────────────────────────
@router.get("/{wallet}/status", response_model=StatusResponse)
async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusResponse:
"""Public-by-wallet read. Returns whether server is configured AND
whether this wallet has bound a Telegram chat."""
wallet = wallet.lower().strip()
configured = bool(settings.telegram_bot_token and settings.telegram_bot_username)
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
if not b:
return StatusResponse(configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=False)
return StatusResponse(
configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=True,
wallet_address=b.wallet_address,
tg_username=b.tg_username,
chat_id=b.chat_id,
alerts_enabled=b.alerts_enabled,
alert_trump=b.alert_trump,
alert_btc_bottom=b.alert_btc_bottom,
alert_funding=b.alert_funding,
alert_kol_divergence=b.alert_kol_divergence,
min_confidence=b.min_confidence,
mute_from_hour=b.mute_from_hour,
mute_until_hour=b.mute_until_hour,
total_alerts_sent=b.total_alerts_sent,
)
@router.post("/{wallet}/init", response_model=InitResponse)
async def init_binding(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> InitResponse:
"""Generate a one-time code and the deep link the frontend renders.
Subscriber-gated — only paying wallets can receive alerts."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_INIT, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if not sub or not sub.active:
raise HTTPException(403, "Wallet must be subscribed to enable Telegram alerts")
code = issue_binding_code(wallet)
deep_link = f"https://t.me/{settings.telegram_bot_username}?start={code}"
return InitResponse(code=code, deep_link=deep_link, expires_in_seconds=600)
@router.post("/{wallet}/preferences", response_model=StatusResponse)
async def update_preferences(
wallet: str, body: PrefsBody,
db: AsyncSession = Depends(get_db),
) -> StatusResponse:
"""Toggle alert sources, confidence floor, mute hours. Signed.
Idempotent — only fields present in the body are updated."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
# Build a canonical body for signing: include only the fields the user
# is actually trying to change (so the signed payload matches what the
# frontend hashed).
signed_body = {k: v for k, v in body.model_dump(exclude={"wallet", "timestamp", "signature"}).items()
if v is not None}
verify_signed_request(
action=ACTION_TG_PREFS, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature,
body=signed_body,
)
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
if not b:
raise HTTPException(404, "No Telegram binding for this wallet — bind via /start first")
values = {}
for f in ["alerts_enabled", "alert_trump", "alert_btc_bottom",
"alert_funding", "alert_kol_divergence", "min_confidence",
"mute_from_hour", "mute_until_hour"]:
v = getattr(body, f)
if v is not None:
values[f] = v
if values:
await db.execute(
update(TelegramBinding).where(TelegramBinding.id == b.id).values(**values)
)
await db.commit()
await db.refresh(b)
return await status(wallet, db)
@router.post("/{wallet}/unbind")
async def unbind(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> dict:
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_UNBIND, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
n = await unbind_wallet(wallet)
return {"removed": n}
@router.post("/{wallet}/test")
async def test(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Send a sample alert to verify the binding works end-to-end."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_TEST, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
ok = await send_test_message(wallet)
if not ok:
raise HTTPException(400, "Test failed — bind via /start first, or check bot token")
return {"sent": True}
+33 -1
View File
@@ -3,17 +3,31 @@ from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade, iso_utc
from app.schemas import BotTrade as BotTradeSchema
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
ACTION_VIEW_TRADES = "view_trades"
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
# Join against trigger_post to surface the source tag. When a trade was
# opened by an ingested signal (VCP scanner, user's module, etc.) the
# source reveals WHICH module produced it — critical for "is module X
# actually making money?" attribution analysis.
trigger_source = None
if trade.trigger_post is not None:
trigger_source = trade.trigger_post.source
# Paper trades are tagged via hl_order_id at open time; that's the only
# stable signal we have to distinguish them in aggregate views.
is_paper = (trade.hl_order_id == "paper")
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
@@ -25,18 +39,36 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
trigger_post_id=trade.trigger_post_id or 0,
opened_at=iso_utc(trade.opened_at) or "",
closed_at=iso_utc(trade.closed_at) or "",
trigger_source=trigger_source,
is_paper=is_paper,
)
@router.get("/trades", response_model=List[BotTradeSchema])
async def get_trades(
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
limit: int = Query(default=20, ge=1, le=100),
page: int = Query(default=1, ge=1),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_TRADES,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
offset = (page - 1) * limit
# joinedload pulls trigger_post in the same query — avoids N+1 lookups
# when serialising 100 trades.
result = await db.execute(
select(BotTrade)
.options(joinedload(BotTrade.trigger_post))
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None))
.order_by(BotTrade.opened_at.desc())
.offset(offset)
+177 -5
View File
@@ -15,15 +15,30 @@ from app.schemas import (
SetSettingsRequest,
)
from app.services.crypto import encrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
# Action names — must match frontend exactly (used in the signed message)
ACTION_SET_API_KEY = "set_hl_api_key"
ACTION_SET_SETTINGS = "set_settings"
ACTION_VIEW_USER = "view_user"
ACTION_SET_API_KEY = "set_hl_api_key"
ACTION_SET_SETTINGS = "set_settings"
ACTION_VIEW_USER = "view_user"
ACTION_SET_MANUAL_WINDOW = "set_manual_window"
ACTION_SET_AUTO_TRADE = "set_auto_trade"
async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> None:
"""Fail before storing an unusable Hyperliquid API wallet key."""
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=account_address,
)
await trader.get_balance()
except Exception as exc:
raise HTTPException(422, f"Hyperliquid rejected this API key: {exc}")
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
@@ -70,12 +85,14 @@ async def set_hl_api_key(
if sub is None:
raise HTTPException(404, "Wallet not subscribed. Subscribe first.")
await verify_hl_api_key_can_trade(api_key=api_key, account_address=wallet)
sub.hl_api_key = encrypt_api_key(api_key)
await db.commit()
masked = f"...{api_key[-6:]}"
logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked)
return SetApiKeyResponse(status="ok", masked_key=masked)
return SetApiKeyResponse(status="ok", masked_key=masked, verified=True)
@router.get("/user/{wallet}/public")
@@ -86,11 +103,22 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
return {"wallet_address": wallet, "active": False, "hl_api_key_set": False}
return {
"wallet_address": wallet, "active": False, "hl_api_key_set": False,
"paper_mode": False, "manual_window_until": None,
"circuit_breaker_tripped_at": None, "circuit_breaker_reason": None,
"auto_trade": False,
}
return {
"wallet_address": wallet,
"active": sub.active,
"hl_api_key_set": bool(sub.hl_api_key),
# Operational state shown on /signals page. Not sensitive.
"paper_mode": bool(sub.paper_mode),
"manual_window_until": iso_utc(sub.manual_window_until),
"circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at),
"circuit_breaker_reason": sub.circuit_breaker_reason,
"auto_trade": bool(sub.auto_trade),
}
@@ -151,9 +179,12 @@ async def get_user(
stop_loss_pct=sub.stop_loss_pct,
min_confidence=sub.min_confidence,
daily_budget_usd=sub.daily_budget_usd,
sys2_leverage=sub.sys2_leverage,
sys2_mode=sub.sys2_mode,
active_from=iso_utc(sub.active_from),
active_until=iso_utc(sub.active_until),
),
manual_window_until=iso_utc(sub.manual_window_until),
)
@@ -190,6 +221,10 @@ async def set_user_settings(
raise HTTPException(422, "stop_loss_pct is required (0.150)")
if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100")
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
raise HTTPException(422, "sys2_leverage must be 110")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive"):
raise HTTPException(422, "sys2_mode must be 'standard' or 'aggressive'")
if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)")
@@ -235,8 +270,145 @@ async def set_user_settings(
sub.stop_loss_pct = s.stop_loss_pct
sub.min_confidence = s.min_confidence
sub.daily_budget_usd = s.daily_budget_usd
sub.sys2_leverage = s.sys2_leverage
if s.sys2_mode is not None:
sub.sys2_mode = s.sys2_mode
sub.active_from = af
sub.active_until = au
await db.commit()
logger.info("Settings updated for %s: %s", wallet, s.model_dump())
return s
# ─── Manual window (convex-strategy "enable for N hours" override) ───────────
@router.post("/user/{wallet}/manual-window")
async def set_manual_window(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Arm the bot for the next N hours, overriding the active_from/until schedule.
Body: { wallet, timestamp, signature, hours }
hours: integer 0168. Pass 0 to clear (immediate disarm).
The bot's main gate (Subscription.active) still applies. This endpoint only
flips the schedule override; an inactive subscription stays paused.
"""
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
wallet = wallet.lower().strip()
raw = await request.json()
# Manual auth payload — keep deliberately minimal so the signed string is
# short and easy to reason about.
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
hours_raw = raw.get("hours")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
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 isinstance(hours_raw, int) or hours_raw < 0 or hours_raw > 168:
raise HTTPException(422, "hours must be 0168")
verify_signed_request(
action=ACTION_SET_MANUAL_WINDOW,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"hours": hours_raw},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
if hours_raw == 0:
sub.manual_window_until = None
new_until_iso = None
logger.info("Manual window cleared for %s", wallet)
else:
until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw)
sub.manual_window_until = until
new_until_iso = iso_utc(until)
# Explicit re-arm clears any active circuit-breaker trip — human in
# the loop has acknowledged the risk and chosen to resume.
cb_cleared = False
if sub.circuit_breaker_tripped_at is not None:
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
cb_cleared = True
logger.info("Manual window armed for %s: until %s (%dh)%s",
wallet, until, hours_raw, " — CB cleared" if cb_cleared else "")
await db.commit()
return {"manual_window_until": new_until_iso}
# ─── Master Auto-Trade switch (simplified operator model) ───────────────────
@router.post("/user/{wallet}/auto-trade")
async def set_auto_trade(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Flip the ONE persistent Auto-Trade gate.
Body: { wallet, timestamp, signature, enabled }
enabled=false → signals are still ingested/shown but NO trade opens.
enabled=true → qualifying signals auto-open trades. Turning it ON also
acknowledges + clears a tripped circuit breaker
(human-in-the-loop), mirroring the old manual-window arm.
"""
wallet = wallet.lower().strip()
raw = await request.json()
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
enabled = raw.get("enabled")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
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 isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
verify_signed_request(
action=ACTION_SET_AUTO_TRADE,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"enabled": enabled},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
sub.auto_trade = enabled
cb_cleared = False
if enabled and sub.circuit_breaker_tripped_at is not None:
# Turning Auto-Trade ON = explicit human ack → clear the breaker.
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
cb_cleared = True
await db.commit()
logger.info("Auto-Trade %s for %s%s",
"ON" if enabled else "OFF", wallet,
" — CB cleared" if cb_cleared else "")
return {"auto_trade": enabled, "circuit_breaker_cleared": cb_cleared}