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