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