fix: pre-launch hardening — HYPE price feed, KOL wallet cleanup, Telegram Trump alert, rate limiting, brittle test

Batch of the pre-launch audit campaign (BUG-01…14 plus three new features):

Pricing / TP-SL protection
- Add app/services/hl_price_feed.py: supplemental HL allMids poller for
  HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store +
  tp_sl_monitor.on_price_tick so bot trades on these assets keep full
  stop-loss / take-profit / trailing protection instead of max-hold only.
- Wire feed into main.py lifespan (startup task + graceful shutdown cancel).

Telegram
- Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump
  posts with no directional signal (relevant=True, signal=hold) now alert
  the public channel only (no per-subscriber noise).
- Rate limiter (slowapi) on the API; assorted bot/digest fixes.

KOL on-chain
- seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate
  orphaned wallets (handle not in KOL_FEEDS → can never produce divergence)
  so the scanner stops burning cycles on them.

Tests / misc
- Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses
  realistic ms timestamps so the in-progress-day drop fires, matching the
  fetcher's bar count (was 0.3179 vs 0.3178 off-by-one).
- Refresh stale notify_signal comment in truth_social.py.

Frontend reduce-action type fix lives in the sibling repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 11:57:19 +08:00
parent 6471e44aac
commit d6c802ef26
40 changed files with 1833 additions and 209 deletions
+4 -3
View File
@@ -8,13 +8,14 @@ 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
from app.services.signed_request import verify_signed_request_any
router = APIRouter()
logger = logging.getLogger(__name__)
PERIOD_DAYS = 30
ACTION_VIEW_PERFORMANCE = "view_performance"
ACTION_VIEW_USER = "view_user"
@router.get("/performance", response_model=BotPerformance)
@@ -25,8 +26,8 @@ async def get_performance(
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_PERFORMANCE,
verify_signed_request_any(
actions=[ACTION_VIEW_PERFORMANCE, ACTION_VIEW_USER],
wallet=wallet,
timestamp_ms=ts,
signature=sig,
+6 -5
View File
@@ -35,7 +35,7 @@ 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
from app.services.signed_request import verify_signed_request, verify_signed_request_any
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -150,8 +150,8 @@ async def get_open_positions(
):
"""Live open positions for the wallet, with mark-to-market PnL."""
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_POSITIONS,
verify_signed_request_any(
actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER],
wallet=wallet,
timestamp_ms=ts,
signature=sig,
@@ -187,8 +187,8 @@ async def get_today_stats(
one-shot count for opens.
"""
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_POSITIONS,
verify_signed_request_any(
actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER],
wallet=wallet,
timestamp_ms=ts,
signature=sig,
@@ -242,6 +242,7 @@ async def get_today_stats(
ACTION_CLOSE_TRADE = "close_trade"
ACTION_SET_GROW = "set_trade_grow"
ACTION_VIEW_POSITIONS = "view_positions"
ACTION_VIEW_USER = "view_user"
ACTION_ADOPT_POSITION = "adopt_position"
ACTION_RELEASE_TRADE = "release_trade"
+14 -1
View File
@@ -1,7 +1,12 @@
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,16 +71,24 @@ def _post_to_schema(post: Post) -> TrumpPost:
@router.get("/posts", response_model=List[TrumpPost])
@limiter.limit("60/minute")
async def get_posts(
request: Request,
limit: int = Query(default=20, ge=1, le=500),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
offset = (page - 1) * limit
result = await db.execute(
select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit)
)
posts = result.scalars().all()
# Posts are scraped every 5s but rarely change once written — allow CDN/browser
# to cache for 30s. stale-while-revalidate=60 means stale content is served
# while a fresh fetch happens in the background (no loading flash).
if response is not None:
response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60"
return [_post_to_schema(p) for p in posts]
+17 -3
View File
@@ -2,7 +2,12 @@ import logging
from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
from app.config import settings
from app.schemas import Candle
@@ -48,10 +53,13 @@ async def fetch_binance_candles(asset: str, tf: str, limit: int) -> List[Candle]
@router.get("/prices/{asset}", response_model=List[Candle])
@limiter.limit("30/minute")
async def get_prices(
request: Request,
asset: str,
tf: str = Query(default="4H"),
limit: int = Query(default=200, ge=1, le=1000),
response: Response = None,
):
asset = asset.upper()
if asset not in VALID_ASSETS:
@@ -59,12 +67,18 @@ async def get_prices(
if tf not in VALID_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"Timeframe must be one of {VALID_TIMEFRAMES}")
# 1m: use in-memory store (updated in real-time)
# 1m: use in-memory store (updated in real-time) — don't cache at CDN level
if tf == "1m":
candles = price_store.get_candles(asset, "1m", limit)
if response is not None:
response.headers["Cache-Control"] = "public, max-age=5, stale-while-revalidate=10"
return [Candle(**c) for c in candles]
# All other timeframes: fetch directly from Binance
# Larger timeframes change infrequently — safe to cache longer
if response is not None:
ttl = 60 if tf in ("5m", "15m") else 300 # 1m for short TFs, 5m for 1H+
response.headers["Cache-Control"] = f"public, max-age={ttl}, stale-while-revalidate={ttl * 2}"
try:
return await fetch_binance_candles(asset, tf, limit)
except Exception as exc:
+4 -3
View File
@@ -9,12 +9,13 @@ 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
from app.services.signed_request import verify_signed_request_any
router = APIRouter()
logger = logging.getLogger(__name__)
ACTION_VIEW_TRADES = "view_trades"
ACTION_VIEW_USER = "view_user"
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
@@ -54,8 +55,8 @@ async def get_trades(
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_TRADES,
verify_signed_request_any(
actions=[ACTION_VIEW_TRADES, ACTION_VIEW_USER],
wallet=wallet,
timestamp_ms=ts,
signature=sig,
+4 -3
View File
@@ -231,8 +231,8 @@ async def set_user_settings(
raise HTTPException(422, "min_confidence must be 0100")
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
raise HTTPException(422, "sys2_leverage must be 110")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive"):
raise HTTPException(422, "sys2_mode must be 'standard' or 'aggressive'")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive", ""):
raise HTTPException(422, "sys2_mode must be 'standard', 'aggressive', or '' (reset)")
if s.daily_budget_usd is not None and not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd must be >0 and ≤100,000 if provided")
@@ -280,7 +280,8 @@ async def set_user_settings(
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
# Empty string = reset to default; otherwise store the chosen mode.
sub.sys2_mode = "standard" if s.sys2_mode == "" else s.sys2_mode
sub.active_from = af
sub.active_until = au
if s.trump_enabled is not None: