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:
+155
-6
@@ -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 0–100 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
|
||||
|
||||
Reference in New Issue
Block a user