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:
+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