KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)

Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
k
2026-06-09 22:55:16 +08:00
parent 213bb911e3
commit 54884f3e24
38 changed files with 2340 additions and 322 deletions
+19 -5
View File
@@ -2,12 +2,14 @@
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
POST /api/signal/toggle — flip the on/off switch (requires X-Ingest-Key)
GET /api/signal/history — last N signals (fired regardless of enabled state)
"""
from fastapi import APIRouter
from fastapi import APIRouter, Header, HTTPException
from typing import Optional
from app.config import settings
from app.services.funding_signal import (
set_enabled,
is_enabled,
@@ -18,17 +20,29 @@ from app.services.funding_signal import (
router = APIRouter(prefix="/signal", tags=["signal"])
def _require_ingest_key(x_ingest_key: Optional[str]) -> None:
"""Fail-closed operator-only guard (same pattern as signals.py)."""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "toggle 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.get("/status")
async def status():
return get_status()
@router.post("/toggle")
async def toggle(enabled: bool):
async def toggle(enabled: bool, x_ingest_key: Optional[str] = Header(default=None)):
"""
Body: ?enabled=true or ?enabled=false
Example: POST /api/signal/toggle?enabled=true
Operator-only toggle — requires X-Ingest-Key header (same secret as signal ingest).
Example: POST /api/signal/toggle?enabled=true -H 'X-Ingest-Key: …'
"""
_require_ingest_key(x_ingest_key)
set_enabled(enabled)
return {"enabled": is_enabled()}
+52 -10
View File
@@ -13,7 +13,7 @@ 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -59,6 +59,11 @@ def _summary_dto(post: KolPost) -> dict:
"tickers": _parse_tickers(post.tickers_json),
"analyzed_at": iso_utc(post.analyzed_at),
"analysis_model": post.analysis_model,
# Extended x_analysis fields (migration 027)
"tier": post.tier,
"post_type": post.post_type,
"talks_vs_trades_flag": post.talks_vs_trades_flag or False,
"sentiment": post.sentiment,
}
@@ -71,19 +76,47 @@ def _detail_dto(post: KolPost) -> dict:
@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"),
source: Optional[str] = Query(default=None, description="substack | blog | podcast"),
signals_only: bool = Query(default=False,
description="exclude noise posts (tier='noise')"),
ticker: Optional[str] = Query(default=None, description="filter by ticker symbol e.g. BTC"),
days: Optional[int] = Query(default=None, ge=1, le=365, description="restrict to last N days"),
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)
base = select(KolPost)
if handle:
stmt = stmt.where(KolPost.kol_handle == handle)
base = base.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}
base = base.where(KolPost.source == source)
if signals_only:
base = base.where(
(KolPost.tier.is_(None)) | (KolPost.tier != "noise")
)
if ticker:
# tickers_json stores [{"ticker":"BTC",...}] — match the key-value pair
base = base.where(KolPost.tickers_json.like(f'%"ticker": "{ticker.upper()}"%'))
if days:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
base = base.where(KolPost.published_at >= cutoff)
total: int = (await db.execute(
select(func.count()).select_from(base.subquery())
)).scalar_one()
rows = (await db.execute(
base.order_by(KolPost.published_at.desc())
.offset((page - 1) * limit)
.limit(limit)
)).scalars().all()
return {
"items": [_summary_dto(p) for p in rows],
"page": page,
"limit": limit,
"total": total,
}
@router.get("/kol/posts/{post_id}")
@@ -365,14 +398,23 @@ async def list_divergence(
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)
# Use post_at (when the KOL post was published) rather than created_at
# (when the divergence row was written to DB). created_at can lag by
# days or weeks when bulk scans are run, causing old event-pairs to
# appear as "recent" divergences long after they occurred.
stmt = select(KolDivergence).where(KolDivergence.post_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)
# Order by post_at (when the KOL actually published), consistent with the
# post_at window filter above. Ordering by created_at (DB write time) put
# a backfilled older post (post_at=05-18, created_at=05-28) ahead of a
# newer one (post_at=05-23), so the "latest divergence" read wrong.
# Tie-break on created_at so same-day posts have a stable order.
stmt = stmt.order_by(KolDivergence.post_at.desc(), KolDivergence.created_at.desc()).limit(200)
rows = (await db.execute(stmt)).scalars().all()
return {
"window_days": days,
+20 -5
View File
@@ -76,14 +76,29 @@ async def get_performance(
max_drawdown_pct=0.0,
)
winning = sum(1 for t in trades if (t.pnl_usd or 0) > 0)
win_rate = winning / total_trades
# Only include trades with a known PnL in financial statistics.
# Trades with pnl_usd=NULL were externally closed or unsettled — treating
# them as 0 silently inflates trade count and distorts win rate / net PnL.
settled = [t for t in trades if t.pnl_usd is not None]
if not settled:
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=total_trades,
win_rate=0.0,
net_pnl_usd=0.0,
avg_hold_seconds=0.0,
max_drawdown_pct=0.0,
)
pnl_values = [(t.pnl_usd or 0.0) for t in trades]
winning = sum(1 for t in settled if t.pnl_usd > 0) # type: ignore[operator]
win_rate = winning / len(settled)
pnl_values = [t.pnl_usd for t in settled] # type: ignore[misc]
net_pnl = sum(pnl_values)
hold_values = [(t.hold_seconds or 0) for t in trades]
avg_hold = sum(hold_values) / len(hold_values)
# For hold time use all trades (we always have opened_at + closed_at when closed)
hold_values = [t.hold_seconds for t in trades if t.hold_seconds is not None]
avg_hold = sum(hold_values) / len(hold_values) if hold_values else 0.0
# Max drawdown: running peak → trough of cumulative PnL
cumulative = 0.0
+41 -17
View File
@@ -199,11 +199,15 @@ async def get_today_stats(
hour=0, minute=0, second=0, microsecond=0, tzinfo=None
)
# Exclude released trades — these were released back to user control and
# closed by the user on HL directly, not by the bot. Including them would
# inflate the bot's "today" P&L with trades the bot didn't manage.
closed_rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at >= midnight,
BotTrade.pnl_usd.is_not(None),
BotTrade.released_at.is_(None),
)
)
closed = closed_rows.scalars().all()
@@ -280,6 +284,15 @@ async def manual_close(
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
raise HTTPException(422, "wallet, timestamp, signature required")
# Verify signature first — prevents trade_id enumeration via 403/409 before auth.
verify_signed_request(
action=ACTION_CLOSE_TRADE,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"trade_id": trade_id},
)
# 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:
@@ -289,14 +302,6 @@ async def manual_close(
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)
@@ -330,7 +335,26 @@ async def manual_close(
force=True,
)
closed = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one()
# B45: close_and_finalize uses its own AsyncSessionLocal session, so the
# route's `db` session has a stale identity-map cache for this trade row.
# `populate_existing=True` forces SQLAlchemy to overwrite the cached
# instance with the freshly-committed values (exit_price, pnl_usd, etc.)
# rather than returning the pre-close snapshot from the identity map.
closed = (await db.execute(
select(BotTrade).where(BotTrade.id == trade_id).execution_options(populate_existing=True)
)).scalar_one()
# B46: close_and_finalize returns silently on certain failures (no price
# for paper close, HL returns no fill, etc.) without raising an exception.
# Detect the failure by checking whether closed_at was actually written.
if closed.closed_at is None:
raise HTTPException(
500,
"Close command issued but the position could not be closed "
"(no price feed, HL fill failure, or another caller closed it first). "
"Refresh open positions — if it still shows, retry or close manually on HL."
)
return CloseTradeResponse(
status="ok",
trade_id=trade_id,
@@ -373,14 +397,6 @@ async def set_trade_grow(
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,
@@ -389,6 +405,14 @@ async def set_trade_grow(
body={"trade_id": trade_id, "enabled": enabled},
)
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")
trade.grow_mode = enabled
await db.commit()
+167 -4
View File
@@ -5,16 +5,29 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from app.ratelimit import limiter
from sqlalchemy import select
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Post, iso_utc
from app.schemas import PriceImpact, TrumpPost
from app.schemas import PostFilterCounts, PostListResponse, PriceImpact, SourceCount, TrumpPost
router = APIRouter()
logger = logging.getLogger(__name__)
_ARCHIVE_EXCLUDED_SOURCES = (
"truth",
"btc_bottom_reversal",
"funding_reversal",
"kol_divergence",
)
_AI_SCORED_EXPR = (
(func.coalesce(Post.ai_confidence, 0) > 0) |
Post.ai_reasoning.is_not(None)
)
def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[bool]:
if pct is None or signal is None:
@@ -98,11 +111,161 @@ async def get_posts(
return [_post_to_schema(p) for p in posts]
@router.get("/posts-paged", response_model=PostListResponse)
@limiter.limit("60/minute")
async def get_posts_page(
request: Request,
limit: int = Query(default=20, ge=1, le=500),
page: int = Query(default=1, ge=1),
source: Optional[str] = Query(
default=None,
description="Filter to a single source (e.g. 'truth', 'btc_bottom_reversal').",
),
source_in: Optional[str] = Query(
default=None,
description="Comma-separated allowlist of sources.",
),
source_not_in: Optional[str] = Query(
default=None,
description="Comma-separated denylist of sources.",
),
archive_only: bool = Query(
default=False,
description="When true, return only archived/retired sources (exclude live modules).",
),
sentiment: Optional[str] = Query(
default=None,
pattern="^(bullish|bearish|neutral)$",
description="Optional sentiment filter.",
),
signal: Optional[str] = Query(
default=None,
pattern="^(buy|short|actionable)$",
description="Optional signal filter. 'actionable' = buy or short.",
),
ai_scored_only: bool = Query(
default=False,
description="When true, exclude off-topic rows that were skipped before AI scoring.",
),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
offset = (page - 1) * limit
stmt = select(Post)
count_stmt = select(func.count()).select_from(Post)
counts_stmt = select(
func.count().label("all_count"),
func.sum(case((Post.signal.in_(("buy", "short")), 1), else_=0)).label("actionable_count"),
func.sum(case((Post.signal == "buy", 1), else_=0)).label("buy_count"),
func.sum(case((Post.signal == "short", 1), else_=0)).label("short_count"),
func.sum(case((_AI_SCORED_EXPR, 0), else_=1)).label("off_topic_count"),
).select_from(Post)
source_counts_stmt = select(
Post.source.label("source"),
func.count(Post.id).label("count"),
func.max(Post.published_at).label("latest"),
).select_from(Post)
included_sources = [s.strip() for s in (source_in or "").split(",") if s.strip()]
excluded_sources = [s.strip() for s in (source_not_in or "").split(",") if s.strip()]
if archive_only:
excluded_sources = list(dict.fromkeys([*excluded_sources, *_ARCHIVE_EXCLUDED_SOURCES]))
if source:
stmt = stmt.where(Post.source == source)
count_stmt = count_stmt.where(Post.source == source)
counts_stmt = counts_stmt.where(Post.source == source)
source_counts_stmt = source_counts_stmt.where(Post.source == source)
elif included_sources:
stmt = stmt.where(Post.source.in_(included_sources))
count_stmt = count_stmt.where(Post.source.in_(included_sources))
counts_stmt = counts_stmt.where(Post.source.in_(included_sources))
# NOTE: source_counts is the chip/source breakdown the UI renders the
# filter bar from. It must reflect every source available in the
# current view scope — NOT just the one the user has selected. So
# `source_in` (the chip-selection narrowing) is deliberately NOT
# applied here; only the exclusion filters (archive_only /
# source_not_in) below scope it. Applying it would collapse the chip
# bar to the single selected source with no way back to "all".
if excluded_sources:
stmt = stmt.where(~Post.source.in_(excluded_sources))
count_stmt = count_stmt.where(~Post.source.in_(excluded_sources))
counts_stmt = counts_stmt.where(~Post.source.in_(excluded_sources))
source_counts_stmt = source_counts_stmt.where(~Post.source.in_(excluded_sources))
if sentiment:
stmt = stmt.where(Post.sentiment == sentiment)
count_stmt = count_stmt.where(Post.sentiment == sentiment)
counts_stmt = counts_stmt.where(Post.sentiment == sentiment)
source_counts_stmt = source_counts_stmt.where(Post.sentiment == sentiment)
if ai_scored_only:
stmt = stmt.where(_AI_SCORED_EXPR)
count_stmt = count_stmt.where(_AI_SCORED_EXPR)
source_counts_stmt = source_counts_stmt.where(_AI_SCORED_EXPR)
if signal == "actionable":
stmt = stmt.where(Post.signal.in_(("buy", "short")))
count_stmt = count_stmt.where(Post.signal.in_(("buy", "short")))
elif signal:
stmt = stmt.where(Post.signal == signal)
count_stmt = count_stmt.where(Post.signal == signal)
stmt = stmt.order_by(Post.published_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
total_result = await db.execute(count_stmt)
counts_result = await db.execute(counts_stmt)
source_counts_result = await db.execute(
source_counts_stmt.group_by(Post.source).order_by(func.count(Post.id).desc(), Post.source.asc())
)
posts = result.scalars().all()
total = int(total_result.scalar_one() or 0)
counts_row = counts_result.one()
off_topic = int(counts_row.off_topic_count or 0)
all_count = int(counts_row.all_count or 0)
if response is not None:
response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60"
return PostListResponse(
items=[_post_to_schema(p) for p in posts],
total=total,
page=page,
limit=limit,
counts=PostFilterCounts(
all=max(0, all_count - off_topic) if ai_scored_only else all_count,
actionable=int(counts_row.actionable_count or 0),
buy=int(counts_row.buy_count or 0),
short=int(counts_row.short_count or 0),
off_topic=off_topic,
),
source_counts=[
SourceCount(
source=row.source,
count=int(row.count or 0),
latest=iso_utc(row.latest),
)
for row in source_counts_result.all()
],
)
@router.get("/signals/accuracy")
async def signal_accuracy(db: AsyncSession = Depends(get_db)):
"""Aggregate accuracy of directional signals (buy/sell/short) against realised price moves."""
"""Aggregate accuracy of directional signals against realised price moves.
Scoped to the CURRENT signal taxonomy the live bot actually trades:
* only buy/short (the retired "sell" vocabulary is excluded — the bot
emits buy/short now, and 42 legacy truth/sell rows would otherwise
pollute the public scoreboard),
* only production sources (SUPPORTED_TRADING_SOURCES) — retired/test
ingest sources like rsi_reversal, sma_reclaim, breakout, phase1 and
`test` must not appear in the public accuracy stats.
"""
from app.services.signal_categories import SUPPORTED_TRADING_SOURCES
result = await db.execute(
select(Post).where(Post.signal.in_(["buy", "sell", "short"]))
select(Post).where(
Post.signal.in_(["buy", "short"]),
func.lower(Post.source).in_(SUPPORTED_TRADING_SOURCES),
)
)
posts = result.scalars().all()
+17 -2
View File
@@ -31,6 +31,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -174,8 +175,22 @@ async def ingest_signal(
prefilter_reason="external_signal", # bypasses entry-filter audit
)
db.add(post)
await db.commit()
await db.refresh(post)
try:
await db.commit()
await db.refresh(post)
except IntegrityError:
# Concurrent request beat us to the INSERT — fetch the existing row.
await db.rollback()
existing2 = await db.execute(select(Post).where(Post.external_id == ext_id_hashed))
prior2 = existing2.scalar_one_or_none()
if prior2:
return SignalIngestResponse(
status="duplicate",
post_id=prior2.id,
dedup_against=prior2.id,
note=f"concurrent ingest: external_id {body.external_id!r} already exists",
)
raise # unexpected — re-raise if we still can't find the row
logger.info(
"Ingested signal: source=%s id=%s → post_id=%d, %s/%s conf=%d category=%s",
+68 -7
View File
@@ -87,14 +87,21 @@ class InitResponse(BaseModel):
expires_in_seconds: int
# ── Endpoints ────────────────────────────────────────────────────────────
# ── Helpers ──────────────────────────────────────────────────────────────
@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()
async def _build_status(
wallet: str,
db: AsyncSession,
authenticated: bool = False,
) -> StatusResponse:
"""Shared status-building logic used by both the GET endpoint and
update_preferences so both always return a consistent StatusResponse.
B37: previously update_preferences called `await status(wallet, db)`,
which passed db as the `timestamp` positional arg and left the DI-managed
`db` param as a raw Depends() wrapper — crashing on `.execute()`.
"""
configured = bool(settings.telegram_bot_token and settings.telegram_bot_username)
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
@@ -104,6 +111,14 @@ async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusRespo
return StatusResponse(configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=False)
if not authenticated:
return StatusResponse(
configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=True,
)
return StatusResponse(
configured=configured,
bot_username=settings.telegram_bot_username or None,
@@ -123,6 +138,49 @@ async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusRespo
)
# ── Endpoints ────────────────────────────────────────────────────────────
@router.get("/{wallet}/status", response_model=StatusResponse)
async def status(
wallet: str,
timestamp: Optional[int] = None,
signature: Optional[str] = None,
db: AsyncSession = Depends(get_db),
) -> StatusResponse:
"""Wallet Telegram status.
Unauthenticated: returns configured + bound (boolean only).
Authenticated (timestamp + signature): returns full binding details.
This prevents third parties from de-anonymising wallets via tg_username/chat_id.
"""
wallet = wallet.lower().strip()
# Verify ownership if credentials provided (best-effort — never block on failure).
# Accept either "view_telegram_status" (dedicated action) or "view_user"
# (broad read action that the frontend already caches for other endpoints).
# Accepting view_user avoids requiring a separate wallet signature just to
# see Telegram status — the frontend reuses its cached view_user envelope.
authenticated = False
if timestamp is not None and signature:
for _action in ("view_telegram_status", "view_user"):
try:
verify_signed_request(
action=_action,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body=None,
allow_replay=True,
)
authenticated = True
break
except Exception:
pass # Try next action; fall through to redacted response if all fail
return await _build_status(wallet, db, authenticated=authenticated)
@router.post("/{wallet}/init", response_model=InitResponse)
async def init_binding(
wallet: str, body: SignedEnvelope,
@@ -192,7 +250,10 @@ async def update_preferences(
await db.commit()
await db.refresh(b)
return await status(wallet, db)
# Return full authenticated status — the caller already proved ownership
# via the signed request verified above (B37: was `await status(wallet, db)`
# which passed db as the timestamp arg, crashing inside status()).
return await _build_status(wallet, db, authenticated=True)
@router.post("/{wallet}/unbind")
+20 -13
View File
@@ -19,27 +19,34 @@ ACTION_VIEW_USER = "view_user"
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
trigger_post_text = 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.
trigger_post_text = trade.trigger_post.text
elif trade.hl_order_id and str(trade.hl_order_id).startswith("adopted:"):
# Adopted (sys2 manage-only) trades have trigger_post_id=NULL by
# construction, so trigger_source would otherwise fall through to
# "Unknown" in the UI. The hl_order_id prefix is the reliable marker
# (see adoption.py / CLAUDE.md) — surface it as a proper attribution.
trigger_source = "adopted"
is_paper = (trade.hl_order_id == "paper")
# Preserve None for nullable fields — do NOT coerce to 0 / "".
# The frontend uses null to distinguish "unknown/not yet settled" from
# a genuine zero (e.g. a break-even trade, a 0-second hold).
# `or 0` was silently masking externally-closed trades and unsettled PnL.
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
exit_price=trade.exit_price or 0.0,
pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0,
exit_price=trade.exit_price, # None = position still open or extern-closed
pnl_usd=trade.pnl_usd, # None = unsettled / extern-closed
hold_seconds=trade.hold_seconds, # None = not yet computed
trigger_post_id=trade.trigger_post_id, # None = adopted/manual, no trigger post
opened_at=iso_utc(trade.opened_at) or "",
closed_at=iso_utc(trade.closed_at) or "",
closed_at=iso_utc(trade.closed_at), # None = still open (shouldn't happen here)
trigger_post_text=trigger_post_text,
trigger_source=trigger_source,
is_paper=is_paper,
)
@@ -50,7 +57,7 @@ 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),
limit: int = Query(default=20, ge=1, le=500), # raised from 100: Analytics needs 500 for full history
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
):
@@ -71,7 +78,7 @@ async def get_trades(
.options(joinedload(BotTrade.trigger_post))
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None))
.order_by(BotTrade.opened_at.desc())
.order_by(BotTrade.closed_at.desc()) # closed_at = settlement time; opened_at would bury recently-closed old trades
.offset(offset)
.limit(limit)
)
+14 -6
View File
@@ -42,17 +42,18 @@ async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> Non
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
# Preserve None — do NOT coerce to 0 / "". See trades.py comment.
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
exit_price=trade.exit_price or 0.0,
pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0,
exit_price=trade.exit_price,
pnl_usd=trade.pnl_usd,
hold_seconds=trade.hold_seconds,
trigger_post_id=trade.trigger_post_id,
opened_at=iso_utc(trade.opened_at) or "",
closed_at=iso_utc(trade.closed_at) or "",
closed_at=iso_utc(trade.closed_at),
)
@@ -107,7 +108,7 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
"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,
"auto_trade": False, "trump_enabled": False, "macro_enabled": False,
}
return {
"wallet_address": wallet,
@@ -119,6 +120,12 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
"circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at),
"circuit_breaker_reason": sub.circuit_breaker_reason,
"auto_trade": bool(sub.auto_trade),
# Per-system enable flags. bot_engine gates System-1 on trump_enabled
# and System-2 on macro_enabled (see _execute_for_subscriber). Without
# these, the UI claims "next Trump signal auto-opens" even when
# trump_enabled=0, where the backend actually skips the trade.
"trump_enabled": bool(sub.trump_enabled),
"macro_enabled": bool(sub.macro_enabled),
}
@@ -179,6 +186,7 @@ async def get_user(
hl_api_key_set=hl_api_key_set,
hl_api_key_masked=masked,
paper_mode=bool(sub.paper_mode),
auto_trade=bool(sub.auto_trade),
trades=[_trade_to_schema(t) for t in trades],
settings=UserSettings(
leverage=sub.leverage,
+9
View File
@@ -90,6 +90,15 @@ class Settings(BaseSettings):
# Public site URL appended to the follow-up tweet. Falls back to frontend_url.
x_link_url: str = ""
# ── X (Twitter) READ — KOL tweet ingestion (separate from the poster above) ──
# twitterapi.io managed-scraper key, sent as the `x-api-key` header. Powers
# kol_x.py: polls each tracked KOL's recent tweets → x_analysis.analyze_x_post
# → KolPost(source="twitter") → kol_divergence. This is READ-ONLY third-party
# data and is UNRELATED to the OAuth 1.0a poster creds above (those write our
# own tweets; this reads other people's). Empty = X ingestion disabled (no-op).
# Get a pay-as-you-go key at twitterapi.io (~$0.15 / 1k tweets).
twitterapi_io_key: str = ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
+14
View File
@@ -226,6 +226,20 @@ async def lifespan(app: FastAPI):
)
logger.info("KOL Substack poller scheduled daily at 01:15 UTC.")
# ── KOL X (Twitter) tweet ingestion (daily) ───────────────────────────
# Polls X-native KOLs (andrewkang/@Rewkang, murad — no Substack feed) plus
# Hayes's real-time position statements. Writes KolPost(source="twitter")
# that the divergence scan (02:15) joins against on-chain data — this is the
# post-side feed that lights up the andrewkang/murad wallets. No-op if
# twitterapi_io_key is unset. Runs 01:30 — between substack (01:15) and
# on-chain (02:00) so fresh posts are present for the 02:15 divergence scan.
from app.services.kol_x import run_x_poll
_scheduler.add_job(
run_x_poll, "cron", hour=1, minute=30,
id="kol_x_poll", max_instances=1, coalesce=True,
)
logger.info("KOL X (Twitter) poller scheduled daily at 01:30 UTC.")
# ── KOL A-tier: on-chain holdings snapshot (daily) ────────────────────
# Polls HL public API (free) for perp positions; Arkham (key optional)
# for full portfolio. Diffs against yesterday's snapshot → writes
+11 -5
View File
@@ -276,11 +276,17 @@ class KolPost(Base):
raw_text: Mapped[str] = mapped_column(Text, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tickers_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
analyzed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
analysis_model: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
analysis_version: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tickers_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
analyzed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
analysis_model: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
analysis_version: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
# x_analysis extended output (migration 027)
tier: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
post_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
talks_vs_trades_flag: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True, default=False)
sentiment: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+42 -5
View File
@@ -42,6 +42,36 @@ class TrumpPost(BaseModel):
model_config = {"from_attributes": True}
class PostFilterCounts(BaseModel):
# Count of posts matching the current non-signal filters. When the caller
# sets ai_scored_only=true this already excludes off-topic/noise rows.
all: int
actionable: int
buy: int
short: int
# Off-topic rows hidden by the "Signals only" toggle. Computed from the
# same source/sentiment scope but ignoring ai_scored_only so the toggle
# can stay visible while active.
off_topic: int
class SourceCount(BaseModel):
source: str
count: int
latest: Optional[str] = None
class PostListResponse(BaseModel):
items: list[TrumpPost]
total: int
page: int
limit: int
counts: PostFilterCounts
source_counts: list[SourceCount] = []
class Candle(BaseModel):
time: int
open: float
@@ -56,12 +86,18 @@ class BotTrade(BaseModel):
asset: str
side: str
entry_price: float
exit_price: float
pnl_usd: float
hold_seconds: int
trigger_post_id: int
# Nullable fields: None = "not yet known / externally closed / not applicable".
# Do NOT coerce to 0 — the frontend uses null to distinguish genuine zeros
# (break-even trade) from "we don't have this data yet".
exit_price: Optional[float] = None # None while still open / extern-closed
pnl_usd: Optional[float] = None # None = unsettled
hold_seconds: Optional[int] = None # None = not yet computed
trigger_post_id: Optional[int] = None # None = adopted/manual (no trigger post)
opened_at: str
closed_at: str
closed_at: Optional[str] = None # None for still-open positions returned from /user
# Optional short trigger snippet for table/list UIs. Comes from the joined
# trigger Post row when present; omitted for adopted / deleted-post trades.
trigger_post_text: Optional[str] = None
# Source tag of the originating signal (e.g. 'truth', 'breakout', 'my_strategy').
# Joined from posts.source on read; not stored on BotTrade itself.
# 'unknown' when the trigger post has been deleted or trigger_post_id is null.
@@ -152,6 +188,7 @@ class UserResponse(BaseModel):
hl_api_key_set: bool
hl_api_key_masked: Optional[str] = None
paper_mode: bool = False
auto_trade: bool = False # B50: was silently dropped, causing Settings to show ON as OFF
trades: list[BotTrade]
settings: UserSettings
# Convex-strategy: ISO-UTC timestamp until which the bot is manually armed.
+1 -1
View File
@@ -48,7 +48,7 @@ from app.services.signal_categories import (
get_exit_profile, get_stop_ladder,
sys2_normalize_mode, sys2_protective_stop_pct,
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
SYS2_MAX_CONCURRENT, SYS2_MODES,
SYS2_MAX_CONCURRENT, SYS2_MODES, SYS2_MAX_LEVERAGE,
)
logger = logging.getLogger(__name__)
+33 -7
View File
@@ -31,14 +31,40 @@ last_tick_at: Optional[datetime] = None
# on Binance; those require a separate HL price feed (see BUG-08 note in CLAUDE.md).
# Until that feed is added, HYPE trades fall back to max-hold only.
ASSET_MAP: dict[str, str] = {
"btcusdt": "BTC",
"ethusdt": "ETH",
"solusdt": "SOL",
# Original 8
"btcusdt": "BTC",
"ethusdt": "ETH",
"solusdt": "SOL",
"trumpusdt": "TRUMP",
"bnbusdt": "BNB",
"dogeusdt": "DOGE",
"linkusdt": "LINK",
"aaveusdt": "AAVE",
"bnbusdt": "BNB",
"dogeusdt": "DOGE",
"linkusdt": "LINK",
"aaveusdt": "AAVE",
# Extended: all mainstream HL_PERPS that have Binance spot/perp pairs.
# Without these, any bot trade on these assets loses TP/SL/trailing
# protection — only max_hold remains as an exit.
"avaxusdt": "AVAX",
"arbusdt": "ARB",
"opusdt": "OP",
"suiusdt": "SUI",
"aptusdt": "APT",
"injusdt": "INJ",
"atomusdt": "ATOM",
"xrpusdt": "XRP",
"ltcusdt": "LTC",
"adausdt": "ADA",
"maticusdt": "MATIC",
"shibusdt": "SHIB",
"pepeusdt": "PEPE",
"wifusdt": "WIF",
"bonkusdt": "BONK",
"taousdt": "TAO",
"jupusdt": "JUP",
"renderusdt": "RENDER",
"fetusdt": "FET",
"tiausdt": "TIA",
"seiusdt": "SEI",
"pendleusdt": "PENDLE",
}
# Build the combined-stream WS URL from ASSET_MAP so the two are always in sync.
+155 -6
View File
@@ -21,6 +21,16 @@ from app.services.price_store import price_store # noqa: F401 (used elsewhere)
logger = logging.getLogger(__name__)
async def _broadcast_trade_alert(wallet: str, event: str, **kwargs) -> None:
"""Broadcast a trade lifecycle event over WebSocket. Fire-and-forget — never raises."""
try:
from app.ws.manager import manager
await manager.broadcast({"type": "trade_alert", "wallet": wallet, "event": event, **kwargs})
except Exception as exc:
logger.warning("trade_alert broadcast failed: %s", exc)
# Platform-wide thresholds (per-user values in Subscription override where applicable)
# No global confidence floor — users pick their own 0100 threshold via settings.
# Per-user max hold lives on Subscription.max_hold_hours (default 168h = 7 days
@@ -230,6 +240,13 @@ async def process_post(post: Post, db: AsyncSession) -> None:
sys2_leverage=s.sys2_leverage,
sys2_mode=s.sys2_mode,
auto_trade=bool(s.auto_trade),
# B38: module toggles — must be in snapshot so the execution gate
# can read them. trump_enabled gates System-1 (Trump); macro_enabled
# gates System-2 (Macro Vibes / bottom reversal). Both default to
# False (new subscribers start with bot idle) so a NULL in old rows
# continues the old conservative behaviour.
trump_enabled=bool(s.trump_enabled),
macro_enabled=bool(s.macro_enabled),
)
for s in subscribers
]
@@ -298,6 +315,63 @@ async def process_post(post: Post, db: AsyncSession) -> None:
await asyncio.gather(*tasks, return_exceptions=True)
def _is_in_active_window(sub: dict) -> bool:
"""Return True if the current UTC time falls inside the subscriber's
active window (schedule or manual override).
Priority (highest wins):
1. manual_window_until — operator-armed override; if set and in the
future, the bot is ALWAYS armed regardless of the schedule.
2. active_from / active_until — user-configured recurring schedule.
Both must be set; if only one is set we treat the schedule as
unconfigured and return True (don't gate).
3. If neither is set, always active.
B30: was defined but never called — entire feature was dead.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
# 1. Manual override window (e.g. "arm for the next 4 hours")
mwu = sub.get("manual_window_until")
if mwu is not None:
mwu_dt = mwu if isinstance(mwu, datetime) else None
if mwu_dt is None:
try:
from datetime import datetime as _dt
mwu_dt = _dt.fromisoformat(str(mwu).replace("Z", ""))
except Exception:
mwu_dt = None
if mwu_dt is not None and mwu_dt > now:
return True # manual override wins
# 2. Recurring schedule
af = sub.get("active_from")
au = sub.get("active_until")
if af is None or au is None:
return True # no schedule configured → always active
# Convert to time-of-day for comparison (schedule repeats daily)
def _to_time(val):
if isinstance(val, datetime):
return val.time()
try:
from datetime import datetime as _dt
return _dt.fromisoformat(str(val).replace("Z", "")).time()
except Exception:
return None
af_t = _to_time(af)
au_t = _to_time(au)
if af_t is None or au_t is None:
return True # can't parse → don't block
now_t = now.time()
if af_t <= au_t:
return af_t <= now_t <= au_t # same-day window
else:
return now_t >= af_t or now_t <= au_t # crosses midnight
async def _execute_for_subscriber(
sub: dict,
post_id: int,
@@ -306,9 +380,27 @@ async def _execute_for_subscriber(
side: str,
) -> None:
wallet = sub["wallet"]
if not sub["hl_api_key"]:
# B38: module on/off switches gate BEFORE any expensive work.
# trump_enabled → System-1 (source="truth"); macro_enabled → System-2.
# Defaults: False. A NULL column (pre-migration row) is treated as False,
# preserving the existing conservative behaviour.
is_sys2 = bool(sub.get("_is_system_2"))
if is_sys2:
if not sub.get("macro_enabled"):
logger.info("Sub %s: macro_enabled OFF — post %d not traded", wallet, post_id)
return
else:
if not sub.get("trump_enabled"):
logger.info("Sub %s: trump_enabled OFF — post %d not traded", wallet, post_id)
return
# B29: paper users have no HL API key by design — skip the key check for
# them. The paper-mode branch later uses price_store instead of HL.
if not sub["hl_api_key"] and not sub.get("paper_mode"):
logger.warning("Subscriber %s has no HL API key, skipping", wallet)
return
# Required-setup guard. System 1 (Trump) needs take-profit and stop-loss.
# System 2 (reversal) supplies its own stop + trailing from the category
# profile, so only the two Trump exit fields are user-required.
@@ -339,6 +431,15 @@ async def _execute_for_subscriber(
wallet, post_id)
return
# ── Schedule / manual-window gate (B30) ───────────────────────────────
# active_from/active_until define a recurring daily window (e.g. 09:00-17:00 UTC).
# manual_window_until is an operator override that arms the bot for a
# fixed duration regardless of the schedule (e.g. "trade for the next 4h").
# System-2 (manage-only / adopt model) is exempt — it never auto-opens.
if _should_apply_schedule(sub) and not _is_in_active_window(sub):
logger.info("Sub %s: outside active window — post %d not traded", wallet, post_id)
return
# ── Circuit breaker gate (P1.1) ────────────────────────────────────────
# Checked BEFORE key decryption (cheap fast-fail). If tripped, the wallet
# has lost too much today or hit a losing streak — block until manual reset.
@@ -410,9 +511,19 @@ async def _execute_for_subscriber(
)
)
# Only count spend from THIS system against THIS system's slice.
# M1 fix: adopted trades have trigger_post_id=NULL so the
# outerjoin yields src=NULL → (src or "").lower()="" → not in
# SYSTEM_2_SOURCES → t_is_s2=False. That miscounts every
# adopted sys2 position against the sys1 budget, inflating
# "spent" and prematurely blocking new Trump scalp opens.
# Adopted trades are always sys2 — their hl_order_id starts
# with "adopted:" by construction (see adoption.py).
spent = 0.0
for t, src in spent_result.all():
t_is_s2 = (src or "").lower() in SYSTEM_2_SOURCES
if t.hl_order_id and str(t.hl_order_id).startswith("adopted:"):
t_is_s2 = True
else:
t_is_s2 = (src or "").lower() in SYSTEM_2_SOURCES
if t_is_s2 != is_s2:
continue
spent += (t.size_usd if t.size_usd is not None else sub["position_size_usd"])
@@ -420,6 +531,11 @@ async def _execute_for_subscriber(
logger.info("Sub %s [%s] daily budget reached: spent=%.2f + new=%.2f > cap=%.2f (%.0f%% of %.2f)",
wallet, _system, spent, sized_position_usd, daily_cap,
(sys2_pct if is_s2 else 100.0), total_cap)
asyncio.create_task(_broadcast_trade_alert(
wallet, "budget_reached",
asset=asset, size_usd=sized_position_usd,
spent_usd=round(spent, 2), cap_usd=round(daily_cap, 2),
))
return
# ── System-2 correlation / concentration cap ───────────────────────
@@ -505,6 +621,24 @@ async def _execute_for_subscriber(
logger.info("Subscriber %s already has open %s position, skipping", wallet, asset)
return
balance = await trader.get_balance()
# HL isolated-margin requires notional / leverage as collateral,
# not the full notional value. Add a 10% buffer for fees + slippage.
leverage = sub.get("leverage") or 1
required_margin = round((sized_position_usd / max(leverage, 1)) * 1.1, 2)
if balance < required_margin:
logger.warning(
"Sub %s: insufficient balance %.2f < required margin %.2f "
"(notional=%.2f lev=%dx) for %s — skipping",
wallet, balance, required_margin, sized_position_usd, leverage, asset,
)
asyncio.create_task(_broadcast_trade_alert(
wallet, "insufficient_balance",
asset=asset, balance_usd=round(balance, 2),
required_usd=required_margin,
))
return
result = await trader.open_position(asset, side, sized_position_usd)
entry_price = result.get('fill_price', 0.0)
@@ -529,7 +663,7 @@ async def _execute_for_subscriber(
# for its stop math, but we still record the true value so
# BotTrade.leverage reflects what HL actually applied.
sub["leverage"] = effective_leverage
if sys2:
if sub.get("_is_system_2"):
from app.services.signal_categories import (
sys2_protective_stop_pct as _sps,
)
@@ -562,7 +696,7 @@ async def _execute_for_subscriber(
# constructor before the later assignment used to throw
# UnboundLocalError on every sys2 fire, leaving the HL
# position open with NO DB record and NO watchdog. Critical.
_sys2_mode = sub.get("_sys2_mode", "standard") if sys2 else None
_sys2_mode = sub.get("_sys2_mode", "standard") if sub.get("_is_system_2") else None
trade = BotTrade(
asset=asset,
@@ -601,7 +735,8 @@ async def _execute_for_subscriber(
_derisk = None
_addon = None
_peak_trail = None
if sys2:
_is_sys2 = bool(sub.get("_is_system_2"))
if _is_sys2:
_mode_for_ladders = _sys2_mode or "standard"
from app.services.signal_categories import (
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
@@ -624,7 +759,7 @@ async def _execute_for_subscriber(
invalidation=eff["invalidation"],
invalidation_price=eff.get("invalidation_price"),
min_hold_until_ts=eff["min_hold_until_ts"],
stop_ladder=get_stop_ladder(post.category) if sys2 else None,
stop_ladder=get_stop_ladder(sub.get("_category", "")) if _is_sys2 else None,
derisk_ladder=_derisk,
derisk_done=0,
addon_ladder=_addon,
@@ -656,6 +791,10 @@ async def _execute_for_subscriber(
except Exception as e:
logger.error("Trade execution failed for %s: %s", wallet, e)
asyncio.create_task(_broadcast_trade_alert(
wallet, "execution_failed",
asset=asset, reason=str(e)[:200],
))
async def partial_derisk(
@@ -885,6 +1024,16 @@ async def pyramid_add(
fill = r.get("fill_price")
filled_coins = float(r.get("size_coins") or 0.0)
if not fill or filled_coins <= 0:
# Revert the pre-claim — HL didn't fill so the rung must
# remain retryable. Without this, addon_steps_done stays
# incremented and the rung is permanently burned (B48).
await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.addon_steps_done == step_idx + 1)
.values(addon_steps_done=step_idx)
)
await db.commit()
return (False, None, None)
# Use the ACTUAL filled notional, not the intended amount —
# an IOC add can under-fill on thin/volatile books; assuming
+1 -1
View File
@@ -129,7 +129,7 @@ async def check_and_trip(
today_trades = await _trades_for_system(db, wallet, start_of_day, system)
today_pnl = sum(t.pnl_usd or 0 for t in today_trades)
dd_limit = -CB_DAILY_DD_USD_PER_BASE * sub.position_size_usd * 10
dd_limit = -CB_DAILY_DD_USD_PER_BASE * sub.position_size_usd
if today_pnl < dd_limit:
reason = "daily_dd"
logger.warning("CB[%s] trip [daily_dd] %s: pnl=%.2f < %.2f",
+39
View File
@@ -11,7 +11,9 @@ Broadcasts alert via WebSocket. Gated by user on/off toggle.
"""
import collections
import json
import logging
import os
from datetime import datetime, timezone
from typing import Deque, Optional
@@ -40,12 +42,49 @@ _enabled: bool = False
_recent_signals: Deque[dict] = collections.deque(maxlen=50)
_last_fired: dict[str, Optional[datetime]] = {s: None for s in WATCH_SYMBOLS}
# B52: persist _enabled across process restarts without a DB migration.
# The file survives `systemctl restart` but is cleared by OS reboots (which is
# acceptable — an operator who reboots the server is expected to re-arm the
# monitor). Path is configurable via env so staging vs prod can differ.
_STATE_FILE = os.environ.get(
"BREAKOUT_MONITOR_STATE_FILE",
"/tmp/trumpsignal-breakout-state.json",
)
def _load_persisted_state() -> None:
"""Read _enabled from disk on startup. Called once at module import time."""
global _enabled
try:
with open(_STATE_FILE) as f:
data = json.load(f)
_enabled = bool(data.get("enabled", False))
logger.info("Breakout monitor: loaded persisted state enabled=%s", _enabled)
except FileNotFoundError:
pass # first run — start disabled
except Exception as exc:
logger.warning("Breakout monitor: failed to load state file: %s", exc)
def _persist_state() -> None:
"""Write _enabled to disk so it survives process restarts."""
try:
with open(_STATE_FILE, "w") as f:
json.dump({"enabled": _enabled, "updated_at": datetime.now(timezone.utc).isoformat()}, f)
except Exception as exc:
logger.warning("Breakout monitor: failed to persist state: %s", exc)
# Load persisted state at import time (called when main.py registers the scheduler).
_load_persisted_state()
# ── Public API ────────────────────────────────────────────────────────────────
def set_enabled(value: bool) -> None:
global _enabled
_enabled = value
_persist_state() # B52: survive restarts
logger.info("Funding signal monitor: %s", "ENABLED" if value else "DISABLED")
+6 -1
View File
@@ -185,7 +185,12 @@ class HyperliquidTrader:
)
logger.info("Set leverage %dx for %s (isolated)", effective, coin)
except Exception as exc:
logger.warning("set_leverage error (non-fatal): %s", exc)
# DO NOT swallow — if leverage isn't set, the position would open
# at whatever HL had previously. Downstream stop math
# (sys2_protective_stop_pct) uses the return value; a wrong
# value puts the stop outside the real liquidation line.
logger.error("set_leverage FAILED for %s %dx: %s — aborting open", coin, effective, exc)
raise RuntimeError(f"set_leverage failed for {coin} at {effective}×: {exc}") from exc
return effective
async def open_position(
+75 -42
View File
@@ -43,6 +43,16 @@ logger = logging.getLogger(__name__)
# of body per post or it just hallucinates a topic line. (Headlines-only
# feeds like Vitalik's blog need a follow-up HTML fetch, deferred.)
# 3. Add with a sensible handle + display_name.
# 4. ⚠️ The KOL feed COUNT is hardcoded in the FRONTEND for SEO/marketing
# (it can't read this list cross-repo). If len(KOL_FEEDS) changes, update
# every "N KOL feeds" mention in the frontend repo:
# app/layout.tsx (JSON-LD ×2), app/page.tsx (×3 incl. metric),
# app/[locale]/kol/page.tsx (×4), app/[locale]/kol/KolPageClient.tsx
# (subtitle "and N more" = count-3), app/[locale]/glossary/page.tsx,
# public/llms.txt + llms-full.txt, app/opengraph-image.tsx.
# Currently len(KOL_FEEDS) == 25. (X-only KOLs live in kol_x.X_KOLS.)
# (2026-06-09: dropped from 29 → 25; removed placeholder, dragonfly,
# niccarter, eugene as dead feeds. Frontend count mentions need updating.)
KOL_FEEDS: list[dict] = [
# ── Substack essayists (long-form thesis pieces) ─────────────────────
{
@@ -50,38 +60,40 @@ KOL_FEEDS: list[dict] = [
"display_name": "Arthur Hayes",
"feed_url": "https://cryptohayes.substack.com/feed",
},
# Placeholder VC (Joel Monegro / Chris Burniske). Token-focused VC, posts
# long-form thesis pieces every 1-3 months that map directly to their
# portfolio bets (Solana staking, L1 monetary premium, etc.).
# Raoul Pal — Real Vision / Global Macro Investor founder. "Short Excerpts
# Raoul Pal — his public Substack (raoulpal.substack.com/feed) went STALE
# (last post 2024-05). Replaced 2026-06-09 with the Real Vision official
# podcast feed (feeds.megaphone.fm/realvision) — daily, free, 2000+ eps,
# full episode descriptions with macro/crypto thesis. It's the whole Real
# Vision channel (not Raoul-only), but high signal + fresh. Same canonical
# handle as his X (@RaoulGMI in kol_x) so long-form + real-time aggregate.
{
"handle": "placeholder",
"display_name": "Placeholder VC",
"feed_url": "https://www.placeholder.vc/blog?format=rss",
"handle": "raoulpal",
"display_name": "Raoul Pal (Real Vision)",
"feed_url": "https://feeds.megaphone.fm/realvision",
"source": "podcast",
},
# Dragonfly Capital research blog on Medium — free, active (10+ posts).
# dragonfly.xyz/blog/rss.xml returns 0 (paywall). medium.com/dragonfly-research
# is the team's public research arm: airdrops, DeFi, protocol deep-dives.
{
"handle": "dragonfly",
"display_name": "Dragonfly Capital",
"feed_url": "https://medium.com/feed/dragonfly-research",
},
# Andy Constan's Substack is paywalled (RSS returns 0). Keeping for any
# occasional public teaser. Forward Guidance podcast (Blockworks) features
# him weekly but is macro/equities-focused — not crypto-coin-specific enough
# to extract ticker signals from episode descriptions.
# REMOVED 2026-06-09 — dead feeds, no active replacement exists:
# • placeholder (Placeholder VC) — last post 2025-09, blog is their only
# public source, no newsletter. ~266d stale.
# • dragonfly (Dragonfly Research, Medium) — last post 2025-03 (~454d).
# dragonfly.xyz has no working RSS (JS-rendered, all endpoints empty);
# Haseeb's medium/@hosseeb is even older (2024).
# If either resumes regular publishing with a real RSS, re-add here.
# Andy Constan — his old Substack (dampedspring.substack.com) returned 0
# entries (paywalled). Replaced 2026-06-09 with his FREE long-form Substack
# "Damped Spring 101" (dampedspring101.substack.com), which he's publicly
# committed to keeping free — active, real macro essays (liquidity, rates,
# positioning). His deepest paid research stays gated, but this carries
# genuine thesis content suitable for ticker/direction extraction.
{
"handle": "dampedspring",
"display_name": "Damped Spring / Andy Constan",
"feed_url": "https://dampedspring.substack.com/feed",
},
# Nic Carter's Substack is paywalled (RSS returns 0). His Medium feed is
# FREE and active — different URL, same author, real content.
{
"handle": "niccarter",
"display_name": "Nic Carter (Castle Island)",
"feed_url": "https://medium.com/feed/@nic__carter",
"feed_url": "https://dampedspring101.substack.com/feed",
},
# REMOVED 2026-06-09 — niccarter (Nic Carter / Castle Island). Substack
# paywalled (0 entries); Medium feed went stale (last 2025-07, ~316d).
# No active free replacement. Re-add if he resumes a public RSS.
# Delphi Digital podcast (Buzzsprout) — 478 episodes, active May 2025.
# Public, free. Episode descriptions name specific protocols / tokens with
# thesis framing — good extraction signal. delphidigital.io/feed returns 0.
@@ -104,12 +116,8 @@ KOL_FEEDS: list[dict] = [
"display_name": "The DeFi Edge",
"feed_url": "https://thedefiedge.com/feed/",
},
# Eugene Ng Ah Sio — trader/analyst, sporadic but specific.
{
"handle": "eugene",
"display_name": "Eugene Ng Ah Sio",
"feed_url": "https://eugene.substack.com/feed",
},
# REMOVED 2026-06-09 — eugene (Eugene Ng Ah Sio). Substack effectively
# dormant: only 5 entries, last 2025-05 (~399d). No alternative source.
# ── DeFi journalism (Substack-style RSS) ─────────────────────────────
# The Defiant — Camila Russo's team. DeFi-focused news with frequent
# protocol + token mentions. Free RSS, ~100 entries.
@@ -149,12 +157,15 @@ KOL_FEEDS: list[dict] = [
"feed_url": "https://feeds.megaphone.fm/lightspeed",
"source": "podcast",
},
# Unchained — Laura Shin. Long interview format with founders and
# traders. Show notes are 6K+ chars (near-transcript).
# Unchained — Laura Shin. Long interview format with founders and traders.
# The website RSS (unchainedcrypto.com/feed) is Cloudflare-blocked (403,
# even HTTP/2 + browser UA). Switched 2026-06-09 to the canonical Megaphone
# podcast feed (resolved via iTunes lookup id=1123922160) — 1100+ episodes,
# active daily, 1.5-2K char show notes naming protocols/tokens.
{
"handle": "unchained",
"display_name": "Unchained (Laura Shin)",
"feed_url": "https://www.unchainedcrypto.com/feed/",
"feed_url": "https://feeds.megaphone.fm/LSHML4761942757",
"source": "podcast",
},
# Bankless podcast — Ryan Sean Adams + David Hoffman. ETH-focused but
@@ -240,8 +251,12 @@ KOL_FEEDS: list[dict] = [
# but extremely high quality. Mostly BTC-only signals.
{
"handle": "lynalden",
# lynalden.com/feed is Cloudflare-blocked (202, no body). Switched
# 2026-06-09 to her FeedBurner mirror, which serves fine. Note: bodies
# are article EXCERPTS (~400 chars) not full text, but enough for the
# AI to extract the BTC/macro thesis + direction.
"display_name": "Lyn Alden",
"feed_url": "https://www.lynalden.com/feed/",
"feed_url": "https://feeds.feedburner.com/lynalden",
"source": "blog",
},
# Bitcoin Magazine — high-frequency news + institutional adoption analysis.
@@ -336,8 +351,19 @@ async def _fetch_feed(feed_url: str) -> list:
"""feedparser is sync; do the HTTP fetch through httpx for timeout
control + uniformity with the rest of the codebase, then hand bytes
to feedparser."""
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
r = await client.get(feed_url, headers={"User-Agent": "TrumpSignal/1.0 KOL-tracker"})
# http2=True matters: some CDN-fronted feeds (e.g. Glassnode) return 403
# to HTTP/1.1 requests but serve fine over HTTP/2 (curl defaults to h2,
# which is why they worked manually but not here). Requires the `h2` pkg.
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True, http2=True) as client:
# Use a real browser UA. Several feeds (Glassnode, others behind a CDN)
# return 403/202 to non-browser agents. A plain bot UA was silently
# losing those feeds. This recovers them without per-feed special-casing.
r = await client.get(feed_url, headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36",
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
})
r.raise_for_status()
parsed = feedparser.parse(r.content)
return list(parsed.entries or [])
@@ -439,6 +465,9 @@ async def _ingest_kol(
row.analyzed_at = utcnow()
row.analysis_model = result.get("model")
row.analysis_version = result.get("version")
# Extended analysis fields (migration 027)
row.post_type = result.get("post_type")
row.talks_vs_trades_flag = bool(result.get("talks_vs_trades_flag", False))
stats["analyzed"] += 1
except Exception as e:
logger.warning("[kol_substack] analysis failed for %s post %s: %s",
@@ -452,11 +481,15 @@ async def _ingest_kol(
async def run_substack_poll(*, analyze: bool = True) -> list[dict]:
"""Poll every configured KOL feed once. Despite the legacy name this now
covers Substack essays, Medium blogs, and major crypto podcasts via RSS.
Returns per-KOL stats."""
Returns per-KOL stats.
Each KOL gets its own session so a commit failure for one does not leave
a dirty session that breaks subsequent KOLs in the same run.
"""
results = []
async with AsyncSessionLocal() as session:
for kol in KOL_FEEDS:
for kol in KOL_FEEDS:
async with AsyncSessionLocal() as session:
stats = await _ingest_kol(session, kol, analyze=analyze)
results.append(stats)
results.append(stats)
logger.info("[kol_substack] poll done: %s", results)
return results
+284
View File
@@ -0,0 +1,284 @@
"""KOL X (Twitter) ingester.
Polls each tracked KOL's recent tweets via twitterapi.io, dedupes by tweet id,
runs `x_analysis.analyze_x_post`, and writes a `KolPost(source="twitter")`.
The `tickers_json` it stores uses the exact `{ticker, action, conviction}` shape
that `kol_divergence` already consumes (it reads `t["ticker"]` / `t["action"]` /
`t["conviction"]`) — so no mapping layer is needed. x_analysis was designed
against that contract; this module just connects the pipe.
WHY THIS EXISTS
`andrewkang` (@Rewkang) and `murad` publish ONLY on X — no Substack feed.
Their on-chain wallets are already seeded (seed_kol_wallets.py), but with no
post-side data the divergence scanner detects nothing for them — the wallets
sit dark. This ingester is the missing post-side feed that lights them up.
DESIGN (mirrors kol_substack.py)
- Disabled (full no-op) when `settings.twitterapi_io_key` is empty.
- `KolPost.kol_handle` is the CANONICAL handle (e.g. "cryptohayes"), NOT the
X screen name — it must match `KolWallet.handle` so divergence can join
post-side ↔ on-chain for the same person.
- Dedup by (source="twitter", external_id=<tweet id>).
- Bare retweets ("RT @…") are skipped before the AI call to save spend.
- Only the first page (~20 newest tweets) is fetched per run. Daily volume
for these KOLs is < 20/day, and dedup makes re-runs cheap. Bump
`max_pages` if you add a very high-frequency account.
COST
~$0.15 / 1k tweets (twitterapi.io). 4 KOLs × ~20 tweets daily ≈ $0.015/mo.
CADENCE
Daily 01:30 UTC — after substack (01:15), before on-chain (02:00) and
divergence (02:15), so fresh X posts are present when divergence runs.
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import AsyncGenerator, Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import KolPost, utcnow
from app.services import x_analysis
logger = logging.getLogger(__name__)
X_API = "https://api.twitterapi.io/twitter/user/last_tweets"
# Tracked X-native KOLs. `handle` MUST match the canonical handle used in
# KolWallet / KOL_FEEDS so divergence can join against on-chain data.
# `x_username` is the actual X screen name (no @).
#
# cryptohayes is also on Substack (long-form) — X adds his real-time position
# statements ("just dumped my $HYPE"), which Substack essays don't carry.
# andrewkang + murad are X-ONLY and have seeded wallets waiting for this feed.
X_KOLS: list[dict] = [
{"handle": "cryptohayes", "x_username": "CryptoHayes", "display_name": "Arthur Hayes"},
{"handle": "andrewkang", "x_username": "Rewkang", "display_name": "Andrew Kang"},
{"handle": "murad", "x_username": "MustStopMurad", "display_name": "Murad Mahmudov"},
# Raoul Pal — Real Vision / GMI founder. Macro/liquidity/cycle thinker
# (mostly DIRECTIONAL, rarely position TRADE_SIGNAL). Same canonical handle
# as his Substack feed below so his X takes + long-form theses aggregate.
{"handle": "raoulpal", "x_username": "RaoulGMI", "display_name": "Raoul Pal"},
]
def _parse_created_at(raw: Optional[str]) -> datetime:
"""X `createdAt` looks like 'Thu Jun 04 12:29:13 +0000 2026'. Normalise to
naive UTC (same convention as kol_substack._parse_pub — divergence window
matching and digest 'since' filters all assume naive UTC)."""
if not raw:
return utcnow()
try:
dt = parsedate_to_datetime(raw)
if dt.tzinfo:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except Exception:
return utcnow()
async def _fetch_tweet_page(
client: httpx.AsyncClient,
x_username: str,
cursor: Optional[str],
key: str,
) -> tuple[list[dict], Optional[str]]:
"""Fetch one page of tweets. Returns (tweets, next_cursor).
next_cursor is None when there are no more pages or on any error."""
params: dict = {"userName": x_username, "includeReplies": "false"}
if cursor:
params["cursor"] = cursor
try:
r = await client.get(X_API, params=params, headers={"x-api-key": key})
if r.status_code != 200:
logger.warning("[kol_x] fetch %s HTTP %d: %s",
x_username, r.status_code, r.text[:200])
return [], None
data = r.json()
# Shape: {status, data: {tweets: [...], next_cursor?: str}, ...}
if data.get("status") != "success":
logger.warning("[kol_x] fetch %s non-success: %s",
x_username, str(data.get("msg") or data)[:200])
return [], None
inner = data.get("data") or {}
tweets = inner.get("tweets") or []
next_cursor = inner.get("next_cursor") or None
return tweets, next_cursor
except Exception as e:
logger.warning("[kol_x] fetch %s exception: %s", x_username, e)
return [], None
async def _iter_tweet_pages(
x_username: str,
max_pages: int = 3,
) -> AsyncGenerator[list[dict], None]:
"""Async generator: yields one page of tweets at a time.
Callers can break out of the loop early (page-level early-stop) without
fetching unnecessary pages. max_pages=3 is the gap-fill ceiling — normal
daily runs stop after page 1 because _ingest_kol_x breaks on all-dedup.
Never raises — a network error yields nothing and ends the iteration.
"""
key = settings.twitterapi_io_key
if not key:
return
cursor: Optional[str] = None
async with httpx.AsyncClient(timeout=20.0) as client:
for _ in range(max_pages):
tweets, cursor = await _fetch_tweet_page(client, x_username, cursor, key)
if tweets:
yield tweets
if not cursor or not tweets:
break
async def _ingest_kol_x(
session: AsyncSession,
kol: dict,
*,
analyze: bool = True,
max_new: int = 20,
max_pages: int = 3,
) -> dict:
"""Ingest one KOL's recent tweets. Mirrors kol_substack._ingest_kol.
Pages are fetched one at a time. If an entire page consists only of
already-stored tweets (dedup hits), fetching stops — subsequent pages
would be even older and equally known, so the API call is skipped.
This keeps the normal daily run to a single page fetch.
"""
handle = kol["handle"]
x_username = kol["x_username"]
stats = {"handle": handle, "x_username": x_username,
"new": 0, "skipped": 0, "analyzed": 0, "errors": 0}
async for page_tweets in _iter_tweet_pages(x_username, max_pages=max_pages):
if stats["new"] >= max_new:
break
page_new = 0 # new tweets stored on this page
page_deduped = 0 # already-stored tweets hit on this page (dedup)
for tw in page_tweets:
if stats["new"] >= max_new:
break
tweet_id = str(tw.get("id") or "").strip()
text = (tw.get("text") or "").strip()
if not tweet_id or not text:
continue
# Bare retweet → noise. Skip before the AI call to save spend.
if text.startswith("RT @"):
stats["skipped"] += 1
continue
# Dedup by (source, external_id).
existing = await session.execute(
select(KolPost).where(
KolPost.source == "twitter",
KolPost.external_id == tweet_id,
)
)
if existing.scalar_one_or_none() is not None:
stats["skipped"] += 1
page_deduped += 1
continue
author = tw.get("author") or {}
follower_count = author.get("followers")
url = tw.get("url") or f"https://x.com/{x_username}/status/{tweet_id}"
pub = _parse_created_at(tw.get("createdAt"))
body_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
row = KolPost(
kol_handle=handle,
source="twitter",
external_id=tweet_id,
url=url,
title=None,
published_at=pub,
raw_text=text,
content_hash=body_hash,
)
session.add(row)
await session.flush()
stats["new"] += 1
page_new += 1
logger.info("[kol_x] new tweet %s id=%s tweet_id=%s", handle, row.id, tweet_id)
if analyze:
try:
result = await x_analysis.analyze_x_post(
handle=handle,
text=text,
posted_at=pub.isoformat(),
follower_count=follower_count,
)
if result.get("error"):
stats["errors"] += 1
else:
row.summary = result.get("summary")
row.tickers_json = json.dumps(result.get("tickers") or [],
ensure_ascii=False)
row.analyzed_at = utcnow()
row.analysis_model = result.get("model")
row.analysis_version = result.get("version")
# Extended x_analysis fields (migration 027)
row.tier = result.get("tier")
row.post_type = result.get("post_type")
row.talks_vs_trades_flag = bool(result.get("talks_vs_trades_flag", False))
row.sentiment = result.get("sentiment")
stats["analyzed"] += 1
except Exception as e:
logger.warning("[kol_x] analysis failed %s tweet %s: %s",
handle, row.id, e)
stats["errors"] += 1
# Page-level early stop: this page yielded NO new tweets AND hit at
# least one dedup → we've caught up to already-stored data, so older
# pages are known too. A page that is ALL bare-RTs (page_deduped == 0)
# must NOT early-stop — the next page may still hold original posts.
if page_new == 0 and page_deduped > 0:
logger.debug("[kol_x] %s page fully deduped — stopping early", handle)
break
await session.commit()
return stats
async def run_x_poll(*, analyze: bool = True) -> list[dict]:
"""Poll every tracked X KOL once. Full no-op (returns []) when the
twitterapi.io key is unset. Returns per-KOL stats.
Each KOL gets its own session so a commit failure for one does not leave
a dirty session that breaks subsequent KOLs in the same run.
"""
if not settings.twitterapi_io_key:
logger.info("[kol_x] twitterapi_io_key not set — X ingestion disabled.")
return []
results = []
for kol in X_KOLS:
try:
async with AsyncSessionLocal() as session:
stats = await _ingest_kol_x(session, kol, analyze=analyze)
results.append(stats)
except Exception as e:
# One KOL's DB/commit failure must not abort the rest of the run.
logger.error("[kol_x] ingest failed for %s: %s", kol.get("handle"), e)
results.append({"handle": kol.get("handle"), "error": str(e)})
logger.info("[kol_x] poll done: %s", results)
return results
+36 -45
View File
@@ -161,63 +161,54 @@ async def fetch_ahr999() -> dict:
}
# ── 2. Altcoin Season Index (blockchaincenter.net formula) ───────────────────
# Take top-50 coins by market cap (excluding stablecoins + wrapped). Count how
# many beat BTC's 90-day return. Result is the count, projected to 0-100.
# 75+ = altseason, <25 = bitcoin season, middle = neutral.
# ── 2. Altcoin Season Index (blockchaincenter.net — official source) ─────────
# Scrape the value directly from blockchaincenter.net, which is the canonical
# publisher of this index (90-day window: how many of the top 50 alts beat BTC
# over 90 days). 75+ = altseason, <25 = bitcoin season.
#
# Previous implementation computed the index from CoinGecko /coins/markets
# using a 30d window (CoinGecko doesn't return 90d per-coin data on that
# endpoint). The 30d vs 90d discrepancy caused readings up to ~30 points
# higher than the official index during BTC-dominated markets. Scraping the
# actual page is more reliable than re-implementing the formula.
#
# Fallback: if the page scrape fails, return None (the @_none_on_fail
# decorator handles that gracefully).
_STABLE_OR_WRAPPED = {
"USDT", "USDC", "DAI", "BUSD", "TUSD", "USDD", "FDUSD", "PYUSD", "USDE",
"WBTC", "WETH", "STETH", "WSTETH", "WEETH", "RETH",
}
_BCC_URL = "https://www.blockchaincenter.net/altcoin-season-index/"
# Regex for the server-rendered value in the Next.js HTML:
# "Season<!-- -->41" or "Season (<!-- -->41" inside the page markup.
_BCC_RE = re.compile(r"Season[^<]{0,20}<!--\s*-->\s*(\d{1,3})")
@_none_on_fail("altcoin_season_index")
async def fetch_altcoin_season_index() -> dict:
"""Compute the Altcoin Season Index from CoinGecko /coins/markets.
"""Fetch the Altcoin Season Index from blockchaincenter.net.
Original blockchaincenter.net formula uses a 90-day window, but
CoinGecko's /coins/markets `price_change_percentage` parameter only
accepts 1h/24h/7d/14d/30d/200d/1y — 90d returns HTTP 400. We use 30d
as the closest practical proxy. Long-horizon altseason (which 90d
captures better) would need per-coin /market_chart calls — 50× the
API budget for a marginal definition improvement.
The site is Next.js SSR — the value is embedded in the initial HTML as a
server-rendered text node. We parse it with a tight regex and fall back to
None on any parse failure so the rest of the snapshot is unaffected.
"""
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA) as c:
r = await c.get(
"https://api.coingecko.com/api/v3/coins/markets",
params={"vs_currency": "usd", "order": "market_cap_desc",
"per_page": 60, "page": 1,
"price_change_percentage": "30d"},
)
async with httpx.AsyncClient(
timeout=DEFAULT_TIMEOUT,
headers={**UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
follow_redirects=True,
) as c:
r = await c.get(_BCC_URL)
r.raise_for_status()
rows = r.json()
html = r.text
# Drop stablecoins + wrapped, keep top 50 of the remainder.
eligible = [
row for row in rows
if (row.get("symbol") or "").upper() not in _STABLE_OR_WRAPPED
and row.get("price_change_percentage_30d_in_currency") is not None
][:50]
if len(eligible) < 30:
return {"value": None, "raw": {"error": "insufficient eligible coins",
"have": len(eligible)}}
m = _BCC_RE.search(html)
if not m:
return {"value": None, "raw": {"error": "regex did not match", "url": _BCC_URL}}
btc_row = next((x for x in rows if x.get("symbol", "").upper() == "BTC"), None)
btc_30d = btc_row.get("price_change_percentage_30d_in_currency") if btc_row else None
if btc_30d is None:
return {"value": None, "raw": {"error": "BTC 30d return missing"}}
value = int(m.group(1))
if not 0 <= value <= 100:
return {"value": None, "raw": {"error": f"parsed value out of range: {value}"}}
n_outperform = sum(
1 for row in eligible
if (row["price_change_percentage_30d_in_currency"] or -999) > btc_30d
)
# Project the count over `len(eligible)` to a 0100 scale.
index = (n_outperform / len(eligible)) * 100
return {
"value": round(index, 1),
"raw": {"n_outperform": n_outperform, "of": len(eligible),
"btc_30d_pct": round(btc_30d, 2), "window": "30d"},
"value": float(value),
"raw": {"source": "blockchaincenter.net", "window": "90d", "parsed": value},
}
+1 -1
View File
@@ -94,7 +94,7 @@ def verify_signed_request(
# 1. Freshness
now_ms = int(time.time() * 1000)
if abs(now_ms - timestamp_ms) > MAX_SKEW_SECONDS * 1000:
if timestamp_ms > now_ms + 30_000 or now_ms - timestamp_ms > MAX_SKEW_SECONDS * 1000: # allow 30s future drift for clock skew
raise HTTPException(401, "Signed request expired or clock skew too large")
# 2. Recover signer
+27 -4
View File
@@ -22,6 +22,7 @@ Source → user-toggle mapping:
from __future__ import annotations
import asyncio
import html
import logging
from datetime import datetime, timezone
from typing import Optional
@@ -100,13 +101,21 @@ def format_post(post: Post) -> str:
asset = post.target_asset or "?"
conf = post.ai_confidence or 0
# Heading: emoji + asset + direction + confidence
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf}</b>"
# Heading: emoji + asset + direction + confidence.
# asset comes from post.target_asset (scanner-written) — escape defensively;
# sig/conf/src are controlled enums/ints/labels.
head = f"{emoji} <b>{html.escape(asset)} · {sig}</b> · conf <b>{conf}</b>"
sub = f"<i>{src}</i>"
# Truncate the RAW text first, THEN escape — escaping first could let a
# 5-char entity (&amp;) get sliced mid-sequence by the length cap.
body = (post.text or "").strip()
if len(body) > 600:
body = body[:600].rstrip() + ""
# parse_mode=HTML: Trump posts routinely contain & < > ("Law & Order").
# Without escaping, Telegram rejects the whole message (400 can't parse
# entities) and every subscriber silently misses the alert.
body = html.escape(body)
# Move size hint if present (BTC bottom & funding emit expected_move_pct)
extra = ""
@@ -157,6 +166,7 @@ def format_trump_mention(post: Post) -> str:
body = (post.text or "").strip()
if len(body) > 300:
body = body[:300].rstrip() + ""
body = html.escape(body) # see format_post — escape user text for HTML mode
fe = (settings.frontend_url or "").rstrip("/")
link = f'\n\n<a href="{fe}/en/trump">→ view on TrumpAlpha</a>' if fe else ""
@@ -197,7 +207,7 @@ def format_public_post(post: Post) -> str:
else:
conf_label = ""
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf_label}</b>"
head = f"{emoji} <b>{html.escape(asset)} · {sig}</b> · conf <b>{conf_label}</b>"
sub = f"<i>{src}</i>"
body = (post.text or "").strip()
@@ -212,6 +222,10 @@ def format_public_post(post: Post) -> str:
reason = reason[:200].rstrip() + ""
body = reason
# Escape AFTER choosing text-vs-reason — both are user/AI content going
# into an HTML-parse-mode message. (See format_post.)
body = html.escape(body)
fe = (settings.frontend_url or "").rstrip("/")
link = ""
if fe:
@@ -447,6 +461,13 @@ async def _dispatch(post_id: int) -> None:
post_id, channel_id)
# Strong references to in-flight dispatch tasks. asyncio.create_task() only
# keeps a WEAK reference, so a fire-and-forget task can be garbage-collected
# mid-await — "Task was destroyed but it is pending!" — silently dropping a
# subscriber fan-out. We hold the task until it completes, then auto-discard.
_dispatch_tasks: set[asyncio.Task] = set()
def notify_signal(post: Post) -> None:
"""Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop
and returns immediately. Safe to call from any async context — falls
@@ -459,7 +480,9 @@ def notify_signal(post: Post) -> None:
if not post or not post.id:
return
try:
asyncio.create_task(_dispatch(post.id))
task = asyncio.create_task(_dispatch(post.id))
_dispatch_tasks.add(task)
task.add_done_callback(_dispatch_tasks.discard)
except RuntimeError:
# No running loop — extremely unusual in our FastAPI context.
logger.warning("notify_signal: no running event loop, skipping post=%s", post.id)
+25
View File
@@ -998,6 +998,31 @@ async def run_bot_loop() -> None:
return
logger.info("Telegram bot loop starting (long-poll mode).")
# ── Startup drain ────────────────────────────────────────────────────
# On restart, Telegram may replay up to 24h of unacknowledged updates.
# The most dangerous replays are stale /adopt callback_query items that
# would re-fire an adoption the user already dealt with. Fix: pull all
# pending updates with timeout=0 (non-blocking), advance offset past
# them without processing, then start the real loop fresh.
try:
drain_url = TG_API.format(token=token, method="getUpdates")
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(drain_url, params={"timeout": 0, "limit": 100})
if r.status_code == 200:
pending = r.json().get("result", [])
if pending:
drain_offset = pending[-1]["update_id"] + 1
# ACK by sending offset back — Telegram won't re-deliver these.
async with httpx.AsyncClient(timeout=10) as client:
await client.get(drain_url, params={"timeout": 0, "offset": drain_offset})
logger.info(
"Startup drain: skipped %d stale update(s), offset now %d",
len(pending), drain_offset,
)
except Exception as exc:
logger.warning("Startup drain failed (non-fatal): %s", exc)
offset: Optional[int] = None
backoff = 1.0
+28 -8
View File
@@ -29,6 +29,7 @@ so a coalesced cron or worker restart can't double-send within a day.
from __future__ import annotations
import html
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -138,26 +139,43 @@ async def build_global_digest(now: Optional[datetime] = None) -> GlobalDigest:
)
# ── KOL: last 24h posts + divergences ────────────────────────────
# Exclude twitter noise (gm / RT / jokes) from the count. Substack
# posts have tier=NULL (kept); twitter signal posts have tier!='noise'
# (kept); twitter noise is dropped. Without this filter a KOL's gm/RT
# spam inflates the daily "N KOL posts" stat. Mirrors the /kol/posts
# signals_only filter and the frontend.
kol_posts = (await db.execute(
select(KolPost).where(KolPost.published_at >= cutoff_24h)
select(KolPost).where(
KolPost.published_at >= cutoff_24h,
(KolPost.tier.is_(None)) | (KolPost.tier != "noise"),
)
)).scalars().all()
# action lives inside tickers_json; easier: classify on summary text.
# The structured action is on KolDivergence rows; for the bare count
# we just split posts by whether divergence rows reference them.
# Count bullish/bearish/neutral at POST level, not divergence-pair level.
# A single post can match multiple on-chain events (one per ticker/wallet),
# producing several KolDivergence rows. Counting rows would inflate the
# numbers — a post mentioning 3 tickers was previously counted 3× (bug).
bullish = bearish = neutral = 0
divergence_rows = (await db.execute(
select(KolDivergence)
.where(KolDivergence.post_at >= cutoff_24h)
.order_by(KolDivergence.created_at.desc())
)).scalars().all()
# Deduplicate by post_id; for posts with mixed directions take "long"
# first (a bullish call that also hedges short is net bullish).
post_direction: dict[int, str] = {}
for d in divergence_rows:
if d.direction == "long":
if d.post_id not in post_direction:
post_direction[d.post_id] = d.direction
elif d.direction == "long":
post_direction[d.post_id] = "long" # long overrides neutral
for direction in post_direction.values():
if direction == "long":
bullish += 1
elif d.direction == "short":
elif direction == "short":
bearish += 1
else:
neutral += 1
# Posts not yet classified into divergences just count as neutral.
# Posts not yet cross-referenced with on-chain data count as neutral.
neutral += max(0, len(kol_posts) - (bullish + bearish + neutral))
divergences_24h = sum(
@@ -168,7 +186,9 @@ async def build_global_digest(now: Optional[datetime] = None) -> GlobalDigest:
if d.signal_type != "divergence":
continue
# "Hayes publicly bullish BTC, on-chain selling"
sample_divergence = (
# Escape — ticker is AI-extracted (kol_analysis) so not guaranteed
# HTML-safe; the digest is sent with parse_mode=HTML.
sample_divergence = html.escape(
f"{d.handle} publicly {d.post_action} {d.ticker}, "
f"on-chain {d.onchain_action}"
)
+13
View File
@@ -158,6 +158,19 @@ def register_trade(
and not derisk_ladder):
return
# Warn when the price feed doesn't cover this asset — TP/SL/trailing
# stops will NEVER fire; only max_hold will exit the position.
from app.services.binance import ASSET_MAP
from app.services.hl_price_feed import HL_PRICE_ASSETS
covered = set(ASSET_MAP.values()) | HL_PRICE_ASSETS
if asset not in covered:
logger.error(
"PRICE FEED GAP: trade %d asset=%s has no price coverage. "
"TP/SL/trailing-stop will NOT trigger — only max_hold protects "
"this position. Add %susdt to ASSET_MAP in binance.py.",
trade_id, asset, asset.lower(),
)
_watched[trade_id] = WatchedTrade(
trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage,
asset=asset, side=side, entry_price=entry_price,
+13 -1
View File
@@ -35,7 +35,7 @@ from app.config import settings
logger = logging.getLogger(__name__)
ANALYSIS_VERSION = "x-v1"
ANALYSIS_VERSION = "x-v2" # v2: tone-is-not-content rule + meme calibration example
ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
_anthropic_client = None
@@ -89,6 +89,13 @@ ALL of the above → tier: "noise", tickers: []
Only extract signals when the post contains CLEAR, EXPLICIT information
about what the KOL is doing or believes RIGHT NOW. Ambiguity → noise.
TONE IS NOT CONTENT. A joke, meme, celebratory, or emoji-spam tone
("Arise Chikun!", "Yachtzee", "Meow", "😘😘😘", "WAGMI lfg") does NOT make a
post noise when it still carries an EXPLICIT ticker + direction. Score the
claim, ignore the wrapper. "$WLD initiate bull market 😘😘😘" is a directional
bullish call on WLD — NOT noise. Only drop to noise when the ticker or the
direction is genuinely absent/vague, never merely because the wording is silly.
═══════════════════════════════════════════════════════════════════════
THREE SIGNAL TIERS
═══════════════════════════════════════════════════════════════════════
@@ -194,6 +201,11 @@ POST: "ETH dominance will crush alts this cycle. The flippening is real."
→ tier: "directional", action: "bullish", asset: ETH, conviction: 0.52,
timeframe: "months"
POST: "Arise Chikun! $WLD initiate bull market. Yachtzee 😘😘😘😘"
→ tier: "directional", action: "bullish", asset: WLD, conviction: 0.7,
timeframe: "immediate" (meme/celebration tone does NOT override the
explicit "$WLD initiate bull market" call — score the claim, not the vibe)
[NOISE — output noise, empty tickers]
POST: "gm everyone 🌅"