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:
+158
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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="0–100. 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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.1–50)")
|
||||
if not (0 <= s.min_confidence <= 100):
|
||||
raise HTTPException(422, "min_confidence must be 0–100")
|
||||
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
|
||||
raise HTTPException(422, "sys2_leverage must be 1–10")
|
||||
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 0–168. 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 0–168")
|
||||
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user