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,