Pre-launch hardening: KOL module, Telegram, scanners, WS resilience

Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
+62 -12
View File
@@ -32,6 +32,7 @@ from app.config import settings
logger = logging.getLogger(__name__)
_client: Optional[AsyncOpenAI] = None
_anthropic_client = None
ANALYSIS_VERSION = "v5-extreme-alpha"
@@ -373,6 +374,19 @@ def _fallback(prefilter: Optional[str] = None, reasoning: str = "") -> dict:
return out
def _use_anthropic() -> bool:
"""True if a native Anthropic API key is configured (takes priority over proxy)."""
return bool(settings.anthropic_api_key)
def _get_anthropic_client():
global _anthropic_client
if _anthropic_client is None:
import anthropic as _anthropic
_anthropic_client = _anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _anthropic_client
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
@@ -393,13 +407,21 @@ def _liquidity_note(hour: int) -> str:
return "Off-peak hours, moderate liquidity"
async def analyze_post(text: str) -> dict:
async def analyze_post(text: str, model: Optional[str] = None) -> dict:
"""Score a Trump post and return signal + structured reasoning.
Args:
text: Raw post text (up to 2000 chars used).
model: Override the model to use. Defaults to settings.ai_live_model
for latency-sensitive live calls; pass settings.ai_model
explicitly for higher-quality batch reanalysis.
Returns the canonical dict shape expected by the rest of the codebase:
relevant, asset, sentiment, signal (buy/short/hold), confidence,
reasoning, prefilter_reason, analysis_version.
"""
if model is None:
model = settings.ai_live_model # fast flash for live posts
# ── Fast pre-filter (no AI call) ────────────────────────────────
stripped = text.strip()
if not stripped:
@@ -423,17 +445,45 @@ async def analyze_post(text: str) -> dict:
)
try:
client = _get_client()
response = await client.chat.completions.create(
model=settings.ai_model,
max_tokens=600,
temperature=0.1,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
raw = (response.choices[0].message.content or "").strip()
if _use_anthropic():
# ── Native Anthropic SDK path ────────────────────────────────
anthropic_client = _get_anthropic_client()
# Map OpenAI-style model name → Anthropic model name
model_name = model
if "haiku" in model_name.lower() and "claude-" not in model_name.lower():
model_name = "claude-haiku-4-5-20251001"
msg = await anthropic_client.messages.create(
model=model_name,
max_tokens=600,
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
# ── OpenAI-compatible proxy path (DeepSeek, gptsapi, etc.) ──
# Reasoning models (deepseek-v4-pro / R1) don't support
# temperature and need higher max_tokens for the thinking pass.
model_name = model
is_reasoning = any(x in model_name for x in ("pro", "reasoner", "r1", "think"))
kwargs: dict = {
"model": model_name,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}
if is_reasoning:
# Reasoning models: large token budget; omit temperature
kwargs["max_tokens"] = 4000
else:
kwargs["max_tokens"] = 1200
kwargs["temperature"] = 0.1
client = _get_client()
response = await client.chat.completions.create(**kwargs)
raw = (response.choices[0].message.content or "").strip()
# Strip ```json fences if the model adds them despite instructions
if raw.startswith("```"):
+376
View File
@@ -0,0 +1,376 @@
"""
Single-post backtest harness.
Given a Post with a directional signal (buy/short) and a target asset, fetch
the historical 1-minute candles for [published_at, published_at + max_hold_h]
from Binance and replay the new convex-strategy exit rules. Outputs what
WOULD have happened if we had been live at that moment with the current
trailing-stop / stop-loss / max-hold settings.
Why this exists:
The bot's m1h accuracy is 45.7%, but that was measured with a 1-hour
fixed-window snapshot — i.e. assuming we close at exactly the 1-hour mark.
The new exit logic (trailing stop, 7-day max hold) is FUNDAMENTALLY
different. Without backtesting we can't know whether changing exits also
changes the win rate / PnL profile. This harness lets us check.
Scope (deliberately narrow for the MVP):
- One post at a time. Batch runner sits on top.
- Uses 1m HIGH/LOW within each bar (worst-case path) — conservative.
- Ignores fees + slippage (caller is expected to subtract them).
- Assumes immediate fill at the candle's OPEN on entry.
- Does NOT re-evaluate regime gates (the caller decides whether to
include the post). This makes "what would have happened" cleaner.
The 1m granularity matters: with 5m or 1H bars you can't distinguish
"stop loss hit then bounced back" from "rallied straight up", and a
trailing-stop strategy lives or dies on that distinction.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
BINANCE_KLINES_URL = "https://api.binance.com/api/v3/klines"
@dataclass
class BacktestResult:
post_id: int
asset: str
side: str # "long" | "short"
entry_price: float
exit_price: float
exit_reason: str # "trailing_stop" | "stop_loss" | "take_profit" | "max_hold"
hold_minutes: int
pnl_pct: float # net of nothing — apply your own fee model
peak_pct: float # best unrealised gain reached
trough_pct: float # worst unrealised gain reached
bars_evaluated: int
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class BacktestParams:
"""Exit-rule snapshot used for the simulation."""
stop_loss_pct: float = 1.5
trailing_stop_pct: Optional[float] = 2.5
trailing_activate_at_pct: Optional[float] = 5.0
take_profit_pct: Optional[float] = None
max_hold_hours: int = 168
async def fetch_binance_1m(
symbol: str,
start: datetime,
end: datetime,
client: Optional[httpx.AsyncClient] = None,
) -> list[dict]:
"""Pull 1-minute candles from Binance spot for [start, end].
Binance caps a single call at 1000 candles → we page in chunks. Returns
candles in chronological order. Each candle: {time_ms, open, high, low, close}.
"""
own_client = client is None
if own_client:
client = httpx.AsyncClient(timeout=20)
out: list[dict] = []
cursor_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000)
end_ms = int(end.replace(tzinfo=timezone.utc).timestamp() * 1000)
try:
while cursor_ms < end_ms:
params = {
"symbol": symbol,
"interval": "1m",
"startTime": cursor_ms,
"endTime": end_ms,
"limit": 1000,
}
resp = await client.get(BINANCE_KLINES_URL, params=params)
resp.raise_for_status()
chunk = resp.json()
if not chunk:
break
for row in chunk:
out.append({
"time_ms": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
})
# Next page starts after the last candle returned.
last_open = chunk[-1][0]
if last_open <= cursor_ms:
break
cursor_ms = last_open + 60_000
if len(chunk) < 1000:
break # final partial page
finally:
if own_client:
await client.aclose()
return out
def _asset_to_symbol(asset: str) -> str:
"""Map our internal asset code to a Binance spot symbol.
Most assets are simply {ASSET}USDT. TRUMP/HYPE/etc. may not exist on
Binance — those will fail at fetch time and we'll return None upstream.
"""
return f"{asset.upper()}USDT"
def simulate_exit(
candles: list[dict],
side: str,
params: BacktestParams,
) -> dict:
"""Walk the candles 1m at a time, applying the exit ladder.
Priority within a bar (worst-case ordering):
1. Stop loss — assume hit via LOW (long) / HIGH (short)
2. Trailing — only if armed; assume hit via the same worst-case extreme
3. Take profit — fixed TP if set
A real bar can't tell us whether the high or low printed first, so we
take the pessimistic path: STOP LOSS BEFORE PROFIT if both could have
triggered. This makes the backtest conservative.
"""
if not candles:
return {"reason": "no_data", "exit_price": 0.0, "bars": 0,
"peak_pct": 0.0, "trough_pct": 0.0, "pnl_pct": 0.0}
entry = candles[0]["open"]
if entry == 0:
return {"reason": "bad_entry", "exit_price": 0.0, "bars": 0,
"peak_pct": 0.0, "trough_pct": 0.0, "pnl_pct": 0.0}
is_long = side == "long"
peak_pct = 0.0
trough_pct = 0.0
armed = False
def pct(price: float) -> float:
raw = (price - entry) / entry
return (raw if is_long else -raw) * 100
for i, bar in enumerate(candles):
# Within-bar extremes in position's favoured direction
good_extreme = bar["high"] if is_long else bar["low"]
bad_extreme = bar["low"] if is_long else bar["high"]
good_pct = pct(good_extreme)
bad_pct = pct(bad_extreme)
# Update running peak / trough using extremes
if good_pct > peak_pct: peak_pct = good_pct
if bad_pct < trough_pct: trough_pct = bad_pct
# 1. Stop loss — pessimistic check first
if bad_pct <= -params.stop_loss_pct:
# Approximate exit at the SL trigger price (not the bar extreme)
sl_price = entry * (1 - params.stop_loss_pct / 100) if is_long \
else entry * (1 + params.stop_loss_pct / 100)
return {
"reason": "stop_loss",
"exit_price": sl_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": -params.stop_loss_pct,
}
# 2. Trailing — arm if peak crossed activation
if (params.trailing_stop_pct is not None
and params.trailing_activate_at_pct is not None):
if not armed and peak_pct >= params.trailing_activate_at_pct:
armed = True
if armed:
# Drawdown from peak (using bad_extreme of this bar)
drawdown = peak_pct - bad_pct
if drawdown >= params.trailing_stop_pct:
# Exit at peak trailing_stop_pct (approx)
exit_pct = peak_pct - params.trailing_stop_pct
ts_price = entry * (1 + exit_pct / 100) if is_long \
else entry * (1 - exit_pct / 100)
return {
"reason": "trailing_stop",
"exit_price": ts_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": exit_pct,
}
# 3. Fixed TP
if params.take_profit_pct is not None and good_pct >= params.take_profit_pct:
tp_price = entry * (1 + params.take_profit_pct / 100) if is_long \
else entry * (1 - params.take_profit_pct / 100)
return {
"reason": "take_profit",
"exit_price": tp_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": params.take_profit_pct,
}
# Walked the whole window without hitting any rule → close at last bar
last_close = candles[-1]["close"]
return {
"reason": "max_hold",
"exit_price": last_close,
"bars": len(candles),
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": pct(last_close),
}
async def backtest_post(
post_id: int,
params: Optional[BacktestParams] = None,
) -> Optional[BacktestResult]:
"""Backtest a single Post by ID.
Returns None if:
- Post not found
- signal is not buy/short
- target_asset is unknown / not on Binance
- Binance has no data for the window
"""
from app.database import AsyncSessionLocal
from app.models import Post
from sqlalchemy import select
params = params or BacktestParams()
async with AsyncSessionLocal() as db:
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if post is None:
logger.warning("Backtest: post %d not found", post_id)
return None
if post.signal not in ("buy", "short"):
logger.warning("Backtest: post %d signal=%s not actionable", post_id, post.signal)
return None
asset = post.target_asset or post.price_impact_asset
if not asset:
logger.warning("Backtest: post %d has no asset", post_id)
return None
side = "long" if post.signal == "buy" else "short"
symbol = _asset_to_symbol(asset)
# Window: [published_at, published_at + max_hold_hours]
start = post.published_at
end = start + timedelta(hours=params.max_hold_hours)
# Don't backtest a window that hasn't fully elapsed yet
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if end > now_naive:
end = now_naive
candles = await fetch_binance_1m(symbol, start, end)
if not candles:
logger.warning("Backtest: no Binance data for %s in [%s, %s]", symbol, start, end)
return None
sim = simulate_exit(candles, side, params)
if sim["reason"] in ("no_data", "bad_entry"):
return None
return BacktestResult(
post_id=post_id,
asset=asset,
side=side,
entry_price=candles[0]["open"],
exit_price=sim["exit_price"],
exit_reason=sim["reason"],
hold_minutes=sim["bars"],
pnl_pct=round(sim["pnl_pct"], 3),
peak_pct=round(sim["peak_pct"], 3),
trough_pct=round(sim["trough_pct"], 3),
bars_evaluated=sim["bars"],
)
async def backtest_batch(
limit: int = 50,
min_confidence: int = 80,
params: Optional[BacktestParams] = None,
) -> dict:
"""Backtest every directional post with confidence ≥ min_confidence.
Returns aggregate stats + per-trade results. Posts whose Binance data
is missing (TRUMP, niche perps) are skipped silently.
"""
from app.database import AsyncSessionLocal
from app.models import Post
from sqlalchemy import select, and_, or_
params = params or BacktestParams()
async with AsyncSessionLocal() as db:
rows = await db.execute(
select(Post.id).where(
and_(
Post.signal.in_(["buy", "short"]),
Post.ai_confidence >= min_confidence,
)
).order_by(Post.published_at.desc()).limit(limit)
)
post_ids = [r[0] for r in rows.all()]
results: list[BacktestResult] = []
skipped: list[int] = []
for pid in post_ids:
try:
r = await backtest_post(pid, params)
if r is None:
skipped.append(pid)
else:
results.append(r)
except Exception as exc:
logger.error("Backtest post %d failed: %s", pid, exc)
skipped.append(pid)
if not results:
return {"params": asdict(params), "results": [], "skipped": skipped,
"summary": {"trades": 0}}
pnls = [r.pnl_pct for r in results]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
by_reason: dict[str, int] = {}
for r in results:
by_reason[r.exit_reason] = by_reason.get(r.exit_reason, 0) + 1
summary = {
"trades": len(results),
"skipped": len(skipped),
"win_rate_pct": round(len(wins) / len(results) * 100, 1),
"avg_pnl_pct": round(sum(pnls) / len(pnls), 3),
"total_pnl_pct": round(sum(pnls), 3),
"best_pct": round(max(pnls), 3),
"worst_pct": round(min(pnls), 3),
"avg_win_pct": round(sum(wins) / len(wins), 3) if wins else 0.0,
"avg_loss_pct": round(sum(losses) / len(losses), 3) if losses else 0.0,
"exit_reasons": by_reason,
}
return {
"params": asdict(params),
"summary": summary,
"skipped": skipped,
"results": [r.to_dict() for r in results],
}
+28 -2
View File
@@ -83,7 +83,21 @@ async def fetch_historical(asset: str, symbol: str, interval: str = "1m", limit:
async def run_binance_ws():
"""Connect to Binance kline stream with exponential back-off on disconnect."""
"""Connect to Binance kline stream with exponential back-off on disconnect.
Binance routinely cycles connections every ~24h, and intermediate proxies
can drop without sending a close frame — both surface here as exceptions.
The reconnect must be fast (sub-second) so price ticks aren't visibly
stuck on the dashboard during a hiccup.
Resilience defenses:
* ping_interval/ping_timeout — peer-of-record liveness check (websockets lib)
* close_timeout — bound how long we wait for a clean close handshake
* open_timeout — bound the initial connect handshake so a hung TCP doesn't
permanently park this task
* max_queue — drop frames if downstream can't keep up rather than back
the WS receive buffer up indefinitely
"""
# Pre-fill with historical data
await fetch_historical("BTC", "btcusdt", limit=500)
await fetch_historical("ETH", "ethusdt", limit=500)
@@ -96,15 +110,27 @@ async def run_binance_ws():
settings.binance_ws_url,
ping_interval=20,
ping_timeout=10,
close_timeout=5,
open_timeout=10,
max_queue=128,
) as ws:
backoff = 1 # reset on successful connection
logger.info("Binance WebSocket connected.")
async for message in ws:
await _process_message(message)
# If we exit the `async with` without an exception, the server
# closed normally — reconnect immediately rather than backing off.
logger.info("Binance WebSocket closed cleanly, reconnecting immediately.")
except asyncio.CancelledError:
logger.info("Binance WebSocket task cancelled.")
return
except Exception as exc:
logger.error("Binance WebSocket error: %s. Reconnecting in %ds.", exc, backoff)
# Use exception() once so a TRACE-back-with-context is logged the
# first time something interesting happens; subsequent same-error
# retries log compactly to avoid drowning the log.
logger.warning(
"Binance WebSocket error: %s (%s). Reconnecting in %ds.",
type(exc).__name__, exc, backoff,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
+707 -63
View File
@@ -6,7 +6,7 @@ Iterates all active subscribers and executes trades on their behalf.
import asyncio
import logging
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Dict
from sqlalchemy import select, update
@@ -23,7 +23,9 @@ logger = logging.getLogger(__name__)
# Platform-wide thresholds (per-user values in Subscription override where applicable)
# No global confidence floor — users pick their own 0100 threshold via settings.
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users)
# Per-user max hold lives on Subscription.max_hold_hours (default 168h = 7 days
# in the convex-strategy redesign). Old 1-hour cap killed every potential
# runner before it could compound, so it's been removed.
# Hyperliquid perp fees (mainnet, base tier).
# IOC orders always cross the book → taker fee both on open and close.
@@ -63,6 +65,17 @@ def _lock_for(trade_id: int) -> asyncio.Lock:
return lock
def _confidence_floor_for(sub: dict) -> int:
if sub.get("_is_system_2"):
from app.services.signal_categories import system2_min_confidence
return system2_min_confidence()
return sub["min_confidence"]
def _should_apply_schedule(sub: dict) -> bool:
return not bool(sub.get("_is_system_2"))
async def process_post(post: Post, db: AsyncSession) -> None:
"""
Entry point called by the scraper after a new post is saved.
@@ -91,6 +104,87 @@ async def process_post(post: Post, db: AsyncSession) -> None:
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
# ── System routing ─────────────────────────────────────────────────────
# Two independent trading systems share this execution layer but NOT
# their strategy logic. See app/services/signal_categories.py.
from app.services.signal_categories import (
get_exit_profile, get_stop_ladder, is_supported_trading_source,
is_system_2, system2_display_name,
)
if not is_supported_trading_source(post.source):
logger.info("Post %d skipped: unsupported trading source=%s", post.id, post.source)
return
sys2 = is_system_2(post.source)
if sys2:
# SYSTEM 2 — reversal/breakout. The Trump-tuned regime filter
# (recent-move ≤5%, vol-contraction) actively REJECTS valid reversal
# setups, so it's bypassed entirely. The signal's structural gates
# (RSI<30 ×4wk, 200d reclaim, funding extreme) ARE the regime check.
exit_profile = get_exit_profile(post.category)
if exit_profile.stop_ladder:
logger.info(
"%s [%s/%s]: bypassing Trump regime filter. "
"Exit = STAGED stop (no TP/trailing) base sl=%.1f%% "
"ladder=%s maxhold=%dh",
system2_display_name(), post.source, post.category,
exit_profile.stop_loss_pct, exit_profile.stop_ladder,
exit_profile.max_hold_hours,
)
else:
logger.info(
"%s [%s/%s]: bypassing Trump regime filter. "
"Exit profile sl=%.1f%% trail=%s@%s maxhold=%dh inval=%s",
system2_display_name(), post.source, post.category,
exit_profile.stop_loss_pct, exit_profile.trailing_stop_pct,
exit_profile.trailing_activate_at_pct, exit_profile.max_hold_hours,
exit_profile.invalidation,
)
else:
# SYSTEM 1 — Trump. REPOSITIONED: low-frequency, selective, tight
# stop, ≥30-min holds. See signal_categories.py.
exit_profile = None
from app.services.signal_categories import (
TRUMP_MIN_CONFIDENCE, TRUMP_COOLDOWN_HOURS,
)
# (a) Confidence floor — only the very top posts clear the bar.
if (post.ai_confidence or 0) < TRUMP_MIN_CONFIDENCE:
logger.info(
"Post %d skipped: Trump confidence %d < floor %d (low-freq mode)",
post.id, post.ai_confidence or 0, TRUMP_MIN_CONFIDENCE,
)
return
# (b) Cooldown — "don't trade every opportunity". If we opened ANY
# Trump trade in the last TRUMP_COOLDOWN_HOURS, skip this post.
# DB-backed so it survives restarts (same pattern as scanners).
from app.services.scanner_state import last_signal_at
from sqlalchemy import select as _sel, func as _fn
from app.models import Post as _Post, BotTrade as _BT
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=TRUMP_COOLDOWN_HOURS)
recent_trump = await db.execute(
_sel(_fn.count(_BT.id))
.select_from(_BT)
.join(_Post, _Post.id == _BT.trigger_post_id)
.where(_Post.source == "truth", _BT.opened_at >= cutoff)
)
if (recent_trump.scalar() or 0) > 0:
logger.info(
"Post %d skipped: Trump cooldown active (a Trump trade opened "
"within the last %dh)", post.id, TRUMP_COOLDOWN_HOURS,
)
return
# (c) Regime filter — still applies; tuned for short-term behaviour.
from app.services.regime_filter import passes_regime_filter
regime_ok, regime_reasons = passes_regime_filter(asset, side)
for r in regime_reasons:
logger.info("Regime [%s]: %s", asset, r)
if not regime_ok:
logger.info("Post %d skipped: regime filter rejected (see ✗ lines above)", post.id)
return
result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712
subscribers = result.scalars().all()
@@ -111,9 +205,80 @@ async def process_post(post: Post, db: AsyncSession) -> None:
daily_budget_usd=s.daily_budget_usd,
active_from=s.active_from,
active_until=s.active_until,
# Convex-strategy fields (default to legacy values if column null).
trailing_stop_pct=s.trailing_stop_pct,
trailing_activate_at_pct=s.trailing_activate_at_pct,
max_hold_hours=s.max_hold_hours or 168,
manual_window_until=s.manual_window_until,
# Phase 1 safety
paper_mode=bool(s.paper_mode),
circuit_breaker_tripped_at=s.circuit_breaker_tripped_at,
circuit_breaker_reason=s.circuit_breaker_reason,
# Two-system separation
sys2_budget_pct=(s.sys2_budget_pct if s.sys2_budget_pct is not None else 0.7),
sys2_cb_tripped_at=s.sys2_cb_tripped_at,
sys2_cb_reason=s.sys2_cb_reason,
sys2_leverage=s.sys2_leverage,
sys2_mode=s.sys2_mode,
auto_trade=bool(s.auto_trade),
)
for s in subscribers
]
# SYSTEM 2: override the user's Trump exit params with the category's
# exit profile. The user configures risk for their Trump scalp; the
# reversal system's risk is a property of the SIGNAL, not the user.
if sys2 and exit_profile is not None:
from app.services.signal_categories import (
sys2_effective_leverage, sys2_protective_stop_pct,
sys2_normalize_mode,
)
for snap in subs_snapshot:
mode = sys2_normalize_mode(snap.get("sys2_mode"))
snap["_sys2_mode"] = mode
# Dynamic System-2 leverage (independent of the Trump `leverage`);
# default depends on the risk mode (standard 2× / aggressive 8×).
lev = sys2_effective_leverage(snap.get("sys2_leverage"), mode)
# Protective stop auto-scaled INSIDE the liquidation line for THIS
# leverage → the position is de-risked by our monitor, never
# liquidated by the exchange.
prot = sys2_protective_stop_pct(lev)
snap["_is_system_2"] = True
snap["_category"] = post.category
snap["leverage"] = lev # HL opens at sys2 leverage
snap["take_profit_pct"] = None # pure trailing, no fixed TP
# Base catastrophic floor = leverage-aware protective stop. The
# upside ladder rungs (get_stop_ladder) still ratchet this UP as
# peak gain grows; they never loosen it.
snap["stop_loss_pct"] = prot
snap["trailing_activate_at_pct"] = exit_profile.trailing_activate_at_pct
snap["trailing_stop_pct"] = exit_profile.trailing_stop_pct
snap["max_hold_hours"] = exit_profile.max_hold_hours
# Carried through for the monitor (time-stop / invalidation).
snap["_sys2_time_stop_hours"] = exit_profile.time_stop_hours
snap["_sys2_invalidation"] = exit_profile.invalidation
snap["_sys2_invalidation_price"] = post.invalidation_price
logger.info(
"System-2 dynamic leverage: %dx → protective stop %.2f%% "
"(approx liquidation %.2f%%) for %s",
lev, prot, 100.0 / lev, snap["wallet"],
)
elif not sys2:
# SYSTEM 1 — Trump, repositioned. System-enforced now (not pure
# user params): tight SL always on, but TP/trailing suppressed for
# the first TRUMP_MIN_HOLD_MINUTES so we don't scalp out early.
# User still controls size / leverage / daily budget.
from app.services.signal_categories import (
TRUMP_STOP_LOSS_PCT, TRUMP_MAX_HOLD_HOURS, TRUMP_MIN_HOLD_MINUTES,
)
for snap in subs_snapshot:
snap["stop_loss_pct"] = TRUMP_STOP_LOSS_PCT
snap["max_hold_hours"] = TRUMP_MAX_HOLD_HOURS
snap["_min_hold_minutes"] = TRUMP_MIN_HOLD_MINUTES
# Keep user's take_profit_pct / trailing config — they still pick
# the profit target; we only gate WHEN it can fire.
post_id = post.id
post_confidence = post.ai_confidence or 0
@@ -135,30 +300,47 @@ async def _execute_for_subscriber(
if not sub["hl_api_key"]:
logger.warning("Subscriber %s has no HL API key, skipping", wallet)
return
# Required-setup guard: the bot is only allowed to trade when the user
# has explicitly set take-profit, stop-loss, and a daily budget. Prevents
# accidental trading on partially-configured accounts.
missing = [k for k in ("take_profit_pct", "stop_loss_pct", "daily_budget_usd")
if sub.get(k) is None]
# Required-setup guard. System 1 (Trump) needs the user's take-profit,
# stop-loss, and daily budget. System 2 (reversal) supplies its own
# stop-loss + (no) take-profit from the category profile — only the
# daily budget remains a user-required risk cap there.
if sub.get("_is_system_2"):
required = ("daily_budget_usd",)
else:
required = ("take_profit_pct", "stop_loss_pct", "daily_budget_usd")
missing = [k for k in required if sub.get(k) is None]
if missing:
logger.info("Sub %s skipped post %d: setup incomplete (missing %s)",
wallet, post_id, ", ".join(missing))
return
if post_confidence < sub["min_confidence"]:
confidence_floor = _confidence_floor_for(sub)
if post_confidence < confidence_floor:
logger.info("Sub %s filters out post %d: conf %d < user min %d",
wallet, post_id, post_confidence, sub["min_confidence"])
wallet, post_id, post_confidence, confidence_floor)
return
# Active-window check. Both values stored as naive-UTC.
af = sub.get("active_from")
au = sub.get("active_until")
if af is not None and au is not None:
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if now_utc_naive < af or now_utc_naive >= au:
logger.info("Sub %s skipped post %d: outside active window [%s, %s)",
wallet, post_id, af, au)
return
# ── Master Auto-Trade gate (D) ─────────────────────────────────────────
# The ONE user-facing switch. The signal has ALREADY been ingested as a
# Post by the ingest endpoint (so it shows in the feed regardless); here
# we only decide whether to OPEN a trade. OFF (default) = monitor-only.
# Replaces the old scanner-toggle / timed manual-window / schedule trio.
# NOTE: this gates NEW entries only — already-open positions keep their
# protective de-risk / stop (tp_sl_monitor runs independently of this).
if not sub.get("auto_trade"):
logger.info("Sub %s: Auto-Trade OFF — post %d recorded, no trade opened",
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.
from app.services.circuit_breaker import is_tripped as _cb_tripped
_system = "sys2" if sub.get("_is_system_2") else "sys1"
tripped, cb_reason = _cb_tripped(sub, _system)
if tripped:
logger.info("Sub %s skipped post %d: %s", wallet, post_id, cb_reason)
return
try:
api_key = decrypt_api_key(sub["hl_api_key"])
@@ -166,49 +348,170 @@ async def _execute_for_subscriber(
logger.error("Cannot decrypt key for %s: %s", wallet, exc)
return
# ── Position sizing (C) ────────────────────────────────────────────────
# Score-based multiplier on the user's base size. Bigger bet when more
# confluences are in place. Computed BEFORE the lock so the value is
# available for both the budget check and the open call.
# System 2 has its OWN sizing — regime_filter's multiplier would shrink
# reversal bets (it penalises volatility expansion / prior movement,
# which are the SIGNAL for a reversal). See signal_categories.
if sub.get("_is_system_2"):
from app.services.signal_categories import system2_size_multiplier
size_mult = system2_size_multiplier(sub.get("_category"), post_confidence)
size_logic = f"sys2 cat={sub.get('_category')}"
else:
from app.services.regime_filter import calculate_size_multiplier
size_mult = calculate_size_multiplier(post_confidence, asset, side)
size_logic = "sys1 regime-scored"
sized_position_usd = round(sub["position_size_usd"] * size_mult, 2)
logger.info(
"Sub %s sizing [%s]: base=%.2f × %.2fx = %.2f (asset=%s, conf=%d)",
wallet, size_logic, sub["position_size_usd"], size_mult,
sized_position_usd, asset, post_confidence,
)
# Per-wallet lock wraps BOTH the budget re-check and the open, so two
# concurrent signals can't both pass the daily-budget gate before either
# trade is written to DB (TOCTOU race under asyncio.gather).
async with _wallet_lock(wallet):
# Re-check daily budget INSIDE the lock so it's atomic with the open.
daily_cap = sub.get("daily_budget_usd")
if daily_cap is not None and daily_cap > 0:
# Budget is SPLIT between the two systems so a Trump scalp can't eat
# the allocation reserved for a once-a-year reversal signal.
total_cap = sub.get("daily_budget_usd")
if total_cap is not None and total_cap > 0:
sys2_pct = sub.get("sys2_budget_pct", 0.7)
is_s2 = bool(sub.get("_is_system_2"))
daily_cap = total_cap * (sys2_pct if is_s2 else (1.0 - sys2_pct))
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
from app.models import Post as _P
from app.services.signal_categories import SYSTEM_2_SOURCES
async with AsyncSessionLocal() as budget_db:
spent_result = await budget_db.execute(
select(BotTrade).where(
select(BotTrade, _P.source)
.outerjoin(_P, _P.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet,
BotTrade.opened_at >= start_of_day,
)
)
spent_trades = spent_result.scalars().all()
spent = sum(
(t.size_usd if t.size_usd is not None else sub["position_size_usd"])
for t in spent_trades
# Only count spend from THIS system against THIS system's slice.
spent = 0.0
for t, src in spent_result.all():
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"])
if spent + sized_position_usd > daily_cap:
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 1.0 - sys2_pct) * 100, total_cap)
return
# ── System-2 correlation / concentration cap ───────────────────────
# Inside the wallet lock so it's atomic with the open. The whole
# System-2 basket is correlated crypto-beta; cap CONCURRENT open
# positions + total open notional so a synchronised "crypto bottomed"
# week can't stack into one 10x leveraged macro bet.
if sub.get("_is_system_2"):
from app.services.signal_categories import (
SYS2_MAX_CONCURRENT, SYS2_MAX_OPEN_NOTIONAL_MULT, SYSTEM_2_SOURCES,
)
from app.models import Post as _P2
async with AsyncSessionLocal() as conc_db:
open_rows = await conc_db.execute(
select(BotTrade, _P2.source)
.outerjoin(_P2, _P2.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
)
)
if spent + sub["position_size_usd"] > daily_cap:
logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f",
wallet, spent, sub["position_size_usd"], daily_cap)
open_sys2 = [
t for t, src in open_rows.all()
if (src or "").lower() in SYSTEM_2_SOURCES
]
open_count = len(open_sys2)
open_notional = sum(
(t.size_usd if t.size_usd is not None else sub["position_size_usd"])
for t in open_sys2
)
notional_ceiling = SYS2_MAX_OPEN_NOTIONAL_MULT * sub["position_size_usd"]
if open_count >= SYS2_MAX_CONCURRENT:
logger.info(
"Sub %s sys2 concentration cap: %d open positions ≥ max %d "
"(correlated crypto-beta — skipping post %d)",
wallet, open_count, SYS2_MAX_CONCURRENT, post_id)
return
if open_notional + sized_position_usd > notional_ceiling:
logger.info(
"Sub %s sys2 notional cap: open=%.2f + new=%.2f > ceiling=%.2f "
"(%.1f× base) — skipping post %d",
wallet, open_notional, sized_position_usd, notional_ceiling,
SYS2_MAX_OPEN_NOTIONAL_MULT, post_id)
return
# Each subscriber gets its own session — AsyncSession is NOT concurrency-safe.
async with AsyncSessionLocal() as db:
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=sub["leverage"],
mainnet=settings.hl_mainnet,
)
# ── Paper-mode branch (P1.3) ──────────────────────────────
# Skip Hyperliquid entirely. Use the current Binance price as
# the synthetic fill. DB row gets hl_order_id="paper" so close
# logic + reconciliation know to skip HL too.
if sub["paper_mode"]:
from app.services.price_store import price_store
entry_price = price_store.latest_price(asset)
if not entry_price:
logger.warning("Paper mode: no price for %s, skipping", asset)
return
# Check DB (not HL) for already-open paper position on same asset.
open_dup = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.asset == asset,
BotTrade.closed_at.is_(None),
)
)
if open_dup.scalar_one_or_none():
logger.info("Paper mode: %s already has open %s, skipping", wallet, asset)
return
result = {"fill_price": entry_price, "order_id": "paper"}
logger.info("[PAPER] Open %s %s for %s @ %.4f size=%.2f",
side, asset, wallet, entry_price, sized_position_usd)
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=sub["leverage"],
mainnet=settings.hl_mainnet,
)
open_positions = await trader.get_open_positions()
if any(p.get('coin') == asset for p in open_positions):
logger.info("Subscriber %s already has open %s position, skipping", wallet, asset)
return
open_positions = await trader.get_open_positions()
if any(p.get('coin') == asset for p in open_positions):
logger.info("Subscriber %s already has open %s position, skipping", wallet, asset)
return
result = await trader.open_position(asset, side, sub["position_size_usd"])
result = await trader.open_position(asset, side, sized_position_usd)
entry_price = result.get('fill_price', 0.0)
# Resolve the FROZEN exit profile ONCE. Everything downstream
# (the watchdog AND recovery-after-restart) reads these — never
# the mutable Subscription/category config again.
import time as _time
eff_min_hold_ts = (
_time.time() + sub["_min_hold_minutes"] * 60
if sub.get("_min_hold_minutes") else None
)
eff = dict(
take_profit_pct = sub["take_profit_pct"],
stop_loss_pct = sub["stop_loss_pct"],
trailing_stop_pct = sub.get("trailing_stop_pct"),
trailing_activate_pct = sub.get("trailing_activate_at_pct"),
max_hold_hours = int(sub["max_hold_hours"]),
invalidation = sub.get("_sys2_invalidation"),
invalidation_price = sub.get("_sys2_invalidation_price"),
min_hold_until_ts = eff_min_hold_ts,
)
trade = BotTrade(
asset=asset,
side=side,
@@ -217,17 +520,40 @@ async def _execute_for_subscriber(
trigger_post_id=post_id,
opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
hl_order_id=result.get('order_id'),
size_usd=sub["position_size_usd"],
size_usd=sized_position_usd,
base_size_usd=sized_position_usd, # immutable; pyramiding grows size_usd
sys2_mode=(_sys2_mode if sys2 else None),
leverage=sub["leverage"],
# Freeze the exit profile on the row.
eff_take_profit_pct = eff["take_profit_pct"],
eff_stop_loss_pct = eff["stop_loss_pct"],
eff_trailing_stop_pct = eff["trailing_stop_pct"],
eff_trailing_activate_pct = eff["trailing_activate_pct"],
eff_max_hold_hours = eff["max_hold_hours"],
eff_invalidation = eff["invalidation"],
eff_invalidation_price = eff["invalidation_price"],
eff_min_hold_until_ts = eff["min_hold_until_ts"],
)
db.add(trade)
await db.commit()
await db.refresh(trade)
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)",
side, asset, wallet, entry_price, trade.id)
logger.info("Opened %s %s for %s @ %.4f size=%.2f (trade_id=%d)",
side, asset, wallet, entry_price, sized_position_usd, trade.id)
# Register with the watchdog using the FROZEN profile.
from app.services.tp_sl_monitor import register_trade
_derisk = None
_addon = None
_peak_trail = None
_sys2_mode = sub.get("_sys2_mode", "standard")
if sys2:
from app.services.signal_categories import (
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
)
_derisk = sys2_derisk_ladder(sub["leverage"], _sys2_mode)
_addon = sys2_addon_ladder(_sys2_mode)
_peak_trail = sys2_peak_trail(_sys2_mode)
register_trade(
trade_id=trade.id,
wallet=wallet,
@@ -236,20 +562,235 @@ async def _execute_for_subscriber(
asset=asset,
side=side,
entry_price=entry_price,
take_profit_pct=sub["take_profit_pct"],
stop_loss_pct=sub["stop_loss_pct"],
take_profit_pct=eff["take_profit_pct"],
stop_loss_pct=eff["stop_loss_pct"],
trailing_stop_pct=eff["trailing_stop_pct"],
trailing_activate_at_pct=eff["trailing_activate_pct"],
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,
derisk_ladder=_derisk,
derisk_done=0,
addon_ladder=_addon,
addon_done=0,
peak_trail=_peak_trail,
grow_mode=bool(getattr(trade, "grow_mode", False)),
)
# Max hold backstop (per-user for System 1, per-category for
# System 2 — already injected into the snapshot upstream).
max_hold_seconds = int(sub["max_hold_hours"]) * 3600
task = asyncio.create_task(_close_after_hold(
trade.id, api_key, sub["leverage"], asset, wallet
trade.id, api_key, sub["leverage"], asset, wallet, max_hold_seconds,
))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# System-2 time-stop: if the position is still ~flat after
# `time_stop_hours`, the thesis is slow/dead — close early
# instead of tying up capital for the full max-hold.
ts_hours = sub.get("_sys2_time_stop_hours")
if ts_hours:
ts_task = asyncio.create_task(_time_stop_check(
trade.id, api_key, sub["leverage"], asset, wallet,
int(ts_hours) * 3600,
))
_background_tasks.add(ts_task)
ts_task.add_done_callback(_background_tasks.discard)
except Exception as e:
logger.error("Trade execution failed for %s: %s", wallet, e)
async def partial_derisk(
trade_id: int,
api_key: str,
asset: str,
wallet: str,
step_idx: int,
frac_of_original: float,
reason: str = "derisk",
) -> bool:
"""Staged de-risk: partially close a System-2 trade, realise the slice's
PnL, and KEEP the trade open. Idempotent per step_idx (guarded by the
persisted `derisk_steps_done` counter under the per-trade lock).
Returns True if the step was executed (or already done), False on a
recoverable no-op so the monitor can retry next tick.
"""
async with _lock_for(trade_id):
async with AsyncSessionLocal() as db:
try:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return True # gone / already fully closed — nothing to do
if (trade.derisk_steps_done or 0) > step_idx:
return True # this step already executed (idempotent)
if (trade.derisk_steps_done or 0) != step_idx:
# Steps must run in order; let the monitor catch up.
return False
size_usd = trade.size_usd if trade.size_usd is not None else 0.0
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
if size_usd <= 0 or rem_frac <= 0:
return True
# frac of the CURRENT open position needed to shed
# `frac_of_original` of the ORIGINAL notional.
frac_of_current = max(0.0, min(1.0, frac_of_original / rem_frac))
if trade.hl_order_id == "paper":
from app.services.price_store import price_store
fill = price_store.latest_price(asset)
if not fill:
logger.error("Paper de-risk: no %s price, retry trade %d", asset, trade_id)
return False
closed_frac_of_current = frac_of_current
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=(trade.leverage or 1),
mainnet=settings.hl_mainnet,
)
r = await trader.reduce_position(asset, frac_of_current)
if r.get("already_closed"):
# Position vanished (manual close / liquidation race).
# Let the reconciler / full-close path handle it.
logger.warning("De-risk: %s position gone for trade %d", asset, trade_id)
return True
fill = r.get("fill_price")
closed_frac_of_current = r.get("closed_fraction") or 0.0
if not fill or closed_frac_of_current <= 0:
return False # no fill — retry next tick
# Fraction of ORIGINAL notional actually shed this step.
closed_frac_of_original = closed_frac_of_current * rem_frac
slice_usd = size_usd * closed_frac_of_original
pct = ((fill - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
signed_pct = pct if trade.side == "long" else -pct
# Round-trip taker fee on this slice (open-share + this close).
fees = slice_usd * HL_TAKER_FEE_RATE * 2
slice_pnl = slice_usd * signed_pct - fees
trade.realized_partial_pnl_usd = round((trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4)
trade.remaining_fraction = max(0.0, round(rem_frac - closed_frac_of_original, 6))
trade.derisk_steps_done = step_idx + 1
await db.commit()
logger.warning(
"De-risk step %d trade %d (%s): closed %.1f%% of original "
"@ %.2f, slice PnL %.2f, remaining %.1f%% (reason=%s)",
step_idx + 1, trade_id, asset, closed_frac_of_original * 100,
fill, slice_pnl, trade.remaining_fraction * 100, reason,
)
return True
except Exception as e:
logger.error("partial_derisk failed for trade %d step %d: %s",
trade_id, step_idx, e)
return False
async def pyramid_add(
trade_id: int,
api_key: str,
asset: str,
wallet: str,
step_idx: int,
frac_of_base: float,
reason: str = "pyramid",
) -> tuple:
"""Pyramiding: add to a CONFIRMED System-2 winner. Returns
(ok: bool, new_entry: float|None, ref_price: float|None).
Guards: only while derisk_steps_done==0 (clean uptrend), idempotent per
step_idx, structural confirmation re-checked at execution, per-trade
notional cap. On success blends the average entry and grows size_usd.
"""
async with _lock_for(trade_id):
async with AsyncSessionLocal() as db:
try:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return (True, None, None)
if (trade.derisk_steps_done or 0) > 0:
return (True, None, None) # went underwater — no pyramiding
if (trade.addon_steps_done or 0) > step_idx:
return (True, None, None) # already done (idempotent)
if (trade.addon_steps_done or 0) != step_idx:
return (False, None, None) # out of order — retry later
base = trade.base_size_usd or trade.size_usd or 0.0
if base <= 0:
return (True, None, None)
from app.services.signal_categories import SYS2_MAX_OPEN_NOTIONAL_MULT
add_usd = round(base * frac_of_base, 2)
cur_notional = trade.size_usd or base
if cur_notional + add_usd > base * SYS2_MAX_OPEN_NOTIONAL_MULT:
logger.warning("Pyramid: notional cap hit trade %d (%.2f+%.2f > %.1fx base)",
trade_id, cur_notional, add_usd, SYS2_MAX_OPEN_NOTIONAL_MULT)
trade.addon_steps_done = step_idx + 1 # stop retrying
await db.commit()
return (True, None, None)
# Structural confirmation — fetched ONCE here, not per tick.
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services.bottom_indicators import trend_confirmed
from app.services.price_store import price_store
prov = for_asset(asset)
raw = await prov.fetch_1d(asset, days=260)
daily = drop_in_progress_bar(raw, "1d")
closes = [float(c["close"]) for c in daily if c.get("close")]
highs = [float(c["high"]) for c in daily if c.get("high")]
px = price_store.latest_price(asset)
if not px:
return (False, None, None)
if not trend_confirmed(closes, highs, px):
return (False, None, None) # not a confirmed uptrend yet
if trade.hl_order_id == "paper":
fill = px
actual_add_usd = add_usd # synthetic full fill
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=(trade.leverage or 1),
mainnet=settings.hl_mainnet,
)
r = await trader.open_position(asset, trade.side, add_usd)
fill = r.get("fill_price")
filled_coins = float(r.get("size_coins") or 0.0)
if not fill or filled_coins <= 0:
return (False, None, None)
# Use the ACTUAL filled notional, not the intended amount —
# an IOC add can under-fill on thin/volatile books; assuming
# the full add_usd would corrupt the blended entry + size.
actual_add_usd = filled_coins * fill
old_notional = trade.size_usd or base
new_notional = old_notional + actual_add_usd
blended = (old_notional * trade.entry_price + actual_add_usd * fill) / new_notional
trade.entry_price = round(blended, 6)
trade.size_usd = round(new_notional, 2)
trade.addon_steps_done = step_idx + 1
await db.commit()
logger.warning(
"Pyramid add step %d trade %d (%s): +$%.2f filled @ %.2f"
"notional $%.2f, blended entry %.4f (reason=%s)",
step_idx + 1, trade_id, asset, actual_add_usd, fill,
new_notional, blended, reason,
)
return (True, round(blended, 6), float(fill))
except Exception as e:
logger.error("pyramid_add failed trade %d step %d: %s",
trade_id, step_idx, e)
return (False, None, None)
async def close_and_finalize(
trade_id: int,
api_key: str,
@@ -297,23 +838,46 @@ async def close_and_finalize(
size_usd = sub.position_size_usd if sub else 20.0
trade_leverage = trade.leverage if trade.leverage is not None else leverage
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=trade_leverage,
mainnet=settings.hl_mainnet,
)
close_result = await trader.close_position(asset)
# ── Paper-mode close (P1.3) ──────────────────────────────
# No Hyperliquid call. Synthesize a fill at the current Binance
# price. Same downstream PnL math as a real trade.
if trade.hl_order_id == "paper":
from app.services.price_store import price_store
paper_exit = price_store.latest_price(asset)
if not paper_exit:
logger.error("Paper close: no price for %s, can't close trade %d",
asset, trade_id)
# Roll back the closed_at claim so we can retry later.
await db.execute(
update(BotTrade).where(BotTrade.id == trade_id).values(closed_at=None)
)
await db.commit()
return
close_result = {"fill_price": paper_exit, "already_closed": False}
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=trade_leverage,
mainnet=settings.hl_mainnet,
)
close_result = await trader.close_position(asset)
# Detect "position was already closed externally" before we even tried
if close_result.get("already_closed"):
# PnL of the (now-gone) open remainder is unknown, BUT any
# PnL already BANKED by staged de-risk is real money — keep
# it in pnl_usd instead of discarding it as None, else
# analytics / "today realised" silently undercount.
banked = trade.realized_partial_pnl_usd or 0.0
logger.warning(
"Trade %d: no open %s position found on HL — "
"user likely closed it manually. Marking trade closed with no PnL.",
trade_id, asset,
"Trade %d: no open %s position found on HL — user likely "
"closed it manually. Marking closed; pnl=%s (banked de-risk only).",
trade_id, asset, round(banked, 2) if banked else None,
)
trade.exit_price = None
trade.pnl_usd = None
trade.pnl_usd = round(banked, 2) if banked else None
trade.remaining_fraction = 0.0
trade.hold_seconds = int(
(datetime.now(timezone.utc) -
trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds()
@@ -334,13 +898,24 @@ async def close_and_finalize(
pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
signed_pct = pct if trade.side == 'long' else -pct
# size_usd is the NOTIONAL value — leverage affects margin, not PnL.
gross_pnl = size_usd * signed_pct
# Deduct round-trip taker fees (IOC crosses book on both open + close).
# Without this, displayed PnL overstates real returns by ~9 bps per trade.
fees_usd = size_usd * HL_TAKER_FEE_RATE * 2
pnl_usd = gross_pnl - fees_usd
# For a staged-de-risk trade only the REMAINING notional is
# closed here; earlier partial reduces already realised their
# slice into realized_partial_pnl_usd. Legacy/non-partial rows
# have remaining_fraction=1.0 + realized_partial=0.0 → identical
# math to before.
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
prior_pnl = trade.realized_partial_pnl_usd or 0.0
remaining_size = size_usd * rem_frac
gross_pnl = remaining_size * signed_pct
# Round-trip taker fees: the OPEN fee was paid on the full
# original notional; partial reduces already charged their
# close-side fee. Here charge the open-share for the remaining
# slice + this final close fee.
fees_usd = remaining_size * HL_TAKER_FEE_RATE * 2
pnl_usd = gross_pnl - fees_usd + prior_pnl
trade.exit_price = exit_price
trade.remaining_fraction = 0.0
trade.pnl_usd = round(pnl_usd, 2)
trade.hold_seconds = hold_secs
# closed_at already set by the atomic UPDATE
@@ -357,6 +932,24 @@ async def close_and_finalize(
gross_pnl, fees_usd, pnl_usd, reason,
)
# ── Circuit breaker check (P1.1) ───────────────────────────
# Run after PnL is written. If daily DD or consecutive-loss
# threshold hit, trip the breaker — that nulls manual_window
# and blocks new trades for CB_LOCKOUT_HOURS.
# Infer which system this trade belonged to from its trigger
# post's source, so the right (independent) breaker is checked.
from app.services.circuit_breaker import check_and_trip
from app.services.signal_categories import SYSTEM_2_SOURCES
_src_row = await db.execute(
select(Post.source).where(Post.id == trade.trigger_post_id)
)
_src = (_src_row.scalar_one_or_none() or "").lower()
_sys = "sys2" if _src in SYSTEM_2_SOURCES else "sys1"
cb_reason = await check_and_trip(wallet, db, _sys)
if cb_reason:
logger.warning("Circuit breaker [%s] tripped for %s: %s",
_sys, wallet, cb_reason)
except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, e)
# Rollback closed_at so recovery can retry this trade on next restart.
@@ -377,7 +970,58 @@ async def close_and_finalize(
async def _close_after_hold(
trade_id: int, api_key: str, leverage: int, asset: str, wallet: str
trade_id: int, api_key: str, leverage: int, asset: str, wallet: str,
max_hold_seconds: int,
) -> None:
await asyncio.sleep(MAX_HOLD_SECONDS)
"""Per-user max-hold backstop. Forces close after the configured duration.
The trailing-stop monitor will usually close the position long before this
fires, but for trades that drift sideways the cap prevents indefinite
funding-rate bleed.
"""
await asyncio.sleep(max_hold_seconds)
await close_and_finalize(trade_id, api_key, leverage, asset, wallet, reason="max_hold")
# Flat-zone band: a position whose |unrealised| is within this % at the
# time-stop checkpoint is considered "not working" and closed early.
_TIME_STOP_FLAT_BAND_PCT = 2.0
async def _time_stop_check(
trade_id: int, api_key: str, leverage: int, asset: str, wallet: str,
delay_seconds: int,
) -> None:
"""System-2 time-stop. After `delay_seconds`, if the trade is still open
AND roughly flat (|unrealised| < band), the thesis is slow/dead — close
it so capital isn't parked for the full multi-week max-hold on a dud.
A trade that's already moved (win or stopped) will be closed/gone by
now; the conditional UPDATE in close_and_finalize makes a late fire a
harmless no-op.
"""
await asyncio.sleep(delay_seconds)
from sqlalchemy import select
async with AsyncSessionLocal() as db:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return # already closed by trail / SL / invalidation
entry = trade.entry_price
from app.services.price_store import price_store
cur = price_store.latest_price(asset)
if not cur or not entry:
return # no price → don't act blindly; max-hold will catch it
raw = (cur - entry) / entry
signed_pct = (raw if trade.side == "long" else -raw) * 100
if abs(signed_pct) < _TIME_STOP_FLAT_BAND_PCT:
logger.info(
"Time-stop firing trade %d (%s %s): flat at %.2f%% after %dh",
trade_id, trade.side, asset, signed_pct, delay_seconds // 3600,
)
await close_and_finalize(
trade_id, api_key, leverage, asset, wallet, reason="time_stop",
)
+190
View File
@@ -0,0 +1,190 @@
"""Pure-price BTC bottom indicators (no on-chain data, no API keys).
Three independent, well-known bottom signals. Each is a simple, transparent
function of price history only — no Realized Cap, no MVRV, no paid feeds:
A. AHR999 — value/DCA index. < 0.45 = deep-value bottom zone.
B. 200-Week MA — BTC has bottomed near its 200WMA every cycle.
C. Pi Cycle Bot — 150d EMA vs 471d SMA × 0.745 crossover region.
The combiner fires only when at least 2 of the 3 agree ("三者为二"), which
historically pins genuine macro bottoms while rejecting single-indicator
noise. Long-only, low-frequency, stateless.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
# BTC genesis block: 2009-01-03.
_BTC_GENESIS = date(2009, 1, 3)
# AHR999 thresholds.
AHR999_BOTTOM = 0.45 # < this → deep-value / bottom zone (signal A)
AHR999_INVALIDATION = 1.2 # > this → no longer cheap, thesis dead
# 200WMA tolerance: price within +5% of the 200-week mean still counts.
WMA200_TOLERANCE = 1.05
# Pi Cycle Bottom multiplier (the canonical 0.745).
PI_CYCLE_MULT = 0.745
PI_CYCLE_EMA = 150
PI_CYCLE_SMA = 471
def _closes(candles: list[dict]) -> list[float]:
return [float(c["close"]) for c in candles if c.get("close")]
def _sma(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
return sum(values[-n:]) / n
def _ema(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
k = 2.0 / (n + 1)
# Seed with the SMA of the first n values, then walk forward.
ema = sum(values[:n]) / n
for v in values[n:]:
ema = v * k + ema * (1 - k)
return ema
def coin_age_days(today: date | None = None) -> int:
d = today or datetime.now(timezone.utc).date()
return max(1, (d - _BTC_GENESIS).days)
def ahr999(daily_closes: list[float], today: date | None = None) -> float | None:
"""AHR999 = (price / GM200) × (price / growth_val).
GM200 = geometric mean of the last 200 daily closes
growth_val = 10 ** (5.84 * log10(coin_age_days) - 17.01)
< 0.45 deep value · 0.451.2 DCA · > 1.2 expensive.
"""
if len(daily_closes) < 200:
return None
price = daily_closes[-1]
if price <= 0:
return None
window = daily_closes[-200:]
# Geometric mean via log-space (avoids overflow).
log_sum = sum(math.log(c) for c in window if c > 0)
gm200 = math.exp(log_sum / 200)
age = coin_age_days(today)
growth_val = 10 ** (5.84 * math.log10(age) - 17.01)
if gm200 <= 0 or growth_val <= 0:
return None
return (price / gm200) * (price / growth_val)
def below_200wma(
weekly_closes: list[float],
price: float | None = None,
) -> tuple[bool, float | None]:
"""True if price ≤ 200-week mean × 1.05. Returns (signal, wma200).
`price` is the reference price to compare. Pass the latest DAILY close so
all three indicators use the same "current price" — otherwise the last
weekly close can be up to 7 days stale and B would disagree with A/C right
at a fast-moving bottom. Falls back to the last weekly close if omitted.
"""
wma = _sma(weekly_closes, 200)
if wma is None or not weekly_closes:
return False, wma
px = price if price is not None else weekly_closes[-1]
return px <= wma * WMA200_TOLERANCE, wma
def pi_cycle_bottom(daily_closes: list[float]) -> tuple[bool, float | None, float | None]:
"""Pi Cycle Bottom: 150d EMA ≤ 471d SMA × 0.745.
Returns (signal, ema150, sma471_scaled).
"""
ema150 = _ema(daily_closes, PI_CYCLE_EMA)
sma471 = _sma(daily_closes, PI_CYCLE_SMA)
if ema150 is None or sma471 is None:
return False, ema150, None
scaled = sma471 * PI_CYCLE_MULT
return ema150 <= scaled, ema150, scaled
def trend_confirmed(
daily_closes: list[float],
daily_highs: list[float],
price: float,
sma_n: int = 200,
high_lookback: int = 20,
high_tol: float = 0.005,
) -> bool:
"""Structural confirmation that the bottom reversal has turned into an
actual uptrend — gate for pyramiding (add-to-winner):
price ≥ 200-day SMA (trend regime reclaimed)
AND price ≥ recent 20d high·(10.5%) (at/near a fresh local high)
Pure function (no I/O) so it's unit-testable; the caller fetches candles.
Conservative: both conditions must hold, evaluated at add-on time.
"""
sma = _sma(daily_closes, sma_n)
if sma is None or not daily_highs:
return False
recent_high = max(daily_highs[-high_lookback:]) if len(daily_highs) >= 1 else None
if recent_high is None or recent_high <= 0:
return False
return price >= sma and price >= recent_high * (1.0 - high_tol)
@dataclass(frozen=True)
class BottomConfluence:
fired: bool # ≥ 2 of 3 true
votes: int # 0..3
ahr999: float | None
a_value: bool # AHR999 < 0.45
b_200wma: bool # price ≤ 200WMA × 1.05
c_pi_cycle: bool # 150 EMA ≤ 471 SMA × 0.745
detail: dict
def bottom_confluence(
daily_closes: list[float],
weekly_closes: list[float],
today: date | None = None,
) -> BottomConfluence:
"""2-of-3 bottom confluence. Pure price, stateless."""
ah = ahr999(daily_closes, today)
a = ah is not None and ah < AHR999_BOTTOM
# All three indicators compare against the SAME current price (latest
# daily close), so a stale weekly close can't make B disagree with A/C.
cur_price = daily_closes[-1] if daily_closes else None
b, wma200 = below_200wma(weekly_closes, cur_price)
c, ema150, sma471s = pi_cycle_bottom(daily_closes)
votes = int(a) + int(b) + int(c)
detail = {
"ahr999": round(ah, 4) if ah is not None else None,
"ahr999_threshold": AHR999_BOTTOM,
"price": round(daily_closes[-1], 2) if daily_closes else None,
"wma200": round(wma200, 2) if wma200 is not None else None,
"wma200_band": round(wma200 * WMA200_TOLERANCE, 2) if wma200 is not None else None,
"pi_ema150": round(ema150, 2) if ema150 is not None else None,
"pi_sma471_scaled": round(sma471s, 2) if sma471s is not None else None,
"votes": votes,
"signals": {"ahr999_value": a, "below_200wma": b, "pi_cycle_bottom": c},
}
return BottomConfluence(
fired=votes >= 2,
votes=votes,
ahr999=ah,
a_value=a,
b_200wma=b,
c_pi_cycle=c,
detail=detail,
)
+201
View File
@@ -0,0 +1,201 @@
"""
Circuit breaker — autonomous "stop trading" trigger per wallet.
Two trip conditions, evaluated AFTER every closed trade:
1. Daily drawdown: cumulative PnL across all trades closed today (UTC)
drops below -CB_DAILY_DD_PCT × (subscription.position_size_usd × 10).
Default cap: -5% of "expected 10-trade notional" — i.e. if the user's
base bet is $20, the daily DD limit is -$10 net realized.
2. Consecutive losses: last CB_CONSEC_LOSSES closed trades for this wallet
all have pnl_usd < 0. Default = 3.
When tripped:
- manual_window_until is nulled (bot stops trading via manual override)
- circuit_breaker_tripped_at = now
- circuit_breaker_reason = "daily_dd" | "consecutive_losses"
- WS broadcast a 'circuit_breaker_tripped' event so the UI lights up red
Gate: `is_tripped()` is checked by _execute_for_subscriber BEFORE opening any
new trade. A tripped wallet stays blocked for CB_LOCKOUT_HOURS regardless of
schedule, so a buggy scanner or a black-swan move can't drain the account
overnight.
User unblock path:
POST /api/user/{wallet}/manual-window with any hours > 0 clears the trip.
This is a deliberate human-in-the-loop — the user must consciously re-arm.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import BotTrade, Subscription
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
CB_DAILY_DD_USD_PER_BASE = 0.5 # Daily DD limit as a multiple of base size.
# base $20 → DD limit -$10. Tune to taste.
CB_CONSEC_LOSSES = 3 # Consecutive losing trades that trip the CB.
CB_LOCKOUT_HOURS = 24 # Once tripped, blocked for this many hours
# unless the user explicitly clears it.
# ─── Trip detection ─────────────────────────────────────────────────────────
# Column indirection so one code path serves both independent breakers.
# system "sys1" → circuit_breaker_tripped_at / circuit_breaker_reason
# system "sys2" → sys2_cb_tripped_at / sys2_cb_reason
_CB_COLS = {
"sys1": ("circuit_breaker_tripped_at", "circuit_breaker_reason"),
"sys2": ("sys2_cb_tripped_at", "sys2_cb_reason"),
}
async def _trades_for_system(db: AsyncSession, wallet: str, since, system: str):
"""Closed, priced trades for `wallet` since `since`, filtered to the
given system by joining the trigger post's source.
sys2 = trigger_post.source in System-2 set; sys1 = everything else
(currently only "truth"). Trades with no trigger post → treated sys1.
"""
from app.models import Post
from app.services.signal_categories import SYSTEM_2_SOURCES
rows = await db.execute(
select(BotTrade, Post.source)
.outerjoin(Post, Post.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at >= since,
BotTrade.pnl_usd.is_not(None),
)
.order_by(BotTrade.closed_at.desc())
)
out = []
for trade, src in rows.all():
is_s2 = (src or "").lower() in SYSTEM_2_SOURCES
if (system == "sys2") == is_s2:
out.append(trade)
return out
async def check_and_trip(
wallet: str, db: AsyncSession, system: str = "sys1",
) -> Optional[str]:
"""Run trip conditions for `wallet` within ONE system. Independent per
system: a Trump losing streak can't lock out the reversal book.
Called from close_and_finalize after a trade's pnl is written, with the
system inferred from the closing trade's source.
"""
col_at, col_reason = _CB_COLS[system]
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
return None
tripped_at = getattr(sub, col_at)
if tripped_at is not None:
age_hrs = (datetime.now(timezone.utc).replace(tzinfo=None) - tripped_at).total_seconds() / 3600
if age_hrs < CB_LOCKOUT_HOURS:
return None # still in active lockout
reason: Optional[str] = None
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
start_of_day = now_naive.replace(hour=0, minute=0, second=0, microsecond=0)
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
if today_pnl < dd_limit:
reason = "daily_dd"
logger.warning("CB[%s] trip [daily_dd] %s: pnl=%.2f < %.2f",
system, wallet, today_pnl, dd_limit)
if reason is None:
# Consecutive losses within this system (all-time, most recent N).
all_sys = await _trades_for_system(
db, wallet, datetime(1970, 1, 1), system,
)
recent = all_sys[:CB_CONSEC_LOSSES]
if len(recent) >= CB_CONSEC_LOSSES and all((t.pnl_usd or 0) < 0 for t in recent):
reason = "consecutive_losses"
logger.warning("CB[%s] trip [consecutive_losses] %s: last %d all negative",
system, wallet, CB_CONSEC_LOSSES)
if reason is None:
return None
setattr(sub, col_at, now_naive)
setattr(sub, col_reason, reason)
# Only System 1 owns manual_window; nulling it on a sys2 trip would
# wrongly disable the Trump book too. Sys2 trip just blocks sys2 entries.
if system == "sys1":
sub.manual_window_until = None
await db.commit()
try:
from app.ws.manager import manager
await manager.broadcast({
"type": "circuit_breaker_tripped",
"wallet": wallet,
"system": system,
"reason": reason,
"tripped_at": now_naive.isoformat(),
"unlock_at": (now_naive + timedelta(hours=CB_LOCKOUT_HOURS)).isoformat(),
})
except Exception as exc:
logger.warning("CB broadcast failed: %s", exc)
return reason
# ─── Gate (called by _execute_for_subscriber) ───────────────────────────────
def is_tripped(sub_dict: dict, system: str = "sys1") -> tuple[bool, str]:
"""Returns (tripped, reason) for the given system's breaker.
`sub_dict` is the snapshot copy built in bot_engine."""
col_at, col_reason = _CB_COLS[system]
tripped_at = sub_dict.get(col_at)
if tripped_at is None:
return False, ""
age_hrs = (datetime.now(timezone.utc).replace(tzinfo=None) - tripped_at).total_seconds() / 3600
if age_hrs >= CB_LOCKOUT_HOURS:
return False, "" # lockout expired (auto-untrip on next refresh)
reason = sub_dict.get(col_reason) or "unknown"
remaining_hrs = CB_LOCKOUT_HOURS - age_hrs
return True, f"cb[{system}]:{reason} (unlock in {remaining_hrs:.1f}h)"
# ─── Manual reset (called from /manual-window endpoint) ─────────────────────
async def clear_trip(wallet: str, db: AsyncSession) -> bool:
"""Clear the trip state. Returns True if a trip was actually cleared.
Called when the user explicitly re-arms manual_window — that's the
human-in-the-loop unblock. Just letting LOCKOUT_HOURS expire works too,
but most users will hit the button.
"""
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None or sub.circuit_breaker_tripped_at is None:
return False
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
await db.commit()
logger.info("CB cleared for %s", wallet)
return True
+347
View File
@@ -0,0 +1,347 @@
"""
Deterministic entry pre-filter — runs BEFORE the AI call.
Lesson from the 13-trade backtest: AI gives confidence 85+ to posts like
"I had excellent conversations with President X" and "The Strait is open again",
which are second-derivative news or pure rhetoric — they don't move markets.
The two posts that ACTUALLY produced 10%+ moves both contained explicit
"ACTION TAKEN" language: "STATEMENT OF PRESIDENT", "Military Success that we
have had". The losers all had future-tense or conversational language.
Rather than hoping the LLM internalises this distinction reliably, we enforce
it with a deterministic Python filter. Cheap, debuggable, no false positives
from a hallucinating model.
This filter runs BEFORE analyze_post() in the scraper, short-circuiting the
AI call when the text is obviously non-actionable. Saves API spend AND keeps
the bot from accumulating "confidence 85, no catalyst" trades.
Three independent gates (post must pass ALL):
1. ACTION verbs present — past-tense action OR formal statement marker
2. NO future-tense disqualifiers — "I will", "considering", "thinking about"
3. NOT a duplicate of a recent post on the same topic
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ─── Gate 1: explicit action markers ────────────────────────────────────────
# Lower-cased substring matches. A post must contain AT LEAST ONE of these.
ACTION_MARKERS = [
# Formal statement / announcement (past or imperative)
"statement of",
"statement by",
"executive order",
"announcing",
"today i signed",
"i have signed",
"i hereby",
"effective immediately",
"effective today",
# Past-tense action by the administration / military
"have agreed",
"has agreed",
"was signed",
"have signed",
"have implemented",
"have imposed",
"have authorized",
"we have had", # "Military Success that we have had..." (post 684)
"have successfully",
"just imposed",
"just signed",
# NOTE: "i have ordered" was in this list but produced false positives.
# Trump uses it for verbal directives ("ordered Navy to shoot boats")
# that don't move markets. Real catalysts use "I have signed" or "STATEMENT OF".
# If you need to catch a genuine order, list the specific action below:
"ordered the suspension of",
"ordered the cancellation of",
"ordered the deployment of",
"just announced",
# Asset / policy specific catalysts
"strategic reserve",
"tariff",
"sanctions on",
"ceasefire signed",
"rate cut",
"rate hike",
# Regulatory / legal
"sec approval",
"sec has approved",
"supreme court ruled",
"doj filed",
]
# ─── Gate 2: future-tense / hedge disqualifiers ─────────────────────────────
# If ANY of these appear in the first 200 chars, the post is intent not action.
FUTURE_TENSE_BLOCKERS = [
# Pure intent
"i will",
"we will",
"i'm thinking",
"considering",
"looking at",
"may consider",
"could be",
# Conversational (not action)
"had a conversation",
"had conversations",
"had a great call",
"had a great meeting",
"spoke with",
"talked with",
"met with",
"i just had excellent",
# Hedge / partial
"most points were agreed",
"we're close to",
"still working on",
]
# ─── Gate 3: deduplication ──────────────────────────────────────────────────
# Two layers:
#
# Layer A: TOPIC KEYWORDS — curated set of geopolitical / macro nouns. If two
# posts within DEDUP_WINDOW_HOURS share ≥1 topic keyword, they're flagged as
# being about the same event. This catches "Iran/Strait/Hormuz" being repeated
# 4 times across 5 days, which the original token-overlap dedup missed because
# the words ARE different (closed/opened/announced/transited) — only the
# topic is the same.
#
# Layer B (legacy): generic token-overlap fallback for posts that don't hit a
# curated topic. Kept as backup so we don't miss novel re-iterations.
DEDUP_WINDOW_HOURS = 48
DEDUP_KEYWORD_THRESHOLD = 3 # token-overlap backup threshold
# Curated list of "topic anchors". Lower-cased substring match. When a new
# post AND a prior post both contain one of these, we treat the new post as
# a probable update to an existing storyline, not a fresh catalyst.
#
# Tune this list against your backtest results: anything that produced repeat
# false-positives should be on here. Anything that produced a real catalyst
# (post 684 = "Pakistan" — yes, even though we list it, Pakistan being mentioned
# again within 48h would correctly be flagged as redundant) should NOT cause us
# to miss the FIRST occurrence — only subsequent ones.
TOPIC_KEYWORDS = {
# Middle East geopolitics (dominant source of FP in our 13-trade audit)
"iran", "iranian",
"hormuz", "strait of hormuz",
"strait of iran", # Trump-ism on post 62 — same physical geography as Hormuz
"the strait", # generic — catches "the Strait is open/closed" phrasing
"israel", "gaza", "lebanon", "hezbollah", "syria",
"yemen", "houthi",
# Russia / Ukraine
"ukraine", "russia", "putin", "zelensky", "kyiv",
# China
"china", "chinese", "xi jinping", "taiwan",
# North Korea
"north korea", "kim jong",
# Macro / monetary
"tariff", "tariffs",
"fed", "fomc", "powell", "interest rate", "rate cut", "rate hike",
"cpi", "inflation",
# Crypto-specific
"bitcoin", "btc", "ethereum", "crypto", "sec",
"strategic reserve",
# South Asia (post 684 family)
"pakistan", "india",
}
# Stop-words we ignore when comparing
_STOP_WORDS = {
"the","a","an","of","to","and","or","in","on","at","is","are","was",
"were","be","been","have","has","had","will","i","we","you","they",
"this","that","with","for","by","from","my","our","your","their",
"it","its","as","but","not","just","very","more","most","than","also",
"would","could","should","do","does","did","new","now","get","got",
"make","made","go","going","go","want","want","know","like","really",
"us","u","said","say","says",
}
def _significant_tokens(text: str) -> set[str]:
"""Lower-cased alphanumeric words ≥4 chars, with stop-words removed."""
raw = re.findall(r"[a-zA-Z]{4,}", text.lower())
return {w for w in raw if w not in _STOP_WORDS}
# How many leading characters to scan for "dominant" topics. Topics that only
# appear in the tail of a long post are background context, not the catalyst.
# A post that mentions Iran in passing 800 chars in shouldn't dedup against
# a different post whose CORE topic is Iran.
TOPIC_HEAD_CHARS = 200
def _topic_keywords_in(text: str) -> set[str]:
"""Return canonical TOPIC_KEYWORDS in the FIRST TOPIC_HEAD_CHARS of `text`.
Canonicalisation collapses substring matches: if both "hormuz" and
"strait of hormuz" match, only the longer one is kept. This prevents
inflated overlap counts where the same physical concept matches twice.
Limiting to the head ensures we match the post's DOMINANT topic, not
incidental mentions buried in the tail.
"""
t = text[:TOPIC_HEAD_CHARS].lower()
raw = {kw for kw in TOPIC_KEYWORDS if kw in t}
# Drop any keyword that is a proper substring of another matched keyword.
canonical = set(raw)
for a in raw:
for b in raw:
if a != b and a in b:
canonical.discard(a)
break
return canonical
# Threshold for dedup by topic. Requiring ≥2 shared CANONICAL topics avoids
# false positives where two posts both mention Iran but are about different
# events (post 684 = Pakistan-Iran context; post 416 = Iran sanctions —
# they share "iran" but nothing else). Two-topic overlap (e.g. Iran + Strait
# of Hormuz, or Bitcoin + Strategic Reserve) is a much stronger same-event
# signal.
DEDUP_TOPIC_MIN = 2
# ─── Public API ─────────────────────────────────────────────────────────────
def has_action_marker(text: str) -> tuple[bool, Optional[str]]:
"""True iff at least one ACTION_MARKERS substring appears in `text`.
Returns (ok, matched_marker_or_None).
"""
t = text.lower()
for m in ACTION_MARKERS:
if m in t:
return True, m
return False, None
def has_future_tense_blocker(text: str) -> tuple[bool, Optional[str]]:
"""True iff one of FUTURE_TENSE_BLOCKERS appears (in first 200 chars)."""
head = text[:200].lower()
for b in FUTURE_TENSE_BLOCKERS:
if b in head:
return True, b
return False, None
async def is_duplicate_of_recent(text: str, db_session_factory) -> tuple[bool, Optional[int]]:
"""True iff a post within DEDUP_WINDOW_HOURS appears to cover the same event.
Two-layer check:
A. Any shared TOPIC_KEYWORD (Iran, Pakistan, tariff, etc.) → duplicate
B. ≥3 shared significant tokens AND ≥50% overlap → duplicate (catches
novel topics not on the curated list)
Returns (is_dup, dup_post_id_or_None).
"""
from sqlalchemy import select
from app.models import Post
new_topics = _topic_keywords_in(text)
new_tokens = _significant_tokens(text)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=DEDUP_WINDOW_HOURS)).replace(tzinfo=None)
async with db_session_factory() as db:
rows = await db.execute(
select(Post.id, Post.text).where(Post.published_at >= cutoff)
)
for pid, prior_text in rows.all():
# Layer A: canonical topic-keyword overlap (≥ DEDUP_TOPIC_MIN)
if new_topics:
prior_topics = _topic_keywords_in(prior_text)
if len(new_topics & prior_topics) >= DEDUP_TOPIC_MIN:
return True, pid
# Layer B: generic token-overlap fallback for novel topics
if len(new_tokens) >= DEDUP_KEYWORD_THRESHOLD:
prior_tokens = _significant_tokens(prior_text)
shared = new_tokens & prior_tokens
if len(shared) >= DEDUP_KEYWORD_THRESHOLD:
if len(shared) / max(len(new_tokens), 1) >= 0.5:
return True, pid
return False, None
async def passes_entry_filter(
text: str,
db_session_factory,
) -> tuple[bool, str]:
"""Master combiner. Returns (passed, reason_string).
On failure, `reason_string` explains which gate killed it. On success,
`reason_string` is empty.
Run this BEFORE analyze_post() to short-circuit the AI call on obvious
non-catalysts.
"""
ok_action, marker = has_action_marker(text)
if not ok_action:
return False, "no_action_marker (no past-tense / formal-statement keyword found)"
has_block, blocker = has_future_tense_blocker(text)
if has_block:
return False, f"future_tense_blocker: {blocker!r}"
is_dup, dup_id = await is_duplicate_of_recent(text, db_session_factory)
if is_dup:
return False, f"duplicate_of_post_{dup_id}"
return True, f"action_marker={marker!r}"
# ─── Backtest helper — synchronous version using a single text + history ────
def passes_entry_filter_sync(text: str, prior_texts: list[str]) -> tuple[bool, str]:
"""Pure-function variant for backtest replay: pass a list of prior post
texts (within the dedup window) instead of hitting the DB.
Useful for running the filter over the historical dataset to count how
many posts would have been EXECUTED vs FILTERED.
"""
ok_action, marker = has_action_marker(text)
if not ok_action:
return False, "no_action_marker"
has_block, blocker = has_future_tense_blocker(text)
if has_block:
return False, f"future_tense: {blocker}"
new_topics = _topic_keywords_in(text)
new_tokens = _significant_tokens(text)
for pt in prior_texts:
# Layer A: canonical topic overlap (≥ DEDUP_TOPIC_MIN)
if new_topics:
shared_topics = new_topics & _topic_keywords_in(pt)
if len(shared_topics) >= DEDUP_TOPIC_MIN:
return False, f"duplicate_topic: {','.join(sorted(shared_topics))}"
# Layer B: generic token overlap
if len(new_tokens) >= DEDUP_KEYWORD_THRESHOLD:
shared = new_tokens & _significant_tokens(pt)
if (len(shared) >= DEDUP_KEYWORD_THRESHOLD
and len(shared) / max(len(new_tokens), 1) >= 0.5):
return False, "duplicate_tokens"
return True, f"ok: {marker}"
+221
View File
@@ -0,0 +1,221 @@
"""
Breakout Signal Monitor — ETH + LINK (BTC as trend context)
Polls Binance every 5 minutes. When both conditions are met:
1. BB squeeze: BB width in bottom 20% of last 60 candles
2. Volume spike: current volume > 2.5x 20-period average
3. Taker buy ratio > 60%
4. Price closes above upper Bollinger Band
Broadcasts alert via WebSocket. Gated by user on/off toggle.
"""
import collections
import logging
from datetime import datetime, timezone
from typing import Deque, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────────────────
WATCH_SYMBOLS = ["ETHUSDT", "LINKUSDT"]
CONTEXT_SYMBOL = "BTCUSDT"
ALL_SYMBOLS = [CONTEXT_SYMBOL] + WATCH_SYMBOLS
CANDLE_LIMIT = 200 # candles to fetch per poll (200 x 5m = ~16.7h history)
BB_PERIOD = 20
BB_SQUEEZE_PCT = 20 # bottom 20% of BB width history → squeeze
VOLUME_MULT = 2.5
TBR_THRESH = 0.60 # taker buy ratio threshold
BTC_TREND_MA = 288 # 24h trend (needs separate longer fetch for BTC)
# ── State ─────────────────────────────────────────────────────────────────────
_enabled: bool = False
_recent_signals: Deque[dict] = collections.deque(maxlen=50)
_last_fired: dict[str, Optional[datetime]] = {s: None for s in WATCH_SYMBOLS}
# ── Public API ────────────────────────────────────────────────────────────────
def set_enabled(value: bool) -> None:
global _enabled
_enabled = value
logger.info("Funding signal monitor: %s", "ENABLED" if value else "DISABLED")
def is_enabled() -> bool:
return _enabled
def get_recent_signals(limit: int = 20) -> list[dict]:
return list(reversed(list(_recent_signals)))[:limit]
def get_status() -> dict:
return {
"enabled": _enabled,
"symbols": WATCH_SYMBOLS,
"recent_signal_count": len(_recent_signals),
}
# ── Binance fetch ─────────────────────────────────────────────────────────────
async def _fetch_klines(client: httpx.AsyncClient, symbol: str, limit: int) -> list[list]:
url = f"{settings.binance_rest_url}/api/v3/klines"
try:
resp = await client.get(url, params={
"symbol": symbol, "interval": "5m", "limit": limit
}, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Failed to fetch klines for %s: %s", symbol, exc)
return []
def _parse_candle(row: list) -> dict:
total_vol = float(row[5])
taker_buy = float(row[9])
return {
"time": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": total_vol,
"tbr": taker_buy / total_vol if total_vol > 0 else 0.5,
}
# ── Indicators ────────────────────────────────────────────────────────────────
def _compute_signal(candles: list[dict]) -> Optional[dict]:
"""
Returns signal dict if breakout signal fires on the most recent candle,
else None. Requires at least 60 candles.
"""
# Drop the last (current incomplete) candle — always use completed candles
candles = candles[:-1]
if len(candles) < 60:
return None
closes = [c["close"] for c in candles]
volumes = [c["volume"] for c in candles]
tbrs = [c["tbr"] for c in candles]
# ── Bollinger Bands (last BB_PERIOD candles) ──────────────────────────────
window = closes[-BB_PERIOD:]
bb_mid = sum(window) / BB_PERIOD
variance = sum((x - bb_mid) ** 2 for x in window) / BB_PERIOD
bb_std = variance ** 0.5
bb_upper = bb_mid + 2 * bb_std
bb_width = (4 * bb_std) / bb_mid if bb_mid > 0 else 0
# BB width percentile over last 60 candles
widths = []
for i in range(len(candles) - 60, len(candles)):
w_window = closes[max(0, i - BB_PERIOD + 1): i + 1]
if len(w_window) < BB_PERIOD:
continue
m = sum(w_window) / len(w_window)
s = (sum((x - m) ** 2 for x in w_window) / len(w_window)) ** 0.5
widths.append((4 * s) / m if m > 0 else 0)
if not widths:
return None
rank = sum(1 for w in widths if w < bb_width) / len(widths) * 100
in_squeeze = rank < BB_SQUEEZE_PCT
# ── Volume ────────────────────────────────────────────────────────────────
vol_ma = sum(volumes[-BB_PERIOD - 1:-1]) / BB_PERIOD
vol_spike = volumes[-1] > VOLUME_MULT * vol_ma if vol_ma > 0 else False
# ── Taker buy ratio ───────────────────────────────────────────────────────
tbr_ok = tbrs[-1] > TBR_THRESH
# ── Price above upper BB ──────────────────────────────────────────────────
above_bb = closes[-1] > bb_upper
if in_squeeze and vol_spike and tbr_ok and above_bb:
return {
"bb_width_pct": round(rank, 1),
"vol_mult": round(volumes[-1] / vol_ma, 2) if vol_ma > 0 else 0,
"tbr": round(tbrs[-1], 3),
"close": closes[-1],
"bb_upper": round(bb_upper, 4),
}
return None
def _btc_trend(candles: list[dict]) -> Optional[bool]:
"""True = BTC above 24h MA, False = below, None = not enough data."""
if len(candles) < BTC_TREND_MA:
return None
closes = [c["close"] for c in candles]
ma = sum(closes[-BTC_TREND_MA:]) / BTC_TREND_MA
return closes[-1] > ma
# ── Main poll loop ────────────────────────────────────────────────────────────
async def poll_funding_signal() -> None:
"""Called by APScheduler every 5 minutes."""
from app.ws.manager import manager # avoid circular import at module load
async with httpx.AsyncClient(timeout=20) as client:
# Fetch BTC with longer history for 24h trend
btc_rows = await _fetch_klines(client, CONTEXT_SYMBOL, BTC_TREND_MA + 10)
btc_candles = [_parse_candle(r) for r in btc_rows]
btc_up = _btc_trend(btc_candles)
# Fetch ETH + LINK
for symbol in WATCH_SYMBOLS:
rows = await _fetch_klines(client, symbol, CANDLE_LIMIT)
if not rows:
continue
candles = [_parse_candle(r) for r in rows]
sig = _compute_signal(candles)
if sig is None:
continue
# Deduplicate: don't fire same symbol twice within 30 minutes
now = datetime.now(timezone.utc)
last = _last_fired.get(symbol)
if last and (now - last).total_seconds() < 1800:
logger.info("Signal for %s deduplicated (too soon)", symbol)
continue
_last_fired[symbol] = now
alert = {
"type": "funding_signal",
"symbol": symbol,
"time": now.isoformat(),
"close": sig["close"],
"tbr": sig["tbr"],
"vol_mult": sig["vol_mult"],
"bb_pct": sig["bb_width_pct"],
"bb_upper": sig["bb_upper"],
"btc_trend": "↑ uptrend" if btc_up else ("↓ downtrend" if btc_up is False else "unknown"),
"enabled": _enabled,
}
_recent_signals.append(alert)
if _enabled:
logger.info("🚨 SIGNAL %s | price=%.4f tbr=%.2f vol=%.1fx btc=%s",
symbol, sig["close"], sig["tbr"], sig["vol_mult"],
alert["btc_trend"])
await manager.broadcast(alert)
else:
logger.info("Signal %s (monitor OFF — not broadcast)", symbol)
+74
View File
@@ -301,3 +301,77 @@ class HyperliquidTrader:
fill_price = float(filled_info.get("avgPx", mid) or mid)
logger.info("Closed %s position @ %.2f (size=%.5f)", coin, fill_price, total_sz)
return {"fill_price": fill_price, "already_closed": False}
async def reduce_position(self, asset: str, fraction: float) -> dict:
"""Partially close `fraction` (0<f<1) of the CURRENT open position
for `asset` with a reduce-only IOC order. Used by System-2 staged
de-risk. Returns {"fill_price": float, "closed_fraction": float,
"already_closed": bool}.
`closed_fraction` is the fraction of the position that was actually
closed (filled_size / pre-existing size) so the caller's PnL +
remaining bookkeeping stays exact even on partial fills.
"""
coin = asset.upper()
f = max(0.0, min(1.0, float(fraction)))
if f <= 0.0:
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
if f >= 1.0:
r = await self.close_position(asset)
r["closed_fraction"] = 0.0 if r.get("already_closed") else 1.0
return r
positions = await self.get_open_positions()
target = next((p for p in positions if p.get("coin") == coin), None)
if target is None:
logger.warning("reduce_position: no open %s on HL — already closed?", coin)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": True}
szi = float(target["szi"])
is_buy = szi < 0 # reducing a short → buy back
pre_size = abs(szi)
sz_dec = await self._get_sz_decimals(coin)
size = round(pre_size * f, sz_dec)
if size <= 0:
# Fraction rounds to nothing at this asset's size precision —
# treat as a no-op so the caller can advance the step without
# an erroneous PnL slice.
logger.warning("reduce_position: %s fraction %.3f rounds to 0 size (pre=%.6f)",
coin, f, pre_size)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
mid = await self._mid_price(coin)
slippage = 0.01
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
if raw_px >= 10000:
limit_px = float(round(raw_px))
elif raw_px >= 1000:
limit_px = round(raw_px, 1)
elif raw_px >= 100:
limit_px = round(raw_px, 2)
else:
limit_px = round(raw_px, 3)
logger.info("Reducing %s by %.0f%%: size=%.6f @ limit %.2f (pre=%.6f)",
coin, f * 100, size, limit_px, pre_size)
result = await self._run(
self._exchange.order,
coin, is_buy, size, limit_px,
{"limit": {"tif": "Ioc"}},
reduce_only=True,
)
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
status = statuses[0] if statuses else {}
if "error" in status:
raise ValueError(f"HL reduce rejected: {status['error']}")
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
raise ValueError(f"reduce_position {coin}: IOC returned 0 fill")
fill_price = float(filled_info.get("avgPx", mid) or mid)
closed_fraction = (total_sz / pre_size) if pre_size > 0 else 0.0
logger.info("Reduced %s: closed %.6f / %.6f (%.1f%%) @ %.2f",
coin, total_sz, pre_size, closed_fraction * 100, fill_price)
return {"fill_price": fill_price, "closed_fraction": closed_fraction,
"already_closed": False}
+223
View File
@@ -0,0 +1,223 @@
"""KOL post → structured signal extractor.
Takes a long-form post (Substack essay) or tweet and returns:
- summary: one Chinese sentence on what this post is about
- tickers: list of {ticker, action, conviction, quote}
action ∈ bullish | bearish | buy | sell | mention
- buy/sell → KOL explicitly states they bought/sold or are entering/exiting
- bullish/bearish → directional view without an explicit position statement
- mention → ticker appears but no clear stance (don't flood with these)
conviction ∈ 0.01.0
- 0.8+ : explicit, repeated, with sizing / timing
- 0.50.7 : clear view, no commitment
- <0.5 : passing reference
Quote is the shortest verbatim sentence supporting the call.
Uses the same Anthropic client style as analysis.py. Designed to be reused
by the Trump-post AI signal module (TODO #4) — same JSON shape, just with
different KOL context strings.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
ANALYSIS_VERSION = "kol-v1"
ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
_anthropic_client = None
_openai_client: Optional[AsyncOpenAI] = None
def _use_anthropic() -> bool:
return bool(settings.anthropic_api_key)
def _anth():
global _anthropic_client
if _anthropic_client is None:
import anthropic as _a
_anthropic_client = _a.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _anthropic_client
def _oai() -> AsyncOpenAI:
global _openai_client
if _openai_client is None:
_openai_client = AsyncOpenAI(
api_key=settings.ai_api_key,
base_url=settings.ai_base_url,
)
return _openai_client
SYSTEM_PROMPT = """You are an analyst extracting tradeable signals from crypto KOL posts.
The author is a known crypto KOL. Your job: distill what they said and which tokens they are talking about RIGHT NOW (not historical references).
Output **strict JSON only**, no markdown, no preface. Schema:
{
"summary": "<one sentence, ≤60 chars/字. If signal exists, state the author's current thesis. If no signal, describe the post topic. Match the post's primary language (中文文章用中文, English 用英文).>",
"tickers": [
{
"ticker": "<UPPERCASE symbol, e.g. BTC, ETH, HYPE, SOL>",
"action": "buy" | "sell" | "bullish" | "bearish" | "mention",
"conviction": <float 0.0-1.0>,
"quote": "<shortest verbatim sentence from the post supporting this call, ≤200 chars. Use the post's original language — do not translate.>"
}
]
}
Rules:
- If the post is macro commentary, news recap, or sponsored content with no specific token call, return tickers=[] and summary describing the topic.
- IGNORE historical price references ("BTC bottomed at $60k earlier this year") — these are context, not current calls.
- IGNORE advertising/sponsor sections — look for cues: "sponsor", "partner", "use code", "promo code", "this episode brought to you by", "ad", "广告", "赞助". Skip any ticker only mentioned inside such a section.
- buy/sell only when the author states a position action ("I bought", "we are long", "我们减仓了", "added to my bag"). Otherwise use bullish/bearish for directional views, or mention for passing references.
- Dedupe per ticker — at most one entry per symbol; pick the strongest action.
- Do NOT invent tickers. If you see "$XYZ" but unsure it's a real token, skip it.
- conviction: 0.8+ requires explicit + repeated + sized/timed view; 0.5-0.7 for clear directional view without commitment; <0.5 for passing references.
- Do not include fiat (USD/CNY/JPY) or stablecoins (USDT/USDC/DAI/FRAX) unless the post's main thesis is about them.
"""
USER_TEMPLATE = """Today is {today_utc}.
KOL handle: {handle}
Source: {source}
Title: {title}
Post body:
\"\"\"
{body}
\"\"\"
"""
def _truncate(text: str, max_chars: int = 24000) -> str:
"""Substack essays can be 50K+ chars. Haiku handles it but we cap to
control cost. Keep head + tail since conclusions often appear at the end."""
if len(text) <= max_chars:
return text
head = max_chars * 2 // 3
tail = max_chars - head
return text[:head] + "\n\n[...trimmed...]\n\n" + text[-tail:]
def _parse_json(raw: str) -> dict:
raw = raw.strip()
if raw.startswith("```"):
# strip fenced code block
raw = raw.split("\n", 1)[1] if "\n" in raw else raw
if raw.endswith("```"):
raw = raw.rsplit("```", 1)[0]
raw = raw.strip()
# Some models prepend "json" after the fence
if raw.startswith("json"):
raw = raw[4:].strip()
return json.loads(raw)
async def extract_kol_signal(
*,
handle: str,
source: str,
title: Optional[str],
body: str,
model: Optional[str] = None,
) -> dict:
"""Run the extractor. Returns {summary, tickers, model, version}.
Returns an empty-but-valid dict on parse/API failure rather than raising —
the caller stores the post regardless; an unanalyzed post can be retried.
"""
today_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d")
user = USER_TEMPLATE.format(
today_utc=today_utc,
handle=handle,
source=source,
title=title or "",
body=_truncate(body),
)
use_anth = _use_anthropic()
if model is None:
# KOL analysis is a daily batch job, not latency-sensitive. Use the
# higher-quality `ai_model` (DeepSeek v4 Pro / reasoning) rather than
# the live `ai_live_model` (flash) reserved for Trump real-time path.
model = ANTHROPIC_MODEL if use_anth else settings.ai_model
try:
if use_anth:
msg = await _anth().messages.create(
model=model,
max_tokens=1500,
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
# OpenAI-compatible (DeepSeek). Reasoning models need higher tokens
# + no temperature; flash/chat models are fine with both.
is_reasoning = any(x in model for x in ("pro", "reasoner", "r1", "think"))
kwargs = {"model": model, "messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user},
], "max_tokens": 4000 if is_reasoning else 1500}
if not is_reasoning:
kwargs["temperature"] = 0.1
# JSON mode — DeepSeek + OpenAI both support response_format.
# Eliminates fenced/preface parse failures. Skipped for reasoning
# models (some don't accept response_format alongside reasoning).
if not is_reasoning:
kwargs["response_format"] = {"type": "json_object"}
resp = await _oai().chat.completions.create(**kwargs)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json(raw)
except Exception as e:
logger.warning("kol_analysis extract failed for %s: %s", handle, e)
return {"summary": None, "tickers": [], "model": model,
"version": ANALYSIS_VERSION, "error": str(e)}
# Normalize
tickers = data.get("tickers") or []
cleaned = []
for t in tickers:
if not isinstance(t, dict):
continue
sym = (t.get("ticker") or "").strip().upper()
if not sym or len(sym) > 12:
continue
action = (t.get("action") or "mention").lower()
if action not in {"buy", "sell", "bullish", "bearish", "mention"}:
action = "mention"
try:
conv = float(t.get("conviction") or 0)
except (TypeError, ValueError):
conv = 0.0
conv = max(0.0, min(1.0, conv))
cleaned.append({
"ticker": sym,
"action": action,
"conviction": round(conv, 2),
"quote": (t.get("quote") or "")[:200],
})
return {
"summary": (data.get("summary") or "").strip() or None,
"tickers": cleaned,
"model": model,
"version": ANALYSIS_VERSION,
}
+285
View File
@@ -0,0 +1,285 @@
"""KOL talks-vs-trades cross-signal detector.
Compares B-tier content signals (Substack / Twitter post → ticker action)
against A-tier on-chain changes (wallet holding changes) for the same
KOL handle + ticker within a ±N-day window.
Two outcomes:
divergence — KOL says bullish but on-chain is selling (or vice versa).
On-chain action is the ground truth; word is noise/manipulation.
Conclusion = opposite of what was said.
alignment — Post and chain agree. Conviction is reinforced.
Conclusion = what was said (and done).
Logic:
post side → action ∈ {buy, bullish} = LONG intent
{sell, bearish} = SHORT intent
{mention} = skip (no clear view)
chain side → change_type ∈ {new_position, increased} = LONG action
{decreased, closed} = SHORT action
LONG intent + SHORT action → divergence, direction='short'
SHORT intent + LONG action → divergence, direction='long'
LONG intent + LONG action → alignment, direction='long'
SHORT intent + SHORT action → alignment, direction='short'
Run cadence: after each onchain poll (02:05 UTC). Also callable manually.
Idempotent: UniqueConstraint(post_id, change_id) prevents double-writes.
"""
from __future__ import annotations
import hashlib
import json
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import KolDivergence, KolHoldingChange, KolPost, KolWallet, Post, utcnow
logger = logging.getLogger(__name__)
# Match window: post and chain event must be within this many days of each other
WINDOW_DAYS = 7
# Only surface on-chain changes above this threshold (avoid noise from dust)
MIN_USD_CHANGE = 10_000
# Post actions that map to a directional view (skip 'mention')
_POST_LONG = {"buy", "bullish"}
_POST_SHORT = {"sell", "bearish"}
# On-chain actions that map to a direction
_CHAIN_LONG = {"new_position", "increased"}
_CHAIN_SHORT = {"decreased", "closed"}
# ── Telegram alert gating ────────────────────────────────────────────────────
# Only push divergences (the surprising "they say one thing, do another" case).
# Alignments are confirmations, not unique alpha — skip to keep volume sane.
ALERT_ON_TYPES = {"divergence"}
# Floor on the post's stated conviction. Without this, every "mention"-like
# soft view that happens to coincide with a buy/sell would page users.
ALERT_MIN_POST_CONVICTION = 0.5
# Minimum USD on the on-chain side to bother alerting. The 10K floor in
# MIN_USD_CHANGE keeps dust out of the table; this stricter floor avoids
# alerting on small rebalances by big wallets.
ALERT_MIN_USD = 50_000
def _post_for_divergence(handle: str, ticker: str, direction: str,
post_action: str, chain_action: str,
conviction: float, change_id: int,
usd_after: Optional[float],
days_apart: float) -> Post:
"""Build a Post row carrying a KOL divergence as an actionable signal.
`direction` is the CONCLUSION direction from _classify: 'long' → buy,
'short' → short. ai_confidence is derived from post conviction so the
user's min_confidence floor in TelegramBinding gates noise.
"""
signal = "buy" if direction == "long" else "short"
usd_str = f"${(usd_after or 0)/1000:.0f}K" if usd_after else "?"
text = (
f"KOL {handle} says {post_action.upper()} {ticker}"
f"but on-chain shows {chain_action} ({usd_str}, Δ{days_apart:.1f}d). "
f"Following the chain (the trade, not the talk): {signal.upper()} {ticker}."
)
# external_id must be unique-per-source. Use the change_id as the dedupe
# key — at most one Post per (kol, ticker, chain-event).
ext = hashlib.md5(f"kol_divergence:{handle}:{ticker}:{change_id}".encode()).hexdigest()
return Post(
external_id=ext,
text=text,
source="kol_divergence",
published_at=datetime.now(timezone.utc).replace(tzinfo=None),
sentiment="bullish" if signal == "buy" else "bearish",
ai_confidence=int(round(conviction * 100)),
relevant=True,
signal=signal,
target_asset=ticker,
category=f"kol_divergence_{signal}",
analysis_version="kol_divergence_v1",
prefilter_reason="kol_divergence",
)
def _classify(post_action: str, chain_action: str) -> Optional[tuple[str, str]]:
"""Returns (signal_type, direction) or None if no meaningful pair."""
if post_action in _POST_LONG:
if chain_action in _CHAIN_LONG:
return "alignment", "long"
if chain_action in _CHAIN_SHORT:
return "divergence", "short"
elif post_action in _POST_SHORT:
if chain_action in _CHAIN_LONG:
return "divergence", "long"
if chain_action in _CHAIN_SHORT:
return "alignment", "short"
return None
async def run_divergence_scan(
lookback_days: int = 30,
) -> list[dict]:
"""Scan recent posts × on-chain changes, write new KolDivergence rows.
Returns list of newly written rows as dicts (for logging / API return).
Already-stored pairs are silently skipped (idempotent).
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=lookback_days)
results: list[dict] = []
# Posts we created this scan — notify_signal runs AFTER commit so the
# dispatcher's separate DB session can read the row.
alert_post_ids: list[int] = []
async with AsyncSessionLocal() as session:
# ── 1. Load recent posts that have directional ticker signals ──────
posts = (await session.execute(
select(KolPost)
.where(KolPost.published_at >= since)
.where(KolPost.tickers_json.is_not(None))
)).scalars().all()
# ── 2. Load recent on-chain changes (keyed by handle via wallet) ───
changes_rows = (await session.execute(
select(KolHoldingChange, KolWallet.handle)
.join(KolWallet, KolHoldingChange.wallet_id == KolWallet.id)
.where(KolHoldingChange.detected_at >= since)
)).all()
# Pre-load all existing (post_id, change_id) pairs so we can skip
# duplicates client-side instead of relying on a UniqueConstraint
# violation. Catching the violation would force a session rollback
# that wipes ALL pending writes (not just the offending one) — a
# subtle data-loss bug. Pre-check is cheap (the table stays small).
existing_pairs: set[tuple[int, int]] = {
(pid, cid)
for (pid, cid) in (await session.execute(
select(KolDivergence.post_id, KolDivergence.change_id)
)).all()
}
# Index changes by (handle, ticker) → list[KolHoldingChange]
chain_index: dict[tuple[str, str], list[tuple[KolHoldingChange, str]]] = defaultdict(list)
for change, handle in changes_rows:
# Skip dust moves
usd_delta = abs((change.usd_after or 0) - (change.usd_before or 0))
if usd_delta < MIN_USD_CHANGE and (change.usd_after or 0) < MIN_USD_CHANGE:
continue
chain_index[(handle, change.ticker.upper())].append((change, handle))
# ── 3. Match posts → changes ───────────────────────────────────────
for post in posts:
try:
tickers = json.loads(post.tickers_json or "[]")
except json.JSONDecodeError:
continue
for t in tickers:
post_action = (t.get("action") or "").lower()
if post_action not in (_POST_LONG | _POST_SHORT):
continue # skip 'mention'
ticker = (t.get("ticker") or "").upper()
if not ticker:
continue
candidates = chain_index.get((post.kol_handle, ticker), [])
for change, handle in candidates:
# Time-window check
post_dt = post.published_at
change_dt = change.detected_at
days_apart = abs((change_dt - post_dt).total_seconds()) / 86400
if days_apart > WINDOW_DAYS:
continue
result = _classify(post_action, change.change_type)
if result is None:
continue
signal_type, direction = result
# Idempotency: skip if (post_id, change_id) already stored
pair_key = (post.id, change.id)
if pair_key in existing_pairs:
continue
existing_pairs.add(pair_key)
conviction = float(t.get("conviction") or 0)
row = KolDivergence(
handle = post.kol_handle,
ticker = ticker,
post_id = post.id,
post_action = post_action,
post_conviction = conviction,
post_at = post_dt,
change_id = change.id,
onchain_action = change.change_type,
usd_before = change.usd_before,
usd_after = change.usd_after,
onchain_at = change_dt,
signal_type = signal_type,
direction = direction,
days_apart = round(days_apart, 2),
)
session.add(row)
# Emit a Post for Telegram fan-out — but only for the
# interesting case (divergences) with enough conviction
# and chain-size to be worth a push. See ALERT_* tunables.
if (signal_type in ALERT_ON_TYPES
and conviction >= ALERT_MIN_POST_CONVICTION
and (change.usd_after or 0) >= ALERT_MIN_USD):
alert_post = _post_for_divergence(
handle=post.kol_handle, ticker=ticker,
direction=direction, post_action=post_action,
chain_action=change.change_type,
conviction=conviction, change_id=change.id,
usd_after=change.usd_after, days_apart=days_apart,
)
session.add(alert_post)
await session.flush() # populate alert_post.id
alert_post_ids.append(alert_post.id)
info = {
"handle": post.kol_handle,
"ticker": ticker,
"signal_type": signal_type,
"direction": direction,
"post_action": post_action,
"onchain_action": change.change_type,
"days_apart": round(days_apart, 2),
"usd_after": change.usd_after,
}
results.append(info)
emoji = "⚠️" if signal_type == "divergence" else ""
logger.info(
"[kol_divergence] %s %s %s: says %s, onchain %s%s (%s) Δ%.1fd",
emoji, post.kol_handle, ticker,
post_action, change.change_type,
signal_type, direction, days_apart,
)
await session.commit()
# Fire Telegram fan-out AFTER commit so _dispatch's own session can
# actually see the rows. _dispatch only needs the post_id, so we skip
# notify_signal's Post-object wrapper and schedule it directly.
if alert_post_ids:
try:
import asyncio
from app.services.telegram import _dispatch
for pid in alert_post_ids:
asyncio.create_task(_dispatch(pid))
except Exception as exc:
logger.warning("[kol_divergence] notify_signal failed: %s", exc)
logger.info("[kol_divergence] scan done: %d new pairs written, %d alerts queued",
len(results), len(alert_post_ids))
return results
+531
View File
@@ -0,0 +1,531 @@
"""KOL A-tier: daily on-chain holdings snapshot + change detection.
Data sources (all free):
Hyperliquid public API — No key. Perp positions + mark prices for any
HL-listed asset (BTC/ETH/SOL/HYPE/...).
Endpoint: POST /info {"type":"clearinghouseState"}
Prices: POST /info {"type":"allMids"}
Etherscan API — Free key (etherscan.io/register, email only).
ERC-20 token balances for any Ethereum address.
Env: ETHERSCAN_API_KEY. Skip ETH wallets if unset.
CoinGecko (no key) — Fallback price for tokens not listed on HL.
Free tier: 30 req/min, batch by contract address.
Pricing priority per token:
1. HL allMids (by symbol, e.g. "ETH" → $2025)
2. CoinGecko by contract address (Ethereum only)
3. Skip (price unknown → usd_value=0, excluded from snapshot)
Daily flow (run_onchain_poll at 02:00 UTC):
For each HL wallet → fetch HL positions → snapshot → diff
For each ETH wallet → fetch Etherscan ERC-20 list → price each token → snapshot → diff
Diffs with usd_after > $50k (new) or ±25% change written to kol_holding_changes.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from typing import 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 KolHoldingChange, KolHoldingSnapshot, KolWallet, utcnow
logger = logging.getLogger(__name__)
HL_API_URL = "https://api.hyperliquid.xyz/info"
ETHERSCAN_API_URL = "https://api.etherscan.io/v2/api"
COINGECKO_URL = "https://api.coingecko.com/api/v3"
# Stablecoins — ignored in all snapshots
STABLES = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "USDE", "FRAX", "LUSD", "CRVUSD"}
# Change detection thresholds
_NEW_POSITION_MIN_USD = 50_000 # new token > $50k → alert
_CHANGE_PCT_THRESHOLD = 25.0 # ±25% USD move → alert
_CLOSED_MIN_USD = 10_000 # closed position must have been > $10k to report
# ── Price layer ───────────────────────────────────────────────────────────────
_hl_prices: dict[str, float] = {} # symbol → USD, refreshed each poll
_hl_prices_fetched_at: float = 0.0
async def _get_hl_prices() -> dict[str, float]:
"""Fetch all HL mark prices (free, no auth). Cached for 5 min per poll cycle.
Returns empty dict on network failure — callers fall back to CoinGecko."""
global _hl_prices, _hl_prices_fetched_at
now = time.time()
if now - _hl_prices_fetched_at < 300 and _hl_prices:
return _hl_prices
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
raw = r.json() # {"BTC": "74541.0", "ETH": "2025.0", ...}
_hl_prices = {sym: float(px) for sym, px in raw.items()}
_hl_prices_fetched_at = now
logger.info("[kol_onchain] HL prices loaded: %d assets", len(_hl_prices))
except Exception as e:
logger.warning("[kol_onchain] HL price fetch failed (%s) — falling back to CoinGecko", e)
# Don't overwrite a previous good cache on transient failure
if not _hl_prices:
_hl_prices = {}
return _hl_prices
# CoinGecko symbol→id map for common crypto assets (fallback when HL unavailable)
_CG_SYMBOL_IDS = {
"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana",
"BNB": "binancecoin", "AVAX": "avalanche-2", "MATIC": "matic-network",
"ARB": "arbitrum", "OP": "optimism", "LINK": "chainlink",
"UNI": "uniswap", "AAVE": "aave", "MKR": "maker",
"HYPE": "hyperliquid", "ENA": "ethena", "PENDLE": "pendle",
"WLD": "worldcoin-wld", "JUP": "jupiter-exchange-solana",
"WBTC": "wrapped-bitcoin", "STETH": "staked-ether",
"EETH": "ether-fi-staked-eth", "WEETH": "wrapped-eeth",
}
async def _coingecko_prices_by_symbol(symbols: list[str]) -> dict[str, float]:
"""Batch price lookup by CoinGecko coin ID. No key needed."""
ids_needed = {s: _CG_SYMBOL_IDS[s] for s in symbols if s in _CG_SYMBOL_IDS}
if not ids_needed:
return {}
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"{COINGECKO_URL}/simple/price", params={
"ids": ",".join(ids_needed.values()),
"vs_currencies": "usd",
})
if r.status_code != 200:
return {}
data = r.json()
# Invert: coin_id → price, then map back to symbol
id_to_price = {v: data.get(v, {}).get("usd", 0) for v in ids_needed.values()}
return {sym: id_to_price[cid] for sym, cid in ids_needed.items() if id_to_price.get(cid)}
except Exception as e:
logger.warning("[kol_onchain] CoinGecko symbol lookup failed: %s", e)
return {}
async def _coingecko_prices_by_contract(
contract_addresses: list[str],
) -> dict[str, float]:
"""Batch price lookup by Ethereum contract address. No key needed.
Chunks into batches of 25 (CoinGecko free tier limit).
Returns {contract_addr_lower: usd_price}."""
if not contract_addresses:
return {}
result: dict[str, float] = {}
addrs_lower = [a.lower() for a in contract_addresses[:100]]
# CoinGecko free tier caps at ~25 addresses per request
chunk_size = 25
async with httpx.AsyncClient(timeout=15.0) as c:
for i in range(0, len(addrs_lower), chunk_size):
chunk = addrs_lower[i:i + chunk_size]
try:
r = await c.get(
f"{COINGECKO_URL}/simple/token_price/ethereum",
params={"contract_addresses": ",".join(chunk), "vs_currencies": "usd"},
)
if r.status_code != 200:
logger.warning("[kol_onchain] CoinGecko %s: %s", r.status_code, r.text[:80])
continue
data = r.json()
result.update({addr: info["usd"] for addr, info in data.items() if "usd" in info})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko chunk failed: %s", e)
return result
# ── Hyperliquid: perp positions ───────────────────────────────────────────────
async def _fetch_hl_positions(address: str) -> tuple[list[dict], float]:
"""Query HL clearinghouseState. Returns (holdings, account_value_usd).
holdings: [{ticker, amount, usd_value, chain, side}]
"""
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.post(HL_API_URL, json={"type": "clearinghouseState", "user": address})
r.raise_for_status()
data = r.json()
prices = await _get_hl_prices()
holdings = []
for pos in data.get("assetPositions", []):
p = pos.get("position", {})
coin = p.get("coin", "")
if not coin:
continue
size = float(p.get("szi", 0))
if abs(size) < 1e-8:
continue
price = prices.get(coin, float(p.get("entryPx") or 0))
notional = abs(size) * price
if notional < 1:
continue
holdings.append({
"ticker": coin,
"amount": abs(size),
"usd_value": round(notional, 2),
"chain": "hl",
"side": "long" if size > 0 else "short",
})
margin = data.get("marginSummary", {})
account_value = float(margin.get("accountValue", 0))
return holdings, account_value
# ── Etherscan: ERC-20 balances ────────────────────────────────────────────────
async def _fetch_eth_holdings(address: str) -> tuple[list[dict], float]:
"""Fetch ERC-20 + ETH holdings via Etherscan free tier.
Free-tier strategy (no Pro needed):
Step 1: GET account/balance → native ETH amount
Step 2: GET account/tokentx (recent) → discover which ERC-20 contracts
the wallet has interacted with
Step 3: GET account/tokenbalance → current balance per contract
Step 4: Price via HL allMids, then CoinGecko by contract address fallback
Etherscan free limit: 5 req/s, ~100k req/day — well within budget for
daily snapshots of a handful of KOL wallets.
"""
if not settings.etherscan_api_key:
return [], 0.0
prices = await _get_hl_prices()
key = settings.etherscan_api_key
async with httpx.AsyncClient(timeout=20.0) as c:
# ── Step 1: ETH native balance ───────────────────────────────────
eth_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "balance",
"address": address, "tag": "latest", "apikey": key,
})
eth_data = eth_r.json()
# ── Step 2: ERC-20 tx history → unique (contract, symbol, decimals) ─
# offset=200 is enough to cover all tokens a KOL holds. Vitalik-scale
# addresses with 100k+ txs can still timeout — real KOL wallets are far smaller.
tx_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokentx",
"address": address, "page": "1", "offset": "200",
"sort": "desc", "apikey": key,
}, timeout=30.0)
tx_data = tx_r.json()
# Collect unique contracts from tx history
seen: dict[str, dict] = {} # contract → {symbol, decimals}
txs = tx_data.get("result") or []
if isinstance(txs, list):
for tx in txs:
contract = (tx.get("contractAddress") or "").lower()
if not contract or contract in seen:
continue
symbol = (tx.get("tokenSymbol") or "").upper()
if not symbol or symbol in STABLES:
continue
try:
decimals = int(tx.get("tokenDecimal") or 18)
except ValueError:
decimals = 18
seen[contract] = {"symbol": symbol, "decimals": decimals}
# ── Step 3: current balance per contract ─────────────────────────────
holdings: list[dict] = []
unpriced: list[str] = [] # contracts needing CoinGecko
contract_meta: dict[str, dict] = {}
# Rate-limit: Etherscan free = 5 req/s. Sleep 0.22s between calls to stay
# under burst limit even when looping 80 tokens × 4 wallets in one poll.
async with httpx.AsyncClient(timeout=20.0) as c:
for contract, meta in list(seen.items())[:80]: # cap at 80 tokens
await asyncio.sleep(0.22)
bal_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokenbalance",
"contractaddress": contract, "address": address,
"tag": "latest", "apikey": key,
})
bal_data = bal_r.json()
if bal_data.get("message") != "OK":
continue
raw = int(bal_data.get("result") or 0)
if raw == 0:
continue
balance = raw / (10 ** meta["decimals"])
symbol = meta["symbol"]
hl_price = prices.get(symbol)
if hl_price and hl_price > 0:
usd_value = round(balance * hl_price, 2)
if usd_value >= 500:
holdings.append({
"ticker": symbol, "amount": round(balance, 6),
"usd_value": usd_value, "chain": "ethereum",
"contract": contract,
})
else:
unpriced.append(contract)
contract_meta[contract] = {
"ticker": symbol, "amount": round(balance, 6),
"chain": "ethereum", "contract": contract,
}
# ── Step 4a: ETH native ──────────────────────────────────────────────
eth_raw = int(eth_data.get("result") or 0)
eth_balance = eth_raw / 1e18
if eth_balance > 0.001:
eth_price = prices.get("ETH", 0.0)
# Fallback: CoinGecko by symbol if HL unavailable
if not eth_price:
cg_sym = await _coingecko_prices_by_symbol(["ETH"])
eth_price = cg_sym.get("ETH", 0.0)
eth_usd = round(eth_balance * eth_price, 2)
if eth_usd >= 100:
holdings.append({
"ticker": "ETH", "amount": round(eth_balance, 6),
"usd_value": eth_usd, "chain": "ethereum",
})
# ── Step 4b: CoinGecko fallback for unpriced tokens ──────────────────
if unpriced:
try:
cg_prices = await _coingecko_prices_by_contract(unpriced)
for contract, price in cg_prices.items():
meta = contract_meta.get(contract)
if not meta or price <= 0:
continue
usd_value = round(meta["amount"] * price, 2)
if usd_value >= 500:
holdings.append({**meta, "usd_value": usd_value})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko fallback failed: %s", e)
# Merge duplicate tickers (different contracts, same symbol) by summing USD value
merged: dict[str, dict] = {}
for h in holdings:
t = h["ticker"]
if t in merged:
merged[t]["usd_value"] = round(merged[t]["usd_value"] + h["usd_value"], 2)
merged[t]["amount"] = round(merged[t]["amount"] + h["amount"], 6)
else:
merged[t] = dict(h)
holdings = list(merged.values())
total_usd = round(sum(h["usd_value"] for h in holdings), 2)
logger.info("[kol_onchain] %s ETH holdings: %d tokens, $%.0f total",
address[:10], len(holdings), total_usd)
return holdings, total_usd
# ── Snapshot + diff ───────────────────────────────────────────────────────────
def _diff_holdings(
prev: list[dict], curr: list[dict], wallet_id: int,
) -> list[KolHoldingChange]:
# SUM by ticker — earlier snapshots may contain duplicate ticker rows
# (different contracts, same symbol e.g. two APE tokens). Dict comprehension
# would silently drop one and falsely flag +1900% diffs the next day.
def _sum_by_ticker(rows: list[dict]) -> dict[str, float]:
agg: dict[str, float] = {}
for h in rows:
t = h.get("ticker")
if not t:
continue
agg[t] = agg.get(t, 0.0) + float(h.get("usd_value") or 0)
return agg
prev_map = _sum_by_ticker(prev)
curr_map = _sum_by_ticker(curr)
now = utcnow()
changes = []
for ticker in set(prev_map) | set(curr_map):
before = prev_map.get(ticker, 0.0)
after = curr_map.get(ticker, 0.0)
if before == 0 and after >= _NEW_POSITION_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="new_position", usd_before=0, usd_after=after,
))
elif after == 0 and before >= _CLOSED_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="closed", usd_before=before, usd_after=0,
pct_change=-100.0,
))
elif before > 0 and after > 0:
pct = (after - before) / before * 100
if abs(pct) >= _CHANGE_PCT_THRESHOLD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="increased" if pct > 0 else "decreased",
usd_before=before, usd_after=after, pct_change=round(pct, 1),
))
return changes
async def _snapshot_wallet(
session: AsyncSession,
wallet: KolWallet,
holdings: list[dict],
total_usd: float,
source: str,
) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# Skip if already snapshotted today
existing = (await session.execute(
select(KolHoldingSnapshot).where(
KolHoldingSnapshot.wallet_id == wallet.id,
KolHoldingSnapshot.snapshot_date == today,
)
)).scalar_one_or_none()
if existing:
return {"wallet_id": wallet.id, "status": "already_snapshotted"}
# Previous snapshot for diff
prev_row = (await session.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet.id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(1)
)).scalar_one_or_none()
prev_holdings = json.loads(prev_row.holdings_json) if prev_row else []
# Store snapshot (strip contract addresses to keep JSON lean)
clean_holdings = [{k: v for k, v in h.items() if k != "contract"} for h in holdings]
snap = KolHoldingSnapshot(
wallet_id=wallet.id,
snapshot_date=today,
holdings_json=json.dumps(clean_holdings, ensure_ascii=False),
total_usd=total_usd,
source=source,
)
session.add(snap)
# Detect and store changes
changes = _diff_holdings(prev_holdings, holdings, wallet.id)
for c in changes:
session.add(c)
logger.info(
"[kol_onchain] %s %s %s $%.0f→$%.0f",
wallet.handle, c.change_type, c.ticker,
c.usd_before or 0, c.usd_after or 0,
)
await session.flush()
return {
"handle": wallet.handle,
"chain": wallet.chain,
"holdings": len(holdings),
"total_usd": total_usd,
"changes": len(changes),
"source": source,
}
# ── Poll entry points ─────────────────────────────────────────────────────────
async def run_hl_poll() -> list[dict]:
"""Poll HL perp positions for all chain='hl' wallets."""
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "hl", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no HL wallets configured")
return []
for wallet in wallets:
try:
holdings, total = await _fetch_hl_positions(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "hl")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] HL fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] HL poll done: %s", results)
return results
async def run_eth_poll() -> list[dict]:
"""Poll Ethereum ERC-20 holdings for all chain='ethereum' wallets.
No-op if ETHERSCAN_API_KEY not set."""
if not settings.etherscan_api_key:
logger.debug("[kol_onchain] ETHERSCAN_API_KEY not set — skipping ETH poll")
return []
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "ethereum", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no Ethereum wallets configured")
return []
# Pre-warm HL prices once for the whole batch
await _get_hl_prices()
for wallet in wallets:
try:
holdings, total = await _fetch_eth_holdings(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "etherscan")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] ETH fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] ETH poll done: %s", results)
return results
async def run_onchain_poll() -> dict:
"""Combined entry point for APScheduler. Runs HL + ETH polls."""
hl = await run_hl_poll()
eth = await run_eth_poll()
return {"hl": hl, "eth": eth}
async def seed_wallets(entries: list[tuple]) -> int:
"""Bulk-insert (handle, chain, address, label, source_url) tuples, skip dupes."""
count = 0
async with AsyncSessionLocal() as session:
for handle, chain, address, label, source_url in entries:
existing = (await session.execute(
select(KolWallet).where(
KolWallet.chain == chain,
KolWallet.address == address.lower(),
)
)).scalar_one_or_none()
if existing:
continue
session.add(KolWallet(
handle=handle, chain=chain,
address=address.lower(),
label=label, source_url=source_url,
))
count += 1
await session.commit()
return count
+383
View File
@@ -0,0 +1,383 @@
"""KOL Substack RSS ingester.
Polls each tracked KOL's Substack feed, dedupes by URL, stores raw post,
then hands off to kol_analysis.extract_kol_signal and writes the result
back onto the same row.
Substack RSS embeds the full post HTML in <content:encoded>. We strip HTML
to plain text before storage + analysis. Hayes posts are typically 50K+
chars of body — the extractor truncates internally.
Daily cadence is plenty (Hayes posts ~monthly, Substack updates feed within
minutes of publish). Call run_substack_poll() from the APScheduler.
"""
from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Iterable, Optional
import feedparser
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import KolPost, utcnow
from app.services import kol_analysis
logger = logging.getLogger(__name__)
# Curated B-tier KOL feeds. Handle is the canonical key. `source` is the
# DB column ("substack" | "podcast" | "blog"); empty defaults to "substack"
# for legacy entries. Twitter-only KOLs come in a separate ingester.
#
# When adding a new feed:
# 1. curl + grep '<item>' to confirm it returns entries.
# 2. Inspect entry summary/content length — AI extraction needs ≥300 chars
# 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.
KOL_FEEDS: list[dict] = [
# ── Substack essayists (long-form thesis pieces) ─────────────────────
{
"handle": "cryptohayes",
"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.).
{
"handle": "placeholder",
"display_name": "Placeholder VC",
"feed_url": "https://www.placeholder.vc/blog?format=rss",
},
# 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.
{
"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",
},
# 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.
{
"handle": "delphi",
"display_name": "Delphi Digital (Podcast)",
"feed_url": "https://rss.buzzsprout.com/2609274.rss",
},
# ── Newly added (verified live + active) ─────────────────────────────
# Anthony Pompliano — Pomp Investments. Active monthly+ on macro/crypto.
{
"handle": "pomp",
"display_name": "Anthony Pompliano (Pomp Letter)",
"feed_url": "https://pomp.substack.com/feed",
},
# The DeFi Edge — researcher who writes 1-2 deep dives per month on
# tokens / sectors. Real thesis + position-aware framing.
{
"handle": "thedefiedge",
"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",
},
# ── DeFi journalism (Substack-style RSS) ─────────────────────────────
# The Defiant — Camila Russo's team. DeFi-focused news with frequent
# protocol + token mentions. Free RSS, ~100 entries.
{
"handle": "thedefiant",
"display_name": "The Defiant",
"feed_url": "https://www.thedefiant.io/api/feed",
"source": "blog",
},
# ── Major crypto podcasts (Megaphone / Simplecast RSS) ───────────────
# Show notes are 1-6K chars — long enough for AI to pull out tickers
# and theses. Bootstrap is capped at max_new=20/run so a 600-episode
# backlog spreads across ~30 days.
#
# Empire (Blockworks) — Jason Yanowitz + Santiago Roel Santos. Weekly
# crypto+macro interviews. Show notes name protocols + price calls.
{
"handle": "empire",
"display_name": "Empire Podcast (Blockworks)",
"feed_url": "https://feeds.megaphone.fm/empire",
"source": "podcast",
},
# 0xResearch (Blockworks) — Boccaccio + Dan Smith. Protocol research
# deep-dives, real revenue/usage discussion. Highest signal density of
# the Blockworks shows.
{
"handle": "0xresearch",
"display_name": "0xResearch (Blockworks)",
"feed_url": "https://feeds.megaphone.fm/0xresearch",
"source": "podcast",
},
# Lightspeed (Blockworks) — Mert Mumtaz (Helius CEO) + Garrett Harper.
# Solana ecosystem focus — SOL, JUP, JTO, PUMP, validator economics.
{
"handle": "lightspeed",
"display_name": "Lightspeed (Solana, Blockworks)",
"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).
{
"handle": "unchained",
"display_name": "Unchained (Laura Shin)",
"feed_url": "https://www.unchainedcrypto.com/feed/",
"source": "podcast",
},
# Bankless podcast — Ryan Sean Adams + David Hoffman. ETH-focused but
# covers all majors. 4K char show notes. Largest crypto-native podcast.
# NOTE: previous feed `simplecast.com/MLdpYXYI` was actually Robert
# Breedlove's "What is Money" show — wrong feed. libsyn is canonical.
{
"handle": "bankless",
"display_name": "Bankless Podcast",
"feed_url": "https://bankless.libsyn.com/rss",
"source": "podcast",
},
# Bell Curve (Multicoin) — Mike Ippolito + Jason Yanowitz + Myles Snider.
# 350 episodes, weekly macro+crypto roundup. Multicoin's portfolio shows
# up frequently (SOL, JTO, JUP, Helium, Render). 1.2K show notes.
{
"handle": "bellcurve",
"display_name": "Bell Curve (Multicoin)",
"feed_url": "https://feeds.megaphone.fm/bellcurve",
"source": "podcast",
},
# The Scoop (The Block) — Frank Chaparro interviews founders + traders.
# 110 episodes, ~700 char show notes. Strong on infrastructure/exchange
# deals (Hyperliquid, Coinbase, Binance dynamics).
{
"handle": "thescoop",
"display_name": "The Scoop (The Block)",
"feed_url": "https://feeds.megaphone.fm/the-scoop",
"source": "podcast",
},
# ── Research newsletters (long-form, high-signal) ────────────────────
# Reflexivity Research — Will Clemente + Sam Rule. On-chain BTC analysis
# and macro pieces. 20 entries, 8K char essays. Concrete on-chain calls.
{
"handle": "reflexivity",
"display_name": "Reflexivity Research (Will Clemente)",
"feed_url": "https://reflexivityresearch.substack.com/feed",
},
# TFTC — Marty Bent's "Bitcoin Brief" newsletter (also a podcast feed).
# 11K char issues, daily Bitcoin + policy. Pure BTC focus but covers
# legislation/macro that moves BTC.
{
"handle": "tftc",
"display_name": "TFTC / Bitcoin Brief (Marty Bent)",
"feed_url": "https://tftc.io/feed",
"source": "blog",
},
]
# Back-compat alias — older imports referenced SUBSTACK_KOLS.
SUBSTACK_KOLS = KOL_FEEDS
_TAG_RE = re.compile(r"<[^>]+>")
_WHITESPACE_RE = re.compile(r"[ \t]+")
_BLANKLINES_RE = re.compile(r"\n{3,}")
def _html_to_text(html: str) -> str:
"""Cheap HTML → text. Good enough for Substack which uses simple markup;
if we ever need real parsing, swap to bs4 (not currently a dep)."""
# Newlines for block-level closes so paragraphs survive
s = re.sub(r"</(p|div|h[1-6]|li|br)\s*>", "\n", html, flags=re.I)
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
s = _TAG_RE.sub("", s)
# HTML entities — feedparser usually decodes these but be safe
s = (s.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", '"').replace("&#8217;", "'").replace("&#8220;", '"')
.replace("&#8221;", '"').replace("&nbsp;", " "))
s = _WHITESPACE_RE.sub(" ", s)
s = _BLANKLINES_RE.sub("\n\n", s)
return s.strip()
def _entry_body(entry) -> str:
"""Pull the richest body field available from a feedparser entry."""
if entry.get("content"):
# content is a list of {value, type}
return entry["content"][0].get("value", "") or ""
return entry.get("summary") or entry.get("description") or ""
def _parse_pub(entry) -> Optional[datetime]:
raw = entry.get("published") or entry.get("updated")
if not raw:
return None
try:
dt = parsedate_to_datetime(raw)
# Always normalize to naive UTC. Previously used .astimezone() which
# converts to *local* time → 8-hour skew when server runs in CST.
# Affects: divergence window matching, digest 'since' filter, UI display.
if dt.tzinfo:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except Exception:
return None
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"})
r.raise_for_status()
parsed = feedparser.parse(r.content)
return list(parsed.entries or [])
async def _ingest_kol(
session: AsyncSession,
kol: dict,
*,
analyze: bool = True,
max_new: int = 20,
) -> dict:
"""Ingest one KOL feed. max_new caps first-run cost for high-volume feeds
(e.g. Delphi podcast has 478 episodes). Subsequent runs only see truly new
entries so the cap rarely triggers after bootstrap."""
handle = kol["handle"]
feed_url = kol["feed_url"]
src = kol.get("source") or "substack" # substack | podcast | blog
stats = {"handle": handle, "source": src,
"new": 0, "skipped": 0, "analyzed": 0, "errors": 0}
try:
entries = await _fetch_feed(feed_url)
except Exception as e:
logger.warning("[kol_substack] fetch failed for %s: %s", handle, e)
stats["errors"] += 1
return stats
for entry in entries:
if stats["new"] >= max_new:
logger.info("[kol_substack] %s hit max_new=%d cap; rest deferred to next run",
handle, max_new)
break
# Podcast feeds (Buzzsprout, etc.) have no <link>; use enclosure URL or entry id.
url = entry.get("link")
if not url:
enclosures = entry.get("enclosures") or []
if enclosures:
url = enclosures[0].get("href")
if not url:
url = entry.get("id") # e.g. "Buzzsprout-19123172"
if not url:
continue
# Dedupe by (source, external_id=url). We also check against the
# legacy "substack" source so podcast/blog re-tags don't double-insert
# entries the old code already wrote.
existing = await session.execute(
select(KolPost).where(
KolPost.source.in_([src, "substack"]),
KolPost.external_id == url,
)
)
row = existing.scalar_one_or_none()
if row is not None:
stats["skipped"] += 1
continue
html = _entry_body(entry)
text = _html_to_text(html)
if not text:
continue
pub = _parse_pub(entry) or utcnow()
title = entry.get("title") or None
body_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
row = KolPost(
kol_handle=handle,
source=src,
external_id=url,
url=url,
title=title,
published_at=pub,
raw_text=text,
content_hash=body_hash,
)
session.add(row)
await session.flush() # get id for logging
stats["new"] += 1
logger.info("[kol_substack] new post %s id=%s title=%r", handle, row.id, title)
if analyze:
try:
result = await kol_analysis.extract_kol_signal(
handle=handle,
source=src,
title=title,
body=text,
)
if result.get("error"):
stats["errors"] += 1
else:
import json as _json
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")
stats["analyzed"] += 1
except Exception as e:
logger.warning("[kol_substack] analysis failed for %s post %s: %s",
handle, row.id, e)
stats["errors"] += 1
await session.commit()
return stats
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."""
results = []
async with AsyncSessionLocal() as session:
for kol in KOL_FEEDS:
stats = await _ingest_kol(session, kol, analyze=analyze)
results.append(stats)
logger.info("[kol_substack] poll done: %s", results)
return results
+360
View File
@@ -0,0 +1,360 @@
"""
Market data abstraction — pluggable candle sources.
Currently 2 providers:
- Binance : Free public REST, broad coverage of major coins.
- Hyperliquid : SAME venue as execution. Covers HL-native perps that
Binance doesn't list (TRUMP, HYPE, PURR, etc.) and
gives us mark-price-consistent data for those assets.
Routing rule:
- Assets in HL_NATIVE_ASSETS → Hyperliquid (no Binance pair exists)
- Everything else → Binance (better history, no rate friction)
Override per-call by selecting a provider explicitly:
await BinanceCandles().fetch_4h("BTC", days=30)
await HyperliquidCandles().fetch_4h("HYPE", days=30)
# Or auto-route:
src = for_asset("HYPE") # → HyperliquidCandles
candles = await src.fetch_4h("HYPE", days=30)
Normalized candle shape (returned by ALL providers):
{"time_ms": int, "open": float, "high": float, "low": float,
"close": float, "volume": float}
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ─── Provider protocol ──────────────────────────────────────────────────────
class CandleSource(Protocol):
name: str
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
"""Last `days` worth of 4-hour candles for `asset`."""
...
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
"""Last `days` daily candles. Used for SMA reclaim / VCP-Daily."""
...
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
"""Last `weeks` weekly candles. Used for weekly-RSI reversal."""
...
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
"""1-minute candles in [start_ms, end_ms]. Paginated internally."""
...
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Funding-rate history. List of {time_ms, rate}. HL-only for now —
Binance provides funding but the cross-venue rate differs, so we
defer to the execution venue (HL)."""
...
# ─── Binance ────────────────────────────────────────────────────────────────
class BinanceCandles:
name = "binance"
base_url = "https://api.binance.com/api/v3/klines"
@staticmethod
def _symbol(asset: str) -> str:
return f"{asset.upper()}USDT"
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "4h",
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_simple(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_simple(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def _fetch_simple(self, asset: str, interval: str, window_ms: int) -> list[dict]:
"""Single-call fetch for intervals where 1000 bars covers the window."""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": interval,
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# Binance caps each call at 1000 candles — paginate forward.
out: list[dict] = []
cursor = start_ms
async with httpx.AsyncClient(timeout=20) as client:
while cursor < end_ms:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "1m",
"startTime": cursor, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
chunk = resp.json()
if not chunk:
break
out.extend(self._normalize(r) for r in chunk)
last_open = chunk[-1][0]
if last_open <= cursor:
break
cursor = last_open + 60_000
if len(chunk) < 1000:
break
return out
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Binance perp funding. Format: list of {time_ms, rate}.
Binance returns rate per 8h funding cycle (matches HL convention).
Note: this is Binance's perp funding, NOT HL's. For HL-traded
positions, prefer HyperliquidCandles.fetch_funding().
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
url = "https://fapi.binance.com/fapi/v1/fundingRate"
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(url, params={
"symbol": self._symbol(asset),
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [
{"time_ms": r["fundingTime"], "rate": float(r["fundingRate"])}
for r in rows
]
@staticmethod
def _normalize(row) -> dict:
return {
"time_ms": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
# ─── Hyperliquid ────────────────────────────────────────────────────────────
class HyperliquidCandles:
"""HL public /info endpoint — same data the HL UI uses.
Endpoint:
POST https://api.hyperliquid.xyz/info
body { "type": "candleSnapshot",
"req": { "coin": "SOL", "interval": "4h",
"startTime": ms, "endTime": ms } }
Returns array of {t, T, s, i, o, c, h, l, v, n} — we normalize to our
standard shape. Useful for HL-native perps Binance doesn't list.
"""
name = "hyperliquid"
def __init__(self, mainnet: bool | None = None):
use_mainnet = settings.hl_mainnet if mainnet is None else mainnet
self.base_url = (
"https://api.hyperliquid.xyz/info" if use_mainnet
else "https://api.hyperliquid-testnet.xyz/info"
)
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "4h", days * 24 * 3600 * 1000)
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_window(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# HL returns ALL candles in the window in one response — no pagination
# needed for typical scanner windows. For multi-day 1m calls HL may
# truncate; the caller should keep windows under ~24h for 1m data.
return await self._fetch(asset.upper(), "1m", start_ms, end_ms)
async def _fetch_window(self, asset: str, interval: str, window_ms: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
return await self._fetch(asset.upper(), interval, start_ms, end_ms)
async def _fetch(self, coin: str, interval: str, start_ms: int, end_ms: int) -> list[dict]:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(self.base_url, json={
"type": "candleSnapshot",
"req": {
"coin": coin, "interval": interval,
"startTime": start_ms, "endTime": end_ms,
},
})
resp.raise_for_status()
rows = resp.json() or []
return [self._normalize(r) for r in rows]
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""HL funding history — HOURLY cadence (1 cycle per hour).
IMPORTANT: HL's /info endpoint caps fundingHistory at 500 rows per
response. 500 rows × 1h cadence = 20.8 days, so a single call CAN'T
return a full 30-day window. We page backwards from `endTime` until
we cover `days` worth of history (or HL runs out of data).
Returns chronologically-sorted list of {time_ms, rate}, deduplicated.
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
cursor = end_ms
collected: dict[int, float] = {} # time_ms → rate (dedup by time)
async with httpx.AsyncClient(timeout=20) as client:
# Safety cap: at most 10 pages (5000 rows ≈ 208 days) — way more
# than any caller could reasonably want, prevents runaway loops
# if HL returns inconsistent data.
for _ in range(10):
if cursor <= start_ms:
break
resp = await client.post(self.base_url, json={
"type": "fundingHistory",
"coin": asset.upper(),
"startTime": start_ms,
"endTime": cursor,
})
resp.raise_for_status()
chunk = resp.json() or []
if not chunk:
break
for r in chunk:
t = r["time"]
if t not in collected:
collected[t] = float(r["fundingRate"])
oldest = min(r["time"] for r in chunk)
if oldest <= start_ms or len(chunk) < 500:
# Either we reached the start of our window, or HL gave
# us a partial page (no more data behind it).
break
cursor = oldest - 1
return [
{"time_ms": t, "rate": rate}
for t, rate in sorted(collected.items())
]
@staticmethod
def _normalize(row: dict) -> dict:
# HL candle keys: t=open_ms, o=open, h/l, c, v
return {
"time_ms": row["t"],
"open": float(row["o"]),
"high": float(row["h"]),
"low": float(row["l"]),
"close": float(row["c"]),
"volume": float(row["v"]),
}
# ─── Routing ────────────────────────────────────────────────────────────────
# HL-native perps that DO NOT have a Binance spot pair. The scanner / backtest
# auto-route these to HL. Add more as you discover them — the list is the
# only place to maintain provider preference.
HL_NATIVE_ASSETS = {
"HYPE", "PURR", "JEFF", "VAPOR",
"PIP", "OMNIX", "PYTH",
# NOTE: TRUMP is now listed on Binance too — using Binance for that gets
# cleaner history. SUI is on both.
}
# Reversal-strategy basket. Major-cap only (no shitcoins), 1 HL-native.
# Used by all three reversal scanners (weekly RSI, SMA reclaim, funding extreme).
REVERSAL_BASKET = ["BTC", "ETH", "SOL", "BNB", "LINK", "AAVE", "DOGE", "HYPE"]
# Bar durations in seconds — used by drop_in_progress_bar() to know whether
# the last candle in a response is still open. Crypto exchanges return the
# CURRENT (in-progress) bar in `klines` responses; for daily/weekly logic
# we usually want the most recent CLOSED bar instead.
INTERVAL_SECONDS = {
"1m": 60, "5m": 300, "15m": 900,
"1h": 3600, "4h": 14400, "8h": 28800,
"1d": 86400, "1w": 604800,
}
def drop_in_progress_bar(candles: list[dict], interval: str) -> list[dict]:
"""Return `candles` minus the last entry if it's still an open bar.
Daily / weekly bars on Binance & HL are returned with `time_ms = open_time`.
The bar's close time is open_time + interval_seconds. If now < close_time,
the bar hasn't closed yet — using its volume/close is misleading (volume
is partial, close is current spot mid-bar).
Use this in scanners that care about CONFIRMED signals (SMA reclaim,
weekly RSI). Use raw candles only when you want to react intra-bar
(rare and usually wrong for position trading).
"""
if not candles:
return candles
dur_s = INTERVAL_SECONDS.get(interval)
if dur_s is None:
return candles # unknown interval — be conservative, return as-is
bar_close_ms = candles[-1]["time_ms"] + dur_s * 1000
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
if now_ms < bar_close_ms:
return candles[:-1]
return candles
_BINANCE_SINGLETON = BinanceCandles()
_HL_SINGLETON = HyperliquidCandles()
def for_asset(asset: str) -> CandleSource:
"""Pick the right provider. HL-native → HL, otherwise Binance.
Always returns SOMETHING — caller doesn't need to handle None. If the
asset doesn't actually trade on the chosen venue, the underlying HTTP
call will return an empty list and the caller falls back to its
"no data" path.
"""
return _HL_SINGLETON if asset.upper() in HL_NATIVE_ASSETS else _BINANCE_SINGLETON
+177
View File
@@ -0,0 +1,177 @@
"""On-chain metrics for the BTC bottom-reversal scanner.
Two providers, same interface (fetch_mvrv_z_score / fetch_sth_sopr → list[float]):
BitcoinDataClient — DEFAULT. bitcoin-data.com public API. FREE, no key.
Returns PRE-COMPUTED MVRV-Z and STH-SOPR (we don't
need realized-cap or local math — realized cap is an
on-chain quantity that CANNOT be derived from price
data, so a vendor is unavoidable; this is the free one).
Free tier: 10 requests/HOUR. The scanner runs once a
day and needs 2 calls — well within budget — but we
add a 6-hour in-process cache as a safety net against
restarts / manual re-runs hammering the limit.
GlassnodeClient — Optional paid fallback. Used automatically if
GLASSNODE_API_KEY is set (higher resolution / SLA).
get_onchain_client() picks the right one. btc_bottom_reversal.py only
depends on the interface, never the concrete class.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ─── Provider 1: bitcoin-data.com (free, default) ───────────────────────────
BITCOIN_DATA_BASE = "https://bitcoin-data.com/v1"
# Endpoint → (path, json value key). Both return a full daily history list
# of {"d","unixTs",<key>} when called with no query params.
_BD_ENDPOINTS = {
"mvrv_z": ("/mvrv-zscore", "mvrvZscore"),
"sth_sopr": ("/sth-sopr", "sthSopr"),
}
# Fresh-fetch window: don't re-hit the API if the on-disk copy is younger
# than this. The scanner runs daily; 18h keeps it to ~1 fetch/day/metric
# (2 req/day total, vs the 10 req/HOUR free cap).
_BD_FRESH_S = 18 * 3600
# How stale on-disk data may be and still be USABLE when the API is down /
# rate-limited. A daily MVRV-Z / STH-SOPR barely moves over 2 days, so
# serving 48h-old data beats skipping a scan or, worse, acting on nothing.
_BD_STALE_OK_S = 48 * 3600
# Disk cache survives restarts — critical given the tight free rate limit.
_BD_CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cache")
_BD_CACHE_FILE = os.path.join(_BD_CACHE_DIR, "onchain_bitcoin_data.json")
def _load_disk_cache() -> dict:
try:
with open(_BD_CACHE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _save_disk_cache(cache: dict) -> None:
try:
os.makedirs(_BD_CACHE_DIR, exist_ok=True)
tmp = _BD_CACHE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(cache, f)
os.replace(tmp, _BD_CACHE_FILE) # atomic
except OSError as exc:
logger.warning("onchain disk-cache write failed: %s", exc)
class BitcoinDataClient:
"""Free, key-less. Returns pre-computed metric series (already the
Z-score / SOPR value — no local computation needed). Disk-cached so the
10 req/hour free cap and process restarts can't starve the scanner."""
async def _series(self, metric: str, days: int) -> list[float]:
now = time.time()
cache = _load_disk_cache()
entry = cache.get(metric) # {"ts": epoch, "values": [...]}
age = (now - entry["ts"]) if entry else None
# Fresh enough → no network call at all.
if entry and age is not None and age < _BD_FRESH_S:
vals = entry["values"]
return vals[-days:] if days else vals
path, key = _BD_ENDPOINTS[metric]
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(f"{BITCOIN_DATA_BASE}{path}")
if resp.status_code == 429:
raise RuntimeError("rate_limited")
resp.raise_for_status()
rows = resp.json() or []
values = [float(r[key]) for r in rows if r.get(key) is not None]
if not values:
raise RuntimeError("empty_response")
cache[metric] = {"ts": now, "values": values}
_save_disk_cache(cache)
return values[-days:] if days else values
except Exception as exc:
# Network/limit failure: fall back to disk if it's still usable.
if entry and age is not None and age < _BD_STALE_OK_S:
logger.warning("bitcoin-data.com %s fetch failed (%s) — "
"serving disk cache aged %.1fh",
metric, exc, age / 3600)
vals = entry["values"]
return vals[-days:] if days else vals
raise RuntimeError(
f"bitcoin-data.com {metric} unavailable ({exc}) and no usable cache"
) from exc
async def fetch_mvrv_z_score(self, asset: str = "BTC", days: int = 730) -> list[float]:
if asset.upper() != "BTC":
return [] # bitcoin-data.com is BTC-only
return await self._series("mvrv_z", days)
async def fetch_sth_sopr(self, asset: str = "BTC", days: int = 30) -> list[float]:
if asset.upper() != "BTC":
return []
return await self._series("sth_sopr", days)
# ─── Provider 2: Glassnode (paid, optional) ─────────────────────────────────
GLASSNODE_BASE_URL = "https://api.glassnode.com/v1/metrics"
class GlassnodeClient:
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key if api_key is not None else settings.glassnode_api_key
async def _get_series(self, path: str, asset: str, days: int) -> list[dict]:
if not self.api_key:
raise RuntimeError("GLASSNODE_API_KEY is not configured")
end = int(datetime.now(timezone.utc).timestamp())
start = end - days * 86_400
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
f"{GLASSNODE_BASE_URL}{path}",
params={"a": asset, "api_key": self.api_key,
"s": start, "u": end, "i": "24h"},
)
resp.raise_for_status()
return resp.json() or []
async def fetch_mvrv_z_score(self, asset: str = "BTC", days: int = 730) -> list[float]:
rows = await self._get_series("/market/mvrv_z_score", asset, days)
return [float(r["v"]) for r in rows if r.get("v") is not None]
async def fetch_sth_sopr(self, asset: str = "BTC", days: int = 30) -> list[float]:
rows = await self._get_series("/indicators/sopr_less_155", asset, days)
return [float(r["v"]) for r in rows if r.get("v") is not None]
# ─── Factory ────────────────────────────────────────────────────────────────
def get_onchain_client():
"""Glassnode if a key is configured (paid, higher SLA), else the free
bitcoin-data.com client. Both expose the same async interface."""
if settings.glassnode_api_key:
logger.info("On-chain provider: Glassnode (paid key configured)")
return GlassnodeClient()
logger.info("On-chain provider: bitcoin-data.com (free, no key)")
return BitcoinDataClient()
+179
View File
@@ -0,0 +1,179 @@
"""
Batch re-analyzer — runs Claude analysis on posts that were backfilled
without AI scoring (ai_confidence == 0).
Designed to run as a one-shot background task. Processes posts oldest-first,
1 per second, so 479 posts ≈ 8 minutes. Progress is logged at every batch.
Usage (via dev endpoint POST /api/dev/reanalyze):
curl -X POST "http://localhost:8000/api/dev/reanalyze?limit=500&dry_run=false"
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
logger = logging.getLogger(__name__)
# Global so the HTTP endpoint can check progress without storing state externally.
_reanalyze_state: dict = {
"running": False,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": None,
"finished_at": None,
}
def get_state() -> dict:
return dict(_reanalyze_state)
async def reanalyze_unscored(
db_session_factory,
limit: int = 500,
dry_run: bool = False,
delay_secs: float = 1.0,
legacy_signals: bool = False,
model: Optional[str] = None,
) -> dict:
"""
Find all posts with ai_confidence == 0, run analyze_post on each,
and write results back to the DB.
Args:
limit: Maximum number of posts to process in this run.
dry_run: If True, analyze but do NOT write to DB (for testing).
delay_secs: Pause between API calls to avoid rate-limiting.
Returns:
Summary dict with processed/updated/errors counts.
"""
global _reanalyze_state
if _reanalyze_state["running"]:
logger.warning("reanalyze already in progress, skipping")
return _reanalyze_state
_reanalyze_state = {
"running": True,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
}
from app.models import Post
from app.services.analysis import analyze_post
try:
# ── Fetch posts needing (re-)analysis ────────────────────────────
# Two cases:
# 1. Never analyzed (ai_confidence == 0)
# 2. Analyzed before target_asset column was added (has buy/short
# signal but target_asset IS NULL). Pass legacy_signals=True
# to target ONLY these, bypassing unscored posts.
from sqlalchemy import or_, and_
if legacy_signals:
condition = and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
else:
# Use analysis_version IS NULL (not ai_confidence==0) so that
# posts already analyzed as "hold" (conf=0, version set) are not
# re-run every time. Only truly unanalyzed posts are targeted.
condition = or_(
Post.analysis_version == None, # noqa: E711
and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
)
async with db_session_factory() as db:
rows = await db.execute(
select(Post.id, Post.text)
.where(condition)
.order_by(Post.published_at.asc())
.limit(limit)
)
posts_to_do = rows.all() # list of (id, text) tuples
_reanalyze_state["total"] = len(posts_to_do)
logger.info("Reanalyze: %d posts queued (limit=%d, dry_run=%s)",
len(posts_to_do), limit, dry_run)
if not posts_to_do:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
return _reanalyze_state
# ── Process each post ─────────────────────────────────────────────
for post_id, text in posts_to_do:
_reanalyze_state["processed"] += 1
try:
analysis = await analyze_post(text, model=model) # caller decides quality vs speed
if not dry_run:
async with db_session_factory() as db:
result = await db.execute(
select(Post).where(Post.id == post_id)
)
post = result.scalar_one_or_none()
if post is None:
continue
post.sentiment = analysis["sentiment"]
post.signal = analysis.get("signal")
post.ai_confidence = analysis["confidence"]
post.ai_reasoning = analysis.get("reasoning")
post.prefilter_reason = analysis.get("prefilter_reason")
post.analysis_version = analysis.get("analysis_version")
post.relevant = analysis["relevant"]
post.price_impact_asset = analysis["asset"] if analysis["relevant"] else None
post.target_asset = analysis.get("target_asset")
post.category = analysis.get("category")
post.expected_move_pct = analysis.get("expected_move_pct")
await db.commit()
_reanalyze_state["updated"] += 1
if _reanalyze_state["processed"] % 20 == 0:
logger.info(
"Reanalyze progress: %d/%d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["total"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
except Exception as exc:
_reanalyze_state["errors"] += 1
logger.error("Reanalyze error on post %d: %s", post_id, exc)
# Rate-limit: wait between API calls
if delay_secs > 0:
await asyncio.sleep(delay_secs)
except Exception as exc:
logger.error("Reanalyze fatal error: %s", exc)
finally:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
logger.info(
"Reanalyze finished: %d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
return _reanalyze_state
+191
View File
@@ -0,0 +1,191 @@
"""
Hyperliquid <-> DB state reconciliation (P1.2).
Runs every RECONCILE_INTERVAL_SECONDS. For each subscription with a saved HL
API key (and NOT in paper mode), fetches actual open positions from HL and
compares them to BotTrade rows with closed_at IS NULL.
Two drift cases:
1. DB has open trade, HL has no matching position
→ User closed manually on HL UI, or HL liquidated, or our open_position
call failed silently after writing the row. Either way the row is stale.
Action: mark the DB row closed with pnl_usd=NULL, reason="closed_externally".
2. HL has open position, DB has no matching open trade
→ A trade exists on HL that we didn't open (legacy position, or stale
wallet shared across systems). We can't safely manage it — log a
warning + WS broadcast for the user to handle manually.
NOTE: this does NOT close stray HL positions. Auto-closing positions we didn't
open is exactly the kind of "helpful" behaviour that costs users money. We
report, the human decides.
Cost note:
HL's user_state endpoint is cheap. 1 call per active subscriber per
RECONCILE_INTERVAL. With 10 subscribers at 60s interval that's 600 calls/hr,
well inside HL's free tier.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription
from app.services.crypto import decrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
logger = logging.getLogger(__name__)
RECONCILE_INTERVAL_SECONDS = 60
# ─── Single-wallet reconcile ────────────────────────────────────────────────
async def _reconcile_wallet(sub: Subscription) -> dict:
"""Compare HL state vs DB state for one subscription.
Returns a small summary dict for logging / WS broadcast. Never raises —
network / HL errors are logged and counted but don't halt the cycle.
"""
summary = {
"wallet": sub.wallet_address, "orphan_hl": [], "orphan_db": [],
"marked_closed": 0, "error": None,
}
# Paper-mode subs aren't on HL at all — nothing to reconcile.
if sub.paper_mode:
return summary
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
summary["error"] = f"decrypt_failed: {exc}"
return summary
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=sub.wallet_address,
leverage=sub.leverage,
mainnet=settings.hl_mainnet,
)
hl_positions = await trader.get_open_positions()
except Exception as exc:
summary["error"] = f"hl_query_failed: {exc}"
return summary
hl_assets_open = {p["coin"]: p for p in hl_positions}
async with AsyncSessionLocal() as db:
# All DB rows we believe are still open for this wallet.
rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == sub.wallet_address,
BotTrade.closed_at.is_(None),
)
)
db_open_trades = rows.scalars().all()
db_assets_open = {t.asset: t for t in db_open_trades}
# Case 1: DB open trade, HL has nothing. Stale row → mark closed.
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
for asset, trade in db_assets_open.items():
# Paper trades never have HL positions; they're managed by the
# normal close path. Skip here.
if trade.hl_order_id == "paper":
continue
if asset not in hl_assets_open:
# Preserve any de-risk PnL ALREADY banked on this trade — the
# open remainder's PnL is unknown (closed externally) but the
# banked slice is real money on HL; setting pnl_usd=None would
# silently undercount realized PnL in analytics / KPIs.
banked = trade.realized_partial_pnl_usd or 0.0
preserved_pnl = round(banked, 2) if banked else None
# Atomic claim — same conditional UPDATE pattern as close_and_finalize.
claimed = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade.id)
.where(BotTrade.closed_at.is_(None))
.values(closed_at=now_naive, pnl_usd=preserved_pnl,
remaining_fraction=0.0)
)
if claimed.rowcount > 0:
summary["marked_closed"] += 1
summary["orphan_db"].append({"asset": asset, "trade_id": trade.id})
# Stop the TP/SL watcher for this trade
from app.services.tp_sl_monitor import unregister
unregister(trade.id)
logger.warning("Reconcile: trade %d (%s %s) closed externally — marked.",
trade.id, trade.side, asset)
await db.commit()
# Case 2: HL has position we don't know about. Report only — don't auto-trade.
for asset, hl_pos in hl_assets_open.items():
if asset not in db_assets_open:
summary["orphan_hl"].append({
"asset": asset, "szi": hl_pos["szi"],
"entry": hl_pos["entry_px"], "uPnL": hl_pos["unrealized_pnl"],
})
logger.warning("Reconcile: %s has unmanaged HL position %s (szi=%s, entry=%s)",
sub.wallet_address, asset, hl_pos["szi"], hl_pos["entry_px"])
return summary
# ─── Periodic top-level task ────────────────────────────────────────────────
async def reconcile_all_once() -> None:
"""One scan over every subscription. Wired into the APScheduler at startup."""
async with AsyncSessionLocal() as db:
rows = await db.execute(
select(Subscription).where(
Subscription.active == True, # noqa: E712
Subscription.hl_api_key.is_not(None),
)
)
subs = rows.scalars().all()
if not subs:
return
# Snapshot the fields we read so we don't hold the session across HL calls.
snapshots = list(subs)
n_changed = 0
n_orphans = 0
n_errors = 0
for sub in snapshots:
summary = await _reconcile_wallet(sub)
n_changed += summary["marked_closed"]
n_orphans += len(summary["orphan_hl"])
if summary["error"]:
n_errors += 1
# Broadcast significant events so the UI shows a warning banner.
if summary["marked_closed"] or summary["orphan_hl"]:
try:
from app.ws.manager import manager
await manager.broadcast({
"type": "reconcile_drift",
"wallet": summary["wallet"],
"orphan_hl": summary["orphan_hl"],
"marked_closed": summary["marked_closed"],
"at": datetime.now(timezone.utc).isoformat() + "Z",
})
except Exception as exc:
logger.warning("reconcile WS broadcast failed: %s", exc)
if n_changed or n_orphans or n_errors:
logger.info("Reconcile cycle: %d subs scanned, %d trades marked closed, %d HL orphans, %d errors",
len(snapshots), n_changed, n_orphans, n_errors)
+76 -7
View File
@@ -11,15 +11,21 @@ from datetime import datetime, timezone
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription
from app.models import BotTrade, Post, Subscription
logger = logging.getLogger(__name__)
async def rehydrate_open_trades() -> None:
# Imported locally to avoid circular imports at module load
from app.services.bot_engine import MAX_HOLD_SECONDS, close_and_finalize
from app.services.bot_engine import close_and_finalize
from app.services.crypto import decrypt_api_key
from app.services.signal_categories import (
get_stop_ladder as _get_stop_ladder,
sys2_derisk_ladder as _sys2_derisk_ladder,
sys2_addon_ladder as _sys2_addon_ladder,
sys2_peak_trail as _sys2_peak_trail,
)
from app.services.tp_sl_monitor import register_trade
async with AsyncSessionLocal() as db:
@@ -53,7 +59,29 @@ async def rehydrate_open_trades() -> None:
# Fall back to current Subscription only for legacy rows (pre-migration 005).
trade_leverage = t.leverage if t.leverage is not None else sub.leverage
# Re-register TP/SL watcher
# Re-register from the trade's FROZEN exit profile (eff_* columns),
# NOT the live Subscription. The trade may be a 90-day System-2
# reversal; using sub.* would rehydrate it with the user's Trump
# stop (1.5%) and 7-day max-hold — silently rewriting its risk on
# every restart. eff_* is stamped at open and never changes.
#
# Legacy rows (opened before this migration) have NULL eff_* —
# fall back to the Subscription so they still get *some* watcher.
eff_sl = t.eff_stop_loss_pct if t.eff_stop_loss_pct is not None else sub.stop_loss_pct
eff_tp = t.eff_take_profit_pct # may legitimately be None (sys2 pure-trail)
eff_tr = t.eff_trailing_stop_pct if t.eff_trailing_stop_pct is not None else sub.trailing_stop_pct
eff_tra = t.eff_trailing_activate_pct if t.eff_trailing_activate_pct is not None else sub.trailing_activate_at_pct
eff_mh = t.eff_max_hold_hours if t.eff_max_hold_hours is not None else (sub.max_hold_hours or 168)
# NOTE: peak_gain_pct is now RESTORED from the row (throttled
# persistence) so a pyramided / in-profit System-2 trade keeps its
# regime across restarts. min_hold still resets on rehydrate, but
# it only matters in the first 30 min of a Trump trade; a restart
# inside that window is rare and the downside (TP can fire early)
# is bounded.
post_res = await db.execute(
select(Post).where(Post.id == t.trigger_post_id)
)
trigger_post = post_res.scalar_one_or_none()
register_trade(
trade_id=t.id,
wallet=t.wallet_address,
@@ -62,14 +90,55 @@ async def rehydrate_open_trades() -> None:
asset=t.asset,
side=t.side,
entry_price=t.entry_price,
take_profit_pct=sub.take_profit_pct,
stop_loss_pct=sub.stop_loss_pct,
take_profit_pct=eff_tp,
stop_loss_pct=eff_sl,
trailing_stop_pct=eff_tr,
trailing_activate_at_pct=eff_tra,
invalidation=t.eff_invalidation,
invalidation_price=(
t.eff_invalidation_price
if t.eff_invalidation_price is not None
else (trigger_post.invalidation_price if trigger_post else None)
),
min_hold_until_ts=t.eff_min_hold_until_ts,
stop_ladder=_get_stop_ladder(
trigger_post.category if trigger_post else None
),
# Restore staged de-risk: rebuild the ladder from the trade's
# FROZEN leverage and skip steps already executed before the
# restart (derisk_steps_done is persisted on the row).
# Restore staged de-risk/pyramid/peak-trail with the trade's
# FROZEN risk mode + leverage, skipping steps already executed
# before the restart (persisted on the row).
derisk_ladder=(
_sys2_derisk_ladder(t.leverage or 1, t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
derisk_done=(t.derisk_steps_done or 0),
addon_ladder=(
_sys2_addon_ladder(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
addon_done=(t.addon_steps_done or 0),
# Restore the monotonic peak so a pyramided / in-profit trade
# doesn't fall back to the underwater de-risk regime on restart.
initial_peak=(t.peak_gain_pct or 0.0),
peak_trail=(
_sys2_peak_trail(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
grow_mode=bool(getattr(t, "grow_mode", False)),
)
# Compute remaining hold; if already exceeded, close immediately
# Remaining hold from the trade's FROZEN max_hold (e.g. 2160h for
# an sma_reclaim), not the user's Trump setting.
opened_aware = t.opened_at.replace(tzinfo=timezone.utc)
elapsed = (now - opened_aware).total_seconds()
remaining = MAX_HOLD_SECONDS - elapsed
max_hold_seconds = int(eff_mh) * 3600
remaining = max_hold_seconds - elapsed
from app.services.bot_engine import _background_tasks
+221
View File
@@ -0,0 +1,221 @@
"""
Regime filter — gates entries to convex setups only.
The Trump-signal bot was originally designed to fire on every high-confidence
post. For a convex (asymmetric-payoff) strategy that's wrong: you want to
trade RARELY but with very high quality. This module decides "is the market
in a regime where this signal has a real shot at a big move?"
Four gates, in order of likely impact:
1. Volatility contraction — only trade assets that have been quiet recently.
A coin that already moved 10% today is not setting up; it has set up.
2. Recent-move filter — don't chase. If the asset moved >5% in the last 24h,
stand down. The fat tail comes AFTER consolidation, not after a run.
3. Thin-liquidity hours — UTC 0309 is Asia thin book. Wider spreads, more
noise stop-outs, fewer institutional bids.
4. BTC counter-trend — if BTC is moving hard against the signal direction,
alts get dragged. Don't fight beta.
Each gate returns a bool + reason. The combiner short-circuits on the first
failure for clear logging.
All thresholds are intentionally configurable in one place. Tune them in
backtest, don't sprinkle magic numbers across the codebase.
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timezone
from typing import Optional, Tuple
from app.services.price_store import price_store
logger = logging.getLogger(__name__)
# ─── Tunables (one place to change) ─────────────────────────────────────────
ATR_RECENT_WINDOW_HOURS = 24 # "recent" volatility window
ATR_BASELINE_WINDOW_HOURS = 168 # 7-day baseline to compare against
ATR_CONTRACTION_RATIO = 0.7 # recent must be ≤ 70% of baseline → "contracted"
RECENT_MOVE_WINDOW_HOURS = 24
RECENT_MOVE_MAX_PCT = 5.0 # block entries if asset already moved >5%
BTC_COUNTER_TREND_HOURS = 6
BTC_COUNTER_TREND_PCT = 3.0 # block if BTC moved >3% against signal
THIN_LIQUIDITY_HOURS_UTC = range(3, 9) # 03:0008:59 UTC
# ─── Individual gates ───────────────────────────────────────────────────────
def _atr_proxy(asset: str, hours: int) -> Optional[float]:
"""Average true-range proxy using 1-min candles in price_store.
Returns the mean of (high - low) / close over the window, or None if
we don't have enough data yet. Cheap; recomputes on every call.
"""
asset = asset.upper()
buf = price_store._candles.get(asset)
if not buf:
return None
n_minutes = hours * 60
candles = list(buf)[-n_minutes:]
if len(candles) < n_minutes // 2: # need at least half the window populated
return None
ranges = [
(c["high"] - c["low"]) / c["close"]
for c in candles
if c["close"] > 0
]
if not ranges:
return None
return statistics.fmean(ranges)
def is_volatility_contracted(asset: str) -> Tuple[bool, str]:
"""True if recent ATR ≤ ATR_CONTRACTION_RATIO × baseline ATR.
Returns (ok, reason). On insufficient data we PASS (None means "we don't
know, don't block") to avoid breaking the bot on cold-start. Document this
asymmetry in the regime log.
"""
recent = _atr_proxy(asset, ATR_RECENT_WINDOW_HOURS)
base = _atr_proxy(asset, ATR_BASELINE_WINDOW_HOURS)
if recent is None or base is None or base == 0:
return True, f"vol_contraction: insufficient data ({asset}), passing"
ratio = recent / base
ok = ratio <= ATR_CONTRACTION_RATIO
return ok, f"vol_contraction: ratio={ratio:.2f} (threshold {ATR_CONTRACTION_RATIO})"
def is_not_already_moving(asset: str) -> Tuple[bool, str]:
"""Block entries when the asset already ran in the last 24h."""
asset = asset.upper()
buf = price_store._candles.get(asset)
if not buf or len(buf) < RECENT_MOVE_WINDOW_HOURS * 60:
return True, f"recent_move: insufficient data ({asset}), passing"
candles = list(buf)[-RECENT_MOVE_WINDOW_HOURS * 60:]
first_close = candles[0]["close"]
last_close = candles[-1]["close"]
if first_close == 0:
return True, "recent_move: bad first close, passing"
move_pct = abs(last_close - first_close) / first_close * 100
ok = move_pct <= RECENT_MOVE_MAX_PCT
return ok, f"recent_move: {move_pct:.2f}% in {RECENT_MOVE_WINDOW_HOURS}h (max {RECENT_MOVE_MAX_PCT}%)"
def is_not_thin_liquidity_hour() -> Tuple[bool, str]:
"""Block during Asia thin-book hours (UTC 0309)."""
hour_utc = datetime.now(timezone.utc).hour
ok = hour_utc not in THIN_LIQUIDITY_HOURS_UTC
return ok, f"thin_liquidity: UTC {hour_utc:02d}h"
def is_btc_not_countertrend(side: str) -> Tuple[bool, str]:
"""Block if BTC is moving hard against the signal direction.
side = 'long' or 'short'. Long signal + BTC dumping > 3% → block.
"""
buf = price_store._candles.get("BTC")
if not buf or len(buf) < BTC_COUNTER_TREND_HOURS * 60:
return True, "btc_trend: insufficient BTC data, passing"
candles = list(buf)[-BTC_COUNTER_TREND_HOURS * 60:]
first_close = candles[0]["close"]
last_close = candles[-1]["close"]
if first_close == 0:
return True, "btc_trend: bad first close, passing"
btc_change_pct = (last_close - first_close) / first_close * 100
# Long signal but BTC dumping → bad. Short signal but BTC pumping → bad.
countertrend = (
(side == "long" and btc_change_pct < -BTC_COUNTER_TREND_PCT) or
(side == "short" and btc_change_pct > BTC_COUNTER_TREND_PCT)
)
ok = not countertrend
return ok, f"btc_trend: BTC {btc_change_pct:+.2f}% in {BTC_COUNTER_TREND_HOURS}h vs {side}"
# ─── Master combiner ────────────────────────────────────────────────────────
def passes_regime_filter(asset: str, side: str) -> Tuple[bool, list[str]]:
"""Run every gate; return (passed_all, list_of_reasons).
Gates are evaluated in cheap-to-expensive order so we short-circuit on
the first failure when iterating callers want fast-rejection paths.
"""
reasons: list[str] = []
checks = [
is_not_thin_liquidity_hour(),
is_not_already_moving(asset),
is_volatility_contracted(asset),
is_btc_not_countertrend(side),
]
passed_all = True
for ok, reason in checks:
reasons.append(("" if ok else "") + reason)
if not ok:
passed_all = False
return passed_all, reasons
# ─── Position sizing (C) ────────────────────────────────────────────────────
def calculate_size_multiplier(
ai_confidence: int,
asset: str,
side: str,
) -> float:
"""Score-based multiplier on `position_size_usd`.
Each green box adds a point; more points → bigger bet. This is the
Druckenmiller "when you have conviction, swing for the fences" idea
expressed numerically. Capped at 4× so a single misread doesn't blow
the daily budget.
Scoring:
+1 if AI confidence ≥ 90
+1 if volatility is contracted (we're at the start of the spring, not the end)
+1 if asset hasn't already run today (still room to go)
+1 if BTC is supportive of the direction (beta in our favour)
"""
score = 0
if ai_confidence >= 90:
score += 1
vc_ok, _ = is_volatility_contracted(asset)
if vc_ok:
score += 1
nm_ok, _ = is_not_already_moving(asset)
if nm_ok:
score += 1
bt_ok, _ = is_btc_not_countertrend(side)
if bt_ok:
score += 1
multipliers = {0: 0.5, 1: 1.0, 2: 1.5, 3: 2.5, 4: 4.0}
return multipliers[score]
# ─── Manual-window helper (D) ───────────────────────────────────────────────
def is_in_manual_window(manual_window_until: Optional[datetime]) -> bool:
"""True if a manual-enable timestamp is set and still in the future.
`manual_window_until` is stored as naive-UTC in DB.
"""
if manual_window_until is None:
return False
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return manual_window_until > now_naive
+175
View File
@@ -0,0 +1,175 @@
"""
Scanner runtime state + control plane.
One module to answer: "what scanners are alive, when did each last fire, and
how do I turn one off without restarting the server?"
Why this exists (3 problems it solves at once):
1. KILL SWITCH — Production safety. If a scanner is misbehaving (false
fires, hammering an API, leaking memory) the operator needs to disable
it WITHOUT redeploying. Per-scanner toggle + "stop all" both required.
2. COOLDOWN PERSISTENCE — Previously each scanner kept _last_signal_at as
a process-local dict. A backend restart wiped that dict, so a scanner
that fired yesterday would happily re-fire today. Now cooldown is read
from the posts table (source-of-truth) via `last_signal_at()`.
3. OBSERVABILITY — Operator needs to see "is my RSI scanner alive?". The
in-memory state tracker records every run (success / fire / error) so
a GET /api/scanners endpoint can show the whole fleet at a glance.
State is intentionally process-local (no DB writes). After restart all
scanners come back ENABLED — same as the rest of the system. Cooldowns
survive restart because they're DB-derived. If you want a "permanently
disabled" scanner, comment it out of main.py instead.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ─── State container ────────────────────────────────────────────────────────
@dataclass
class ScannerState:
name: str
enabled: bool = True
last_run_at: Optional[datetime] = None
last_status: str = "never_ran" # never_ran | ok | fired | error
last_message: Optional[str] = None
last_fired_at: Optional[datetime] = None
consecutive_errors: int = 0
total_runs: int = 0
total_fires: int = 0
def to_dict(self) -> dict:
d = asdict(self)
for k in ("last_run_at", "last_fired_at"):
if d[k] is not None:
# last_run_at is tz-aware (datetime.now(timezone.utc)), so
# isoformat() already emits "...+00:00". Appending "Z" would
# produce the invalid "...+00:00Z" which JS Date can't parse
# (renders as "NaNd ago" in the UI). Just use isoformat().
d[k] = d[k].isoformat()
return d
_STATES: dict[str, ScannerState] = {}
# ─── Registration (called once per scanner at import time) ──────────────────
def register(name: str) -> ScannerState:
if name not in _STATES:
_STATES[name] = ScannerState(name=name)
return _STATES[name]
# ─── Toggle controls ────────────────────────────────────────────────────────
def is_enabled(name: str) -> bool:
"""Return False ONLY if explicitly disabled. Unknown scanners default
to enabled — a scanner shouldn't silently refuse to run because its
state wasn't registered (defensive)."""
s = _STATES.get(name)
return s.enabled if s else True
def set_enabled(name: str, enabled: bool) -> Optional[ScannerState]:
s = _STATES.get(name)
if s is None:
return None
s.enabled = enabled
logger.info("Scanner %s %s", name, "ENABLED" if enabled else "DISABLED")
return s
def disable_all() -> int:
"""Kill switch. Returns count of scanners that were running and got
flipped off. Already-disabled scanners are skipped."""
n = 0
for s in _STATES.values():
if s.enabled:
s.enabled = False
n += 1
if n:
logger.warning("Scanner kill switch: disabled %d scanners", n)
return n
def enable_all() -> int:
n = 0
for s in _STATES.values():
if not s.enabled:
s.enabled = True
n += 1
return n
def get_all() -> list[ScannerState]:
return list(_STATES.values())
# ─── Per-run telemetry (called by scanners after each scan) ─────────────────
def record_run(name: str, status: str, message: Optional[str] = None) -> None:
"""status: 'ok' (ran, no signal), 'fired' (emitted a signal), 'error'."""
s = register(name)
s.last_run_at = datetime.now(timezone.utc)
s.last_status = status
s.last_message = message[:240] if message else None
s.total_runs += 1
if status == "fired":
s.total_fires += 1
s.last_fired_at = s.last_run_at
s.consecutive_errors = 0
elif status == "error":
s.consecutive_errors += 1
else:
s.consecutive_errors = 0
# ─── DB-backed cooldown (survives restart) ──────────────────────────────────
async def last_signal_at(source: str, target_asset: str) -> Optional[datetime]:
"""Most recent post with the given source+target_asset. Returns naive UTC.
Used by scanners INSTEAD of in-memory cooldown trackers. The DB is the
authoritative record of "did this scanner already fire?" — surviving
restarts, multi-process deploys, and even DB migrations.
"""
from sqlalchemy import select, func
from app.database import AsyncSessionLocal
from app.models import Post
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.max(Post.published_at)).where(
Post.source == source,
Post.target_asset == target_asset.upper(),
)
)
ts = result.scalar_one_or_none()
return ts
async def in_cooldown(source: str, target_asset: str, cooldown_days: int) -> bool:
"""Convenience wrapper. True iff we fired the same {source, asset} within
the last `cooldown_days`."""
last = await last_signal_at(source, target_asset)
if last is None:
return False
age = datetime.now(timezone.utc).replace(tzinfo=None) - last
return age < timedelta(days=cooldown_days)
View File
+12
View File
@@ -0,0 +1,12 @@
# Archived scanners (not in the live path)
These were generic System-2 scanners superseded by the focused
`btc_bottom_reversal` state machine. Nothing imports them; they are NOT
scheduled and do NOT register with scanner_state. Kept for reference/history.
- vcp_breakout.py — volatility-contraction breakout (bidirectional)
- weekly_rsi_reversal.py — weekly RSI extreme + recovery (bidirectional)
To revive: re-add an APScheduler job in app/main.py and a
scanner_state.register() call, and ensure the source tag is in
signal_categories.SYSTEM_2_SOURCES.
@@ -0,0 +1,251 @@
"""
VCP-style breakout scanner — example of an external signal module.
What this scans for (Minervini-light, adapted for crypto perps):
1. Volatility contraction: 7-day ATR ≤ 50% of 30-day ATR
(the price has been getting tighter — supply is drying up)
2. Breakout confirmation: current 4h close > max(prior 30-day highs)
(a real break of the range, not just touching the top)
3. Volume confirmation: last 24h volume ≥ 1.5× 30-day average
(institutional participation, not retail noise)
4. Cooldown: no signal for the same asset in the past 48h
(avoid stacking)
When all 4 conditions hit, the scanner POSTs to /api/signals/ingest. From
there the trade goes through the same convex-strategy pipeline as Trump
signals — regime filter, sizing, trailing stop, all of it.
Why this module exists:
Trump signals have 1-2 true catalysts per month. Adding a TA-based source
that finds setups continuously means the bot has SOMETHING to act on most
weeks, instead of waiting for geopolitical fireworks. The user's hypothesis
is that consolidation breakouts have asymmetric upside; the existing
trailing-stop logic is purpose-built to capture that.
Tunables (don't hardcode in caller — change them HERE if you want to test):
ATR_RECENT_DAYS, ATR_BASELINE_DAYS, ATR_RATIO_MAX,
BREAKOUT_LOOKBACK_DAYS, VOLUME_MULT_MIN, COOLDOWN_HOURS.
Asset list:
Default = SOL only. Extend ASSETS_TO_SCAN with more once you've watched
this run for a week and confirmed it doesn't fire-hose noise.
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services import scanner_state
logger = logging.getLogger(__name__)
SCANNER_NAME = "vcp_breakout"
scanner_state.register(SCANNER_NAME)
# ─── Tunables ───────────────────────────────────────────────────────────────
ATR_RECENT_DAYS = 7
ATR_BASELINE_DAYS = 30
ATR_RATIO_MAX = 0.5 # recent ATR must be ≤ 50% of baseline
BREAKOUT_LOOKBACK_DAYS = 30 # break of this many days' high
VOLUME_MULT_MIN = 1.5 # current 24h vol vs 30-day avg
COOLDOWN_HOURS = 48 # don't re-signal same asset within this window
# Assets to scan. Provider selection (Binance vs HL) is automatic via
# market_data.for_asset() — HL-native perps like TRUMP/HYPE route to HL
# automatically. Add to HL_NATIVE_ASSETS in market_data.py if needed.
ASSETS_TO_SCAN = [
"SOL",
# "ETH", "LINK", "AVAX", # Binance has them
# "TRUMP", "HYPE", # HL-native (routed automatically)
]
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# Data fetching is delegated to market_data.for_asset() — see that module
# for Binance vs Hyperliquid routing.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _atr_proxy(candles: list[dict]) -> Optional[float]:
"""Mean of (high-low)/close — simple ATR proxy, dimensionless."""
ranges = [(c["high"] - c["low"]) / c["close"] for c in candles if c["close"] > 0]
return statistics.fmean(ranges) if ranges else None
def evaluate_breakout(candles: list[dict]) -> tuple[bool, dict]:
"""Returns (is_signal, debug_info). All 4 gates must pass.
Pure function — no side effects, fully testable.
"""
if len(candles) < ATR_BASELINE_DAYS * 6:
return False, {"reason": "insufficient_data", "bars": len(candles)}
# 1. Volatility contraction
recent_bars = candles[-ATR_RECENT_DAYS * 6:]
baseline_bars = candles[-ATR_BASELINE_DAYS * 6:]
atr_recent = _atr_proxy(recent_bars)
atr_baseline = _atr_proxy(baseline_bars)
if not atr_recent or not atr_baseline:
return False, {"reason": "atr_calc_failed"}
atr_ratio = atr_recent / atr_baseline
if atr_ratio > ATR_RATIO_MAX:
return False, {"reason": "no_contraction", "atr_ratio": round(atr_ratio, 3)}
# 2. Range break — UP through prior high (long) or DOWN through prior
# low (short). Lookback excludes the current bar.
lookback = candles[-BREAKOUT_LOOKBACK_DAYS * 6 : -1]
if not lookback:
return False, {"reason": "no_lookback"}
prior_high = max(c["high"] for c in lookback)
prior_low = min(c["low"] for c in lookback)
current_close = candles[-1]["close"]
if current_close > prior_high:
direction, ref, gap = "buy", prior_high, (current_close - prior_high) / prior_high * 100
elif current_close < prior_low:
direction, ref, gap = "short", prior_low, (prior_low - current_close) / prior_low * 100
else:
return False, {"reason": "no_range_break", "close": current_close,
"prior_high": prior_high, "prior_low": prior_low}
# 3. Volume confirmation (same for both directions — a real break needs
# participation regardless of which way it goes)
recent_vol_24h = sum(c["volume"] for c in candles[-6:])
baseline_avg_24h = sum(c["volume"] for c in candles[-180:]) / 30
if baseline_avg_24h <= 0:
return False, {"reason": "no_volume_data"}
vol_ratio = recent_vol_24h / baseline_avg_24h
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": direction,
"atr_ratio": round(atr_ratio, 3),
"vol_ratio": round(vol_ratio, 2),
"break_ref": round(ref, 4),
"break_at": round(current_close, 4),
"headroom_pct": round(gap, 2),
}
def _confidence_from(debug: dict) -> int:
"""Map the signal's quality into a 0-100 confidence used by sizing.
The tighter the contraction and the bigger the volume spike, the higher
the confidence. Capped at 95 — leave headroom so AI signals at 100 stay
distinguishable.
"""
score = 70 # baseline for "all 4 gates passed"
# Tighter contraction → higher confidence
if debug.get("atr_ratio", 1) < 0.35: score += 10
elif debug.get("atr_ratio", 1) < 0.45: score += 5
# Bigger volume spike → higher confidence
if debug.get("vol_ratio", 0) >= 3.0: score += 10
elif debug.get("vol_ratio", 0) >= 2.0: score += 5
return min(score, 95)
# ─── Ingest call ────────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
"""POST the signal to /api/signals/ingest. Treats the local server as the
target — same machine, no network hop in dev.
"""
if not settings.ingest_api_key:
logger.warning("VCP: signal would fire on %s but INGEST_API_KEY is empty", asset)
return
confidence = _confidence_from(debug)
direction = debug["direction"]
way = "breakout ↑" if direction == "buy" else "breakdown ↓"
rel = "above" if direction == "buy" else "below"
payload = {
"source": "breakout",
"external_id": f"vcp-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}",
"text": (
f"VCP {way} on {asset}: ATR ratio {debug['atr_ratio']} "
f"(7d/30d), {debug['vol_ratio']}× normal volume, "
f"close ${debug['break_at']} {rel} {BREAKOUT_LOOKBACK_DAYS}-day "
f"level ${debug['break_ref']} ({debug['headroom_pct']}%)"
),
"signal": direction,
"target_asset": asset,
"confidence": confidence,
"category": "vcp_breakout",
"expected_move_pct": 5.0, # historical VCP median expansion
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("VCP ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("VCP signal emitted for %s, confidence=%d: %s",
asset, confidence, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""Single scan pass over ASSETS_TO_SCAN. Called every 30 minutes.
Kill-switch + DB-backed cooldown. Drops in-progress 4h bar before
evaluating — without that, the breakout test ran against a partial bar
and could flicker FIRE / no-FIRE within a 4h window.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("VCP scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in ASSETS_TO_SCAN:
# Cooldown — DB-backed, restart-safe.
cooldown_days = COOLDOWN_HOURS / 24
if await scanner_state.in_cooldown("breakout", asset, cooldown_days):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_4h(asset, days=ATR_BASELINE_DAYS + 1)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("VCP fetch failed for %s via %s: %s", asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "4h")
if not candles:
logger.warning("VCP: %s returned 0 candles from %s — symbol may not exist",
asset, provider.name)
continue
is_signal, debug = evaluate_breakout(candles)
logger.info("VCP scan %s [%s]: %s%s", asset, provider.name,
"FIRE" if is_signal else "no", debug)
if is_signal:
await _emit_signal(asset, debug)
fired_any = True
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,264 @@
"""
Weekly RSI extreme + recovery scanner.
What it catches:
Severe weekly oversold conditions that flush capitulating holders, followed
by the first sign of strength. Historical examples this would have hit:
BTC 2022-11 ($15.5k bottom)
ETH 2022-06 ($900 bottom)
SOL 2022-12 ($8 bottom)
AAVE 2022-06 ($45 bottom)
Trigger logic (intentionally strict):
PRE-CONDITION: ≥ 4 consecutive completed weekly bars with RSI(14) < 30
(this is the capitulation phase — sustained extreme weakness)
TRIGGER: current completed week's RSI(14) >= 35
(i.e. the FIRST recovery week — RSI has lifted off the floor)
COOLDOWN: 60 days — these events happen ~once per year per asset.
No re-firing on the same setup.
Companion exit parameters (passed to the bot via signal payload — wider stops
to match the longer holding period and higher single-trade conviction):
SL = 8%
TRAILING_ACTIVATE = 15%
TRAILING_STOP = 6%
MAX_HOLD = 60 days
This is a high-conviction, low-frequency signal. Expect 0-3 fires per asset
per year. Don't tune it for higher frequency — that defeats the point.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
from app.services import scanner_state
SCANNER_NAME = "weekly_rsi_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
RSI_PERIOD = 14
RSI_OVERSOLD = 30 # "deeply oversold" floor (long setup)
RSI_RECOVERY_TRIGGER = 35 # bounce-off line for the long
RSI_OVERBOUGHT = 70 # "euphoric" ceiling (short setup)
RSI_ROLLOVER_TRIGGER = 65 # roll-off line for the short
WEEKS_OF_OVERSOLD = 4 # consecutive extreme weeks required (both sides)
WEEKS_TO_FETCH = 40 # enough history for stable Wilder's RSI
COOLDOWN_DAYS = 60
# Exit profile passed to /signals/ingest. Caller can override on the bot side.
PAYLOAD_CONFIDENCE = 88 # high conviction — top of the regime-filter score
PAYLOAD_EXPECTED_MOVE = 30.0 # historical median move after capitulation
# Cooldown is now DB-backed via scanner_state.in_cooldown() — survives
# restart. No more in-memory _last_signal_at dict.
# ─── RSI math (Wilder's smoothed) ───────────────────────────────────────────
def calculate_rsi(closes: list[float], period: int = 14) -> list[Optional[float]]:
"""Wilder's RSI. Returns a list aligned with `closes` where the first
`period` entries are None (not enough history yet).
Implementation note: the first valid RSI uses a SIMPLE mean of the first
`period` gains/losses, subsequent values use exponential smoothing with
alpha = 1/period. This matches TradingView, Binance UI, and most charting
tools — the "Cutler's RSI" (pure SMA) variant would give different results.
"""
n = len(closes)
rsi: list[Optional[float]] = [None] * n
if n < period + 1:
return rsi
gains = [max(closes[i] - closes[i - 1], 0) for i in range(1, n)]
losses = [max(closes[i - 1] - closes[i], 0) for i in range(1, n)]
# First valid RSI = simple mean over first `period` deltas
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
if avg_loss == 0:
rsi[period] = 100.0
else:
rs = avg_gain / avg_loss
rsi[period] = 100 - 100 / (1 + rs)
# Subsequent values use Wilder's smoothing
for i in range(period + 1, n):
avg_gain = (avg_gain * (period - 1) + gains[i - 1]) / period
avg_loss = (avg_loss * (period - 1) + losses[i - 1]) / period
if avg_loss == 0:
rsi[i] = 100.0
else:
rs = avg_gain / avg_loss
rsi[i] = 100 - 100 / (1 + rs)
return rsi
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_rsi_reversal(weekly_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function — no side effects, fully testable.
Returns (is_signal, debug_info).
"""
if len(weekly_candles) < RSI_PERIOD + WEEKS_OF_OVERSOLD + 2:
return False, {"reason": "insufficient_data", "bars": len(weekly_candles)}
closes = [c["close"] for c in weekly_candles]
rsi = calculate_rsi(closes, RSI_PERIOD)
# The MOST RECENT weekly bar is `closes[-1]`. We treat it as the "current"
# recovery candidate. Look BACKWARDS for WEEKS_OF_OVERSOLD prior bars all
# with RSI < RSI_OVERSOLD.
current_rsi = rsi[-1]
if current_rsi is None:
return False, {"reason": "rsi_not_computable"}
window = rsi[-(WEEKS_OF_OVERSOLD + 1):-1] # the N weeks before current
if any(r is None for r in window):
return False, {"reason": "history_gaps_in_window"}
this_close = weekly_candles[-1]["close"]
prev_close = weekly_candles[-2]["close"]
# ── LONG: capitulation bottom ──────────────────────────────────────────
# Sustained RSI<30, now lifting ≥35, price turning up.
if (current_rsi >= RSI_RECOVERY_TRIGGER
and all(r < RSI_OVERSOLD for r in window)
and this_close > prev_close):
low_w = min(c["low"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "buy",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((this_close - low_w) / low_w * 100, 2) if low_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# ── SHORT: euphoric top ────────────────────────────────────────────────
# Sustained RSI>70, now rolling ≤65, price turning down. Symmetric mirror.
if (current_rsi <= RSI_ROLLOVER_TRIGGER
and all(r > RSI_OVERBOUGHT for r in window)
and this_close < prev_close):
high_w = max(c["high"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "short",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((high_w - this_close) / high_w * 100, 2) if high_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# Neither side qualified — report the closest miss for the log.
return False, {
"reason": "no_setup",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
}
# ─── Ingest emission ────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
if not settings.ingest_api_key:
logger.warning("RSI-reversal would fire on %s but INGEST_API_KEY empty", asset)
return
direction = debug["direction"]
side_word = "capitulation bottom" if direction == "buy" else "euphoric top"
payload = {
"source": "rsi_reversal",
"external_id": f"rsi-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%W')}",
"text": (
f"Weekly RSI {side_word} on {asset}: RSI {debug['current_rsi']} after "
f"{WEEKS_OF_OVERSOLD}wk extreme (window: {debug['window_rsi']}). "
f"{debug['move_pct']}% move off the extreme @ ${debug['trigger_close']}."
),
"signal": direction,
"target_asset": asset,
"confidence": PAYLOAD_CONFIDENCE,
"category": "rsi_extreme_reversal",
"expected_move_pct": PAYLOAD_EXPECTED_MOVE,
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("RSI-reversal ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("RSI-reversal signal emitted for %s: %s", asset, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""One scan pass over REVERSAL_BASKET. Called by APScheduler.
Kill-switch aware: short-circuits if the operator disabled this scanner.
Cooldown is DB-backed (queries posts table), so restarts don't reset
state.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("RSI-reversal scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in REVERSAL_BASKET:
# Cooldown — DB-backed.
if await scanner_state.in_cooldown("rsi_reversal", asset, COOLDOWN_DAYS):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_1w(asset, weeks=WEEKS_TO_FETCH)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("RSI-reversal fetch failed for %s via %s: %s",
asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "1w")
if not candles:
logger.warning("RSI-reversal: %s returned 0 weekly bars from %s",
asset, provider.name)
continue
is_signal, debug = evaluate_rsi_reversal(candles)
if is_signal:
logger.info("RSI-reversal scan %s [%s]: FIRE — %s", asset, provider.name, debug)
await _emit_signal(asset, debug)
fired_any = True
else:
logger.debug("RSI-reversal scan %s [%s]: no — %s", asset, provider.name, debug)
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,148 @@
"""BTC bottom-reversal long scanner — 2-of-3 price confluence.
Simple, transparent, no on-chain data and no API keys. Fires only when at
least TWO of these three classic bottom signals agree:
A. AHR999 < 0.45 — deep-value / bottom zone
B. price ≤ 200-week MA ×1.05 — every cycle has bottomed near the 200WMA
C. Pi Cycle Bottom — 150d EMA ≤ 471d SMA × 0.745
Long only, low frequency, stateless. No tight stop (real macro bottoms wick
1530%); the position is invalidated when AHR999 climbs back above 1.2 — i.e.
BTC is no longer cheap and the bottom thesis is spent.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services.bottom_indicators import bottom_confluence
SCANNER_NAME = "btc_bottom_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
ASSET = "BTC"
COOLDOWN_DAYS = 30
# 3 votes → max conviction; 2 votes → solid. Scale confidence with agreement.
CONFIDENCE_BY_VOTES = {2: 88, 3: 94}
EXPECTED_MOVE_PCT = 25.0
# Pi Cycle needs 471 daily closes; +buffer for the dropped in-progress bar.
DAILY_LOOKBACK_DAYS = 520
# 200-week MA needs 200 weekly closes; +buffer.
WEEKLY_LOOKBACK_WEEKS = 210
async def evaluate_once() -> tuple[bool, dict]:
provider = for_asset(ASSET)
raw_daily = await provider.fetch_1d(ASSET, days=DAILY_LOOKBACK_DAYS)
daily = drop_in_progress_bar(raw_daily, "1d")
raw_weekly = await provider.fetch_1w(ASSET, weeks=WEEKLY_LOOKBACK_WEEKS)
weekly = drop_in_progress_bar(raw_weekly, "1w")
if not daily:
return False, {"reason": "missing_btc_daily"}
if not weekly:
return False, {"reason": "missing_btc_weekly"}
daily_closes = [float(c["close"]) for c in daily if c.get("close")]
weekly_closes = [float(c["close"]) for c in weekly if c.get("close")]
conf = bottom_confluence(daily_closes, weekly_closes)
debug: dict = dict(conf.detail)
if not conf.fired:
debug["reason"] = "confluence_not_met"
return False, debug
confidence = CONFIDENCE_BY_VOTES.get(conf.votes, 88)
debug["confidence"] = confidence
# No tight stop. Thesis is invalidated when BTC is no longer cheap; the
# 200WMA band is a useful structural reference for the exit monitor.
debug["invalidation_price"] = debug.get("wma200")
return True, debug
def _summary(debug: dict) -> str:
s = debug.get("signals", {})
on = [k for k, v in s.items() if v]
return (
f"BTC bottom confluence ({debug.get('votes')}/3): {', '.join(on)}. "
f"AHR999 {debug.get('ahr999')} (<{debug.get('ahr999_threshold')}), "
f"price {debug.get('price')}, 200WMA {debug.get('wma200')}, "
f"Pi 150EMA {debug.get('pi_ema150')} vs 471SMA×0.745 "
f"{debug.get('pi_sma471_scaled')}. No tight stop; "
f"invalidate if AHR999 > 1.2."
)
async def _emit_signal(debug: dict) -> bool:
"""POST the signal to the ingest endpoint. Returns True iff a Post was
(idempotently) created. False means the signal was NOT recorded — caller
must NOT treat the run as 'fired' (cooldown is keyed off the DB Post)."""
if not settings.ingest_api_key:
logger.error("BTC bottom-reversal would fire but INGEST_API_KEY empty "
"— signal NOT recorded, check deploy env")
return False
payload = {
"source": "btc_bottom_reversal",
"external_id": f"btc-bottom-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": "buy",
"target_asset": ASSET,
"confidence": debug["confidence"],
"category": "btc_bottom_reversal_long",
"expected_move_pct": EXPECTED_MOVE_PCT,
"invalidation_price": debug.get("invalidation_price"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("BTC bottom-reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("BTC bottom-reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("BTC bottom-reversal scanner disabled — skipping run")
return
if await scanner_state.in_cooldown("btc_bottom_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("BTC bottom-reversal cooldown active")
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
should_fire, debug = await evaluate_once()
except Exception as exc:
logger.error("BTC bottom-reversal scan failed: %s", exc)
scanner_state.record_run(SCANNER_NAME, "error", str(exc))
return
if should_fire:
logger.info("BTC bottom-reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
if emitted:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
# Not recorded → no DB Post → cooldown won't arm. Surface this as
# an error so a misconfigured deploy is obvious, not a silent
# daily "fired" that never actually trades.
scanner_state.record_run(SCANNER_NAME, "error", "ingest_failed_or_key_missing")
else:
logger.info("BTC bottom-reversal no — %s", debug)
scanner_state.record_run(SCANNER_NAME, "ok")
+379
View File
@@ -0,0 +1,379 @@
"""
Funding rate extreme + reversal scanner.
What it catches:
Crowded perp positioning that's about to unwind. When one side has been
paying ridiculous funding for weeks, the eventual unwind ("the squeeze")
is the cleanest single-day move you'll find. Examples:
BTC 2024-08 funding deeply +ve for 30d → 14% flush down within a week
SOL 2023-09 funding deeply -ve for 30d → 60% rally over the next month
ETH 2024-Q4 funding +3.5% on 30d → 12% pullback
Trigger logic:
PRE-CONDITION: Sum of last 30 days of funding rate (per 8h cycle)
exceeds ±FUNDING_EXTREME_PCT. The sign tells us which
side is overcrowded:
sum > +3% → longs have been paying — bearish setup
(signal = short)
sum < -3% → shorts paying — bullish setup
(signal = buy)
TRIGGER: Funding direction has STARTED to mean-revert
(last 3 funding cycles closer to zero than the 30d avg)
AND price has begun moving in the contrarian direction
(last 7 days price move at least 3% in that direction).
COOLDOWN: 14 days. Funding extremes can persist for weeks but
each unwind is a distinct event.
Companion exits — tighter than RSI/SMA because funding moves play out faster
(days, not weeks):
SL = 4%
TRAILING_ACTIVATE = 10%
TRAILING_STOP = 5%
MAX_HOLD = 30 days
This is the highest-frequency of the three reversal signals — expect 3-5
fires per asset per year — and the most "alpha-like" (most market participants
ignore funding entirely).
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# Promoted from booster → standalone scanner. Still importable by
# btc_bottom_reversal for confluence boost; ALSO emits its own signals via
# scan_once() registered with the APScheduler in app/main.py.
SCANNER_NAME = "funding_reversal"
scanner_state.register(SCANNER_NAME)
ASSET = "BTC" # BTC-only for now; extend to ETH/SOL after results validate
# How much funding+price history to load each tick.
# - Binance: 8h cadence → 30d ≈ 90 cycles
# - HL: 1h cadence → capped at 500 rows ≈ 20.8d (handled inside evaluate)
FUNDING_DAYS_LOOKBACK = 30
PRICE_DAYS_LOOKBACK = 35 # need 7d confirm + buffer for in-progress bar
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
FUNDING_HISTORY_DAYS = 30
FUNDING_EXTREME_THRESHOLD = 0.03 # 3% cumulative — extreme one-sided pressure
# CRITICAL: "recent" is in HOURS, not CYCLES. Binance funding is 8h-cadence
# (3 cycles/day) but HL is 1h-cadence (24 cycles/day) — a fixed "3 cycles
# lookback" means 24h on Binance but only 3h on HL. We pick cycles dynamically
# based on the response's actual cadence so 24h of funding history is always
# the comparison window.
FUNDING_REVERSAL_LOOKBACK_HOURS = 24
FUNDING_REVERSAL_RATIO = 0.5 # recent avg must be ≤ 50% of 30d-avg in magnitude
PRICE_CONFIRM_DAYS = 7
PRICE_CONFIRM_PCT = 3.0 # price moved 3%+ in contrarian direction
COOLDOWN_DAYS = 14
PAYLOAD_CONFIDENCE = 82 # slightly lower than RSI/SMA — funding can chop
PAYLOAD_EXPECTED_MOVE = 10.0
EMIT_STANDALONE_SIGNALS = True # promoted from booster → standalone signal source
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _detect_cadence_hours(funding: list[dict]) -> float:
"""Infer the funding cadence (hours between cycles) from the response.
Binance returns ~8h intervals; HL returns ~1h. We can't assume — the
response itself is the source of truth. Average over the most recent 10
intervals to smooth out occasional gaps.
"""
if len(funding) < 2:
return 8.0 # safe Binance default
sample = funding[-min(11, len(funding)):]
diffs = [sample[i]["time_ms"] - sample[i - 1]["time_ms"] for i in range(1, len(sample))]
if not diffs:
return 8.0
return statistics.fmean(diffs) / 3_600_000.0 # ms → hours
MIN_COVERAGE_DAYS = 14 # below this, the signal is statistically too noisy
def evaluate_funding_reversal(
funding_history: list[dict],
daily_candles: list[dict],
) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Debug contains `direction` key on real signals — 'buy' or 'short'.
Cross-venue safety: HL caps fundingHistory at 500 rows (~20.8 days for
1h cadence), Binance can give a full 30 days. We compute the actual
coverage span from the data and scale the cumulative-funding threshold
proportionally — so 3% over 30 days and 2.08% over 20.8 days are
treated as equivalent in daily-average terms.
"""
if not funding_history or len(funding_history) < 2:
return False, {"reason": "insufficient_funding_history",
"cycles": len(funding_history)}
cadence_h = _detect_cadence_hours(funding_history)
# Actual time span the response covers — bounded by HL's 500-row cap
# for HL, or by what we asked for on Binance.
span_ms = funding_history[-1]["time_ms"] - funding_history[0]["time_ms"]
actual_days = span_ms / 86_400_000
if actual_days < MIN_COVERAGE_DAYS:
return False, {"reason": "insufficient_coverage_days",
"actual_days": round(actual_days, 1),
"min_required": MIN_COVERAGE_DAYS}
rates = [f["rate"] for f in funding_history]
cumulative_funding = sum(rates)
avg_full_window = statistics.fmean(rates)
# Scale the extreme threshold by ACTUAL coverage so cross-venue results
# are comparable. Binance: 30d → threshold ×1. HL: 20.8d → threshold ×0.69.
scaled_threshold = FUNDING_EXTREME_THRESHOLD * (actual_days / FUNDING_HISTORY_DAYS)
# "Recent" lookback in TIME units, not cycles. 24h of cycles regardless
# of venue. Min 3 to avoid pathological cases (very short history).
recent_cycles = max(3, int(FUNDING_REVERSAL_LOOKBACK_HOURS / cadence_h))
recent_cycles = min(recent_cycles, len(rates)) # don't over-slice
avg_recent_cycle = statistics.fmean(rates[-recent_cycles:])
# 2. Identify direction (which side is overcrowded)
if cumulative_funding > scaled_threshold:
# Longs have been paying → expect SHORT squeeze
direction = "short"
elif cumulative_funding < -scaled_threshold:
# Shorts have been paying → expect LONG rally
direction = "buy"
else:
return False, {
"reason": "no_extreme",
"cumulative_funding_pct": round(cumulative_funding * 100, 3),
"scaled_threshold_pct": round(scaled_threshold * 100, 3),
"coverage_days": round(actual_days, 1),
}
# 3. Funding must be MEAN-REVERTING (recent cycles softer than 30d avg)
# Use absolute magnitude — what matters is "the pressure is easing",
# not the direction of zero-crossing.
if abs(avg_full_window) == 0:
return False, {"reason": "degenerate_avg"}
revert_ratio = abs(avg_recent_cycle) / abs(avg_full_window)
if revert_ratio > FUNDING_REVERSAL_RATIO:
return False, {
"reason": "funding_still_extreme",
"revert_ratio": round(revert_ratio, 2),
"needed_below": FUNDING_REVERSAL_RATIO,
"direction": direction,
}
# 4. Price confirms the contrarian move (last 7 days)
if not daily_candles or len(daily_candles) < PRICE_CONFIRM_DAYS + 1:
return False, {"reason": "insufficient_price_history",
"have": len(daily_candles), "need": PRICE_CONFIRM_DAYS + 1}
first_close = daily_candles[-(PRICE_CONFIRM_DAYS + 1)]["close"]
last_close = daily_candles[-1]["close"]
if first_close == 0:
return False, {"reason": "bad_first_close"}
pct_change = (last_close - first_close) / first_close * 100
if direction == "buy" and pct_change < PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_recovering",
"pct_7d": round(pct_change, 2), "direction": direction}
if direction == "short" and pct_change > -PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_falling",
"pct_7d": round(pct_change, 2), "direction": direction}
boost = {
"direction": direction,
"cum_funding_30d_pct": round(cumulative_funding * 100, 3),
"recent_avg_cycle_pct": round(avg_recent_cycle * 100, 4),
"revert_ratio": round(revert_ratio, 2),
"price_7d_pct": round(pct_change, 2),
"trigger_close": round(last_close, 4),
}
if not EMIT_STANDALONE_SIGNALS:
return False, {"reason": "boost_only", "boost": boost}
return True, boost
# ─── Standalone scanner plumbing ────────────────────────────────────────────
async def _fetch_inputs() -> tuple[list[dict], list[dict]]:
"""Pull funding history + daily candles for ASSET.
We bypass provider.fetch_1d in favor of fapi.binance.com/fapi/v1/klines
because (a) funding lives on the futures venue anyway — same liquidity
pool, same trade flow — and (b) the spot api.binance.com host is
occasionally geo-blocked, while fapi is more reliably reachable.
"""
provider = for_asset(ASSET)
funding = await provider.fetch_funding(ASSET, days=FUNDING_DAYS_LOOKBACK)
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - PRICE_DAYS_LOOKBACK * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
"https://fapi.binance.com/fapi/v1/klines",
params={"symbol": f"{ASSET}USDT", "interval": "1d",
"startTime": start_ms, "endTime": end_ms, "limit": 1000},
)
resp.raise_for_status()
raw_daily = [
{"time_ms": r[0], "open": float(r[1]), "high": float(r[2]),
"low": float(r[3]), "close": float(r[4]), "volume": float(r[5])}
for r in resp.json()
]
daily = drop_in_progress_bar(raw_daily, "1d")
return funding, daily
def _summary(debug: dict) -> str:
direction = debug.get("direction", "?").upper()
cum = debug.get("cum_funding_30d_pct")
recent = debug.get("recent_avg_cycle_pct")
revert = debug.get("revert_ratio")
p7d = debug.get("price_7d_pct")
return (
f"BTC funding extreme reversal — {direction}. "
f"30d cumulative funding {cum}% (crowded {'longs' if direction == 'SHORT' else 'shorts'}); "
f"last-24h cycle avg {recent}% (revert ratio {revert}); "
f"price 7d {p7d:+.2f}% confirms the unwind."
)
async def _emit_signal(debug: dict) -> bool:
"""POST to the ingest endpoint. Idempotent via external_id (per-day)."""
if not settings.ingest_api_key:
logger.error("Funding reversal would fire but INGEST_API_KEY empty — not recorded")
return False
direction = debug["direction"] # 'buy' or 'short'
expected_move = PAYLOAD_EXPECTED_MOVE if direction == "buy" else -PAYLOAD_EXPECTED_MOVE
payload = {
"source": "funding_reversal",
"external_id": f"funding-{ASSET}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": direction,
"target_asset": ASSET,
"confidence": PAYLOAD_CONFIDENCE,
"category": f"funding_reversal_{direction}",
"expected_move_pct": expected_move,
"invalidation_price": debug.get("trigger_close"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("Funding reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("Funding reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
"""Hourly tick. Idempotent (cooldown-gated, external_id-dedupe at ingest)."""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("Funding reversal scanner disabled — skipping")
return
if await scanner_state.in_cooldown("funding_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("Funding reversal cooldown active (%dd)", COOLDOWN_DAYS)
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
funding, daily = await _fetch_inputs()
fired, debug = evaluate_funding_reversal(funding, daily)
except Exception as exc:
# Always include exception type — httpx errors often have empty .args
# which formatted as just "Funding reversal scan failed:" before.
logger.exception("Funding reversal scan failed: %s (%s)",
type(exc).__name__, exc)
scanner_state.record_run(SCANNER_NAME, "error",
f"{type(exc).__name__}: {exc}"[:200])
return
if fired:
logger.info("Funding reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
scanner_state.record_run(SCANNER_NAME, "fired" if emitted else "error",
None if emitted else "ingest_failed")
else:
logger.info("Funding reversal no — %s", debug.get("reason"))
scanner_state.record_run(SCANNER_NAME, "ok", debug.get("reason"))
# ─── Read API helper — current snapshot for the BTC page tab ────────────────
async def get_current_snapshot() -> dict:
"""Live read for the frontend BTC page funding tab. Returns the latest
funding rate, the 24h running average, cumulative 30d sum, and the verdict
of evaluate_funding_reversal() against current data. Cheap to call — only
network cost is two market_data fetches the scanner would do anyway."""
try:
funding, daily = await _fetch_inputs()
except Exception as exc:
logger.exception("funding snapshot fetch failed")
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
if not funding:
return {"ok": False, "error": "no_funding_data"}
cadence_h = _detect_cadence_hours(funding)
rates = [f["rate"] for f in funding]
cum_30d_pct = sum(rates) * 100
span_days = (funding[-1]["time_ms"] - funding[0]["time_ms"]) / 86_400_000
latest = rates[-1] * 100
# Last-24h equivalent average per-cycle rate (in %)
recent_n = max(3, int(24 / cadence_h)) if cadence_h > 0 else 24
recent_n = min(recent_n, len(rates))
last_24h_avg = (sum(rates[-recent_n:]) / recent_n) * 100
fired, debug = evaluate_funding_reversal(funding, daily)
# 7-day funding history for the sparkline (truncate to keep payload small)
history = [
{"t": int(f["time_ms"]), "rate_pct": round(f["rate"] * 100, 5)}
for f in funding[-int(min(len(funding), 24 * 7 / max(cadence_h, 0.5))) :]
]
return {
"ok": True,
"asset": ASSET,
"cadence_hours": round(cadence_h, 2),
"coverage_days": round(span_days, 1),
"latest_rate_pct": round(latest, 5),
"last_24h_avg_pct": round(last_24h_avg, 5),
"cum_30d_pct": round(cum_30d_pct, 3),
"extreme_threshold_pct": round(FUNDING_EXTREME_THRESHOLD * 100, 3),
"signal_fired": fired,
"debug": debug,
"history": history,
}
+146
View File
@@ -0,0 +1,146 @@
"""
200-day SMA Reclaim scanner.
What it catches:
The moment a price that has been LIVING BELOW its 200-day moving average
for a sustained period climbs back ABOVE it on real volume. Historically
one of the most reliable "trend has changed" markers in any market —
hedge fund books, retail TA tools, momentum quants, everyone watches it.
Examples this would have caught:
BTC 2023-01 (~$22k, after the FTX flush)
BTC 2024-09 (after Q3 chop)
ETH 2023-01 (~$1500)
SOL 2023-02 (~$24, after FTX)
Trigger logic:
PRE-CONDITION: For the past DAYS_BELOW_REQUIRED days, daily close has been
BELOW the rolling 200-day SMA. (proves we're reversing a
sustained downtrend, not crossing a flat MA in chop)
TRIGGER: Today's close > 200-day SMA, AND
Today's volume > 1.3 × 30-day avg volume.
COOLDOWN: 30 days — false reclaims and shake-outs happen, don't
re-fire on noise.
Companion exit profile:
SL = 6%
TRAILING_ACTIVATE = 12%
TRAILING_STOP = 5%
MAX_HOLD = 90 days
The 90-day max-hold matches the holding period needed for a real trend
change to play out (~3 months is the historical median for a confirmed
200d-SMA-reclaim trend run).
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# LIBRARY MODULE — NOT a standalone scanner. evaluate_sma_reclaim() is
# imported by btc_bottom_reversal.py as the price-reclaim entry gate. It
# deliberately does NOT register with scanner_state: no UI toggle, no
# schedule. (Old standalone scan_once/_emit_signal removed — see git log.)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
SMA_PERIOD = 200
DAYS_BELOW_REQUIRED = 30 # how long the asset must have been under SMA
VOLUME_LOOKBACK_DAYS = 30
VOLUME_MULT_MIN = 1.3
DAYS_TO_FETCH = 260 # SMA(200) + 30d-below check + safety margin
COOLDOWN_DAYS = 30
PAYLOAD_CONFIDENCE = 85
PAYLOAD_EXPECTED_MOVE = 20.0 # historical median 90-day run after reclaim
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_sma_reclaim(daily_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Expects `daily_candles` ordered chronologically (oldest first), each
having keys close, volume.
"""
if len(daily_candles) < SMA_PERIOD + DAYS_BELOW_REQUIRED + 2:
return False, {"reason": "insufficient_data", "bars": len(daily_candles)}
closes = [c["close"] for c in daily_candles]
volumes = [c["volume"] for c in daily_candles]
# Rolling 200-day SMA at each bar from index SMA_PERIOD-1 onwards
smas: list[Optional[float]] = [None] * len(closes)
running_sum = sum(closes[:SMA_PERIOD])
smas[SMA_PERIOD - 1] = running_sum / SMA_PERIOD
for i in range(SMA_PERIOD, len(closes)):
running_sum += closes[i] - closes[i - SMA_PERIOD]
smas[i] = running_sum / SMA_PERIOD
today_close = closes[-1]
today_sma = smas[-1]
if today_sma is None:
return False, {"reason": "sma_not_computable"}
# Bottom-reversal mode is LONG-only:
# reclaim (long): was BELOW the SMA for N days, today closes ABOVE
# We explicitly do not trade symmetric short breakdowns here. Crypto
# top-calling is a different strategy with different risk.
reclaimed = today_close > today_sma
brokedown = today_close < today_sma
if brokedown:
return False, {
"reason": "shorts_disabled",
"close": round(today_close, 4),
"sma": round(today_sma, 4),
}
if not reclaimed:
return False, {"reason": "on_sma_no_cross",
"close": round(today_close, 4), "sma": round(today_sma, 4)}
# Prior DAYS_BELOW_REQUIRED bars must ALL be on the OPPOSITE side of the
# SMA from today (a real regime flip, not chop around a flat MA).
streak = 0
for i in range(2, DAYS_BELOW_REQUIRED + 2):
sma_at = smas[-i]
if sma_at is None:
return False, {"reason": "sma_history_incomplete"}
prior_on_wrong_side = closes[-i] >= sma_at
if prior_on_wrong_side:
return False, {"reason": "regime_period_too_short", "broke_at_day": i}
streak += 1
# Volume confirmation: today >= VOLUME_MULT_MIN × 30-day avg
avg_vol_30d = sum(volumes[-(VOLUME_LOOKBACK_DAYS + 1):-1]) / VOLUME_LOOKBACK_DAYS
if avg_vol_30d <= 0:
return False, {"reason": "no_volume_baseline"}
vol_ratio = volumes[-1] / avg_vol_30d
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": "buy",
"close": round(today_close, 4),
"sma_200": round(today_sma, 4),
"gap_pct": round(abs(today_close - today_sma) / today_sma * 100, 2),
"streak_days": streak,
"vol_ratio": round(vol_ratio, 2),
}
+430
View File
@@ -0,0 +1,430 @@
"""
Two trading systems, one execution layer.
This module is the formal boundary between:
SYSTEM 1 — Trump (event-driven scalp)
source == "truth". Immediate market reaction to a post. Tight stops,
short hold, time-stop if no move. Uses the USER's configured exit
params (take_profit_pct / stop_loss_pct / trailing_*). Goes through
the Trump-tuned regime filter (thin-liquidity hours, recent-move,
vol-contraction).
SYSTEM 2 — Bitcoin Bottom (low-frequency bottom-reversal long)
source == "btc_bottom_reversal". Multi-week holds, wide trailing,
signal-invalidation exits.
Exit params come from THIS module (per category), NOT the user.
BYPASSES the Trump regime filter — those gates actively reject valid
reversal setups (a reclaim day IS a >5% move; reversals happen on
volatility EXPANSION not contraction).
The two systems share only the low-level execution plumbing (HL connector,
position monitor, reconciler, DB). Everything strategic is separated here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# Sources that belong to System 2. Everything else is either System 1 ("truth")
# or unsupported for live trading.
SYSTEM_2_SOURCES = {
"btc_bottom_reversal",
}
SYSTEM_1_SOURCES = {"truth"}
SUPPORTED_TRADING_SOURCES = SYSTEM_1_SOURCES | SYSTEM_2_SOURCES
def is_system_2(source: str) -> bool:
"""True if this signal should run through the reversal pipeline."""
return (source or "").lower() in SYSTEM_2_SOURCES
def is_supported_trading_source(source: str) -> bool:
"""Fail closed for unknown ingest sources instead of treating them as Trump."""
return (source or "").lower() in SUPPORTED_TRADING_SOURCES
def system2_display_name() -> str:
return "Bitcoin Bottom"
def system2_min_confidence() -> int:
return 85
# ── System 1 (Trump) — REPOSITIONED ─────────────────────────────────────────
# Originally "high-frequency, many small bets". Repositioned per operator
# decision to: LOW frequency, SELECTIVE, tight stop, ≥30-min holds.
#
# - Don't trade every post. A Trump trade puts the system on cooldown so
# the next N hours of posts are ignored — only the FIRST qualifying
# high-conviction post in a quiet window gets acted on.
# - Raise the confidence floor: only the very top posts clear the bar.
# - Tight stop, ALWAYS active (capital protection — a post that triggers
# an immediate -1.5% means we read it wrong, get out).
# - But on the winning side, hold ≥30 min: suppress take-profit / trailing
# exits until the floor elapses so we don't scalp out of a developing
# move. Asymmetric by design: losers cut fast, winners get room.
TRUMP_MIN_CONFIDENCE = 88 # was effectively the user's 70-85 setting
TRUMP_COOLDOWN_HOURS = 12 # after a Trump trade, ignore further Trump
# signals this long (the "don't trade every
# opportunity" lever)
TRUMP_MIN_HOLD_MINUTES = 30 # no TP / trailing exit before this; hard SL
# stays active throughout
TRUMP_STOP_LOSS_PCT = 1.5 # tight, kept
TRUMP_MAX_HOLD_HOURS = 6 # backstop force-close
@dataclass(frozen=True)
class CategoryExit:
"""Exit profile for a System-2 signal category.
These OVERRIDE the user's per-subscription exit params. Rationale: the
user picks risk for the Trump scalp; the reversal system's risk is a
property of the SIGNAL TYPE (an RSI capitulation reversal needs 60 days
to play out no matter what the user set their Trump stop-loss to).
Fields:
stop_loss_pct : hard stop, % adverse in position direction
trailing_activate_at_pct : peak gain that arms the trailing stop
trailing_stop_pct : retrace-from-peak that closes once armed
max_hold_hours : backstop force-close
time_stop_hours : if position is still ~flat after this many
hours, the thesis is slow/dead — close.
None = no time stop (pure trend capture).
invalidation : symbolic fast-exit condition. Interpreted
by tp_sl_monitor. None = stop-loss only.
"below_entry" → exit if price crosses back
through the entry (signal thesis dead).
"""
stop_loss_pct: float
trailing_activate_at_pct: Optional[float]
trailing_stop_pct: Optional[float]
max_hold_hours: int
time_stop_hours: Optional[int] = None
invalidation: Optional[str] = None
# Staged stop-loss ladder (System-2, optional). A list of
# (peak_gain_trigger_pct, new_stop_floor_pct) rungs. As the trade's PEAK
# gain crosses each trigger, the stop FLOOR ratchets up to the rung's
# value (signed gain% vs entry: negative = still a loss, 0 = breakeven,
# positive = locked-in profit). The position is NEVER closed for hitting
# a profit target — it only ever exits when price falls back to the
# current staged floor (or max-hold). This is a pure staged stop, not a
# take-profit and not a from-peak trailing stop. When set, it OVERRIDES
# take_profit_pct + trailing_* for that trade.
stop_ladder: Optional[list] = None
# ── Per-category exit profiles ──────────────────────────────────────────────
# Tuned to each signal's natural time horizon. These are STARTING points —
# refine against forward-test data, not intuition.
_CATEGORY_EXITS: dict[str, CategoryExit] = {
# Weekly RSI capitulation reversal — slowest, biggest target.
"rsi_extreme_reversal": CategoryExit(
stop_loss_pct=8.0, trailing_activate_at_pct=15.0,
trailing_stop_pct=6.0, max_hold_hours=1440, # 60 days
),
# 200d SMA reclaim — trend change. Fast exit if it loses the SMA again.
"sma_reclaim": CategoryExit(
stop_loss_pct=6.0, trailing_activate_at_pct=12.0,
trailing_stop_pct=5.0, max_hold_hours=2160, # 90 days
invalidation="below_entry", # reclaim failed → thesis dead
),
# Funding extreme unwind — faster, days not weeks.
"funding_extreme_reversal": CategoryExit(
stop_loss_pct=4.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=5.0, max_hold_hours=720, # 30 days
time_stop_hours=240, # 10d flat → squeeze didn't materialise
),
# BTC bottom-reversal long — 2-of-3 price confluence (AHR999 + 200WMA +
# Pi Cycle Bottom). NO take-profit, NO from-peak trailing. The ONLY exit
# is a STAGED stop-loss ("阶段止损") that ratchets up as the trade's peak
# gain crosses each rung, plus the 120-day max-hold backstop.
#
# The BASE catastrophic floor is NOT fixed here — it is derived per-trade
# from the chosen System-2 leverage (sys2_protective_stop_pct), so it
# always sits inside the exchange liquidation line. These rungs only
# ratchet that floor UP as peak gain grows (they never loosen it):
# peak ≥ 20% → stop -12%
# peak ≥ 40% → stop 0% (breakeven — free trade)
# peak ≥ 70% → stop +25% (lock profit)
# peak ≥ 110% → stop +55%
# peak ≥ 160% → stop +95%
#
# Rungs are deliberately far apart so normal post-bottom volatility does
# not knock it out, and it never sells just because a target was hit.
"btc_bottom_reversal_long": CategoryExit(
stop_loss_pct=35.0, # fallback only; bot_engine overrides per-lev
trailing_activate_at_pct=None,
trailing_stop_pct=None,
max_hold_hours=12960, # 18 months — a cycle bull runs 618mo;
# the ratchet/peak-trail is the real exit,
# the clock is just a far backstop.
invalidation=None,
stop_ladder=[
(20.0, -12.0),
(40.0, 0.0),
(70.0, 25.0),
(110.0, 55.0),
(160.0, 95.0),
],
),
# VCP breakout — short-term continuation.
"vcp_breakout": CategoryExit(
stop_loss_pct=3.0, trailing_activate_at_pct=6.0,
trailing_stop_pct=2.5, max_hold_hours=168, # 7 days
time_stop_hours=48,
),
}
# Fallback for a System-2 signal whose category isn't explicitly mapped —
# conservative medium profile so an un-mapped scanner doesn't trade huge.
_SYSTEM_2_DEFAULT = CategoryExit(
stop_loss_pct=5.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=4.0, max_hold_hours=336, # 14 days
time_stop_hours=120,
)
def get_exit_profile(category: Optional[str]) -> CategoryExit:
"""Resolve a System-2 category to its exit profile. Unknown → default."""
if not category:
return _SYSTEM_2_DEFAULT
return _CATEGORY_EXITS.get(category.lower(), _SYSTEM_2_DEFAULT)
# ── System-2 dynamic leverage ───────────────────────────────────────────────
# The user picks System-2 leverage freely. The protective stop is then
# auto-scaled to stay INSIDE the exchange liquidation line, so the position
# is de-risked by our own monitor and is never liquidated by the exchange.
#
# liquidation distance (price move) ≈ 100 / leverage (%)
# protective full-exit stop = 85% of that (15% maint/fee/funding buffer)
# …but never wider than SYS2_MAX_STOP_PCT — the bottom thesis only needs to
# tolerate a ~30% wick; risking more buys nothing.
#
# Net effect:
# lev ≤ 2x → stop = 35% (full bottom-wick tolerance, the original design)
# lev = 3x → stop ≈ 28%
# lev = 5x → stop ≈ 17%
# lev = 10x→ stop ≈ 8.5% (you WILL get shaken out by a normal wick — shown
# to the user as the explicit cost of high leverage)
SYS2_DEFAULT_LEVERAGE = 2
SYS2_MIN_LEVERAGE = 1
SYS2_MAX_LEVERAGE = 10
SYS2_MAX_STOP_PCT = 35.0
SYS2_LIQ_BUFFER = 0.85 # exit at 85% of the way to liquidation
# ── System-2 risk MODE ──────────────────────────────────────────────────────
# Two opt-in profiles, frozen onto the trade at signal time. STANDARD is the
# tuned cycle-rider (low leverage, survive bull corrections). AGGRESSIVE is a
# separately-funded high-risk/high-explosiveness sleeve: high leverage, heavier
# + earlier pyramiding, wider peak-trail, lighter early de-risk. BOTH keep the
# two safety invariants: (1) final de-risk rung is a full close INSIDE the
# liquidation line — never exchange-liquidated; (2) post-pyramid breakeven
# floor — a winner can't become a loser.
SYS2_MODE_STANDARD = "standard"
SYS2_MODE_AGGRESSIVE = "aggressive"
SYS2_MODES = (SYS2_MODE_STANDARD, SYS2_MODE_AGGRESSIVE)
# Default leverage when the user hasn't set an explicit sys2_leverage.
SYS2_MODE_DEFAULT_LEV = {SYS2_MODE_STANDARD: 2, SYS2_MODE_AGGRESSIVE: 8}
def sys2_normalize_mode(mode: Optional[str]) -> str:
m = (mode or "").strip().lower()
return m if m in SYS2_MODES else SYS2_MODE_STANDARD
def sys2_effective_leverage(value: Optional[int],
mode: Optional[str] = SYS2_MODE_STANDARD) -> int:
"""Resolve + clamp System-2 leverage. None → the mode's default
(standard 2×, aggressive 8×). Always clamped to [1, 10]."""
m = sys2_normalize_mode(mode)
default = SYS2_MODE_DEFAULT_LEV[m]
if value is None:
return default
try:
v = int(value)
except (TypeError, ValueError):
return default
return max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, v))
def sys2_protective_stop_pct(leverage: int) -> float:
"""Protective full-exit distance (positive %), auto-scaled to leverage so
it always triggers INSIDE the exchange liquidation line."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
liq_distance_pct = 100.0 / lev
return round(min(SYS2_MAX_STOP_PCT, SYS2_LIQ_BUFFER * liq_distance_pct), 2)
def sys2_approx_liquidation_pct(leverage: int) -> float:
"""Rough exchange liquidation distance (positive %) for UX display."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
return round(100.0 / lev, 2)
# Staged de-risk ("分段式减仓"): instead of one full exit at the protective
# stop, scale OUT in three steps as the trade moves against us. The final
# step is exactly the Phase-1 protective level P (inside liquidation), so the
# never-exchange-liquidated guarantee is preserved — we just bleed out in
# thirds rather than all at once.
#
# 0.60·P → close 1/3 of the original notional
# 0.80·P → close another 1/3
# 1.00·P → close the remaining 1/3 (full exit; same safety as Phase 1)
SYS2_DERISK_FRACTIONS = (0.60, 0.80, 1.00)
# Pyramiding ("做对了往上加仓"): when the bottom call is RIGHT and the trend
# confirms, scale INTO the winner to amplify the move. Mirror of the de-risk
# ladder. Conservative sizing (user-selected): adds at most +0.6× the base
# notional. Each rung requires BOTH a peak-gain trigger AND a structural
# trend confirmation (price ≥ 200d SMA AND at a fresh local high), checked at
# execution time. Pyramiding is DISABLED once any de-risk step has fired
# (a trade that went underwater is not a clean uptrend to add into).
# Extended for a cycle bull: deeper continuation rungs so a multi-x move is
# actually scaled. Each add still needs structural confirmation (price ≥ 200d
# SMA AND a fresh high) so the deep rungs only fire in a genuine sustained
# uptrend, never on a chop fakeout. Total adds ≤ +0.75× base — still well
# inside the 8× notional cap; amplification stays conservative.
SYS2_ADDON_PEAK_TRIGGERS = (25.0, 50.0, 85.0, 140.0, 220.0) # peak-gain % vs blended entry
SYS2_ADDON_FRACTIONS = (0.30, 0.20, 0.10, 0.10, 0.05) # of ORIGINAL base notional
# AGGRESSIVE sleeve: earlier + heavier adds (≤ +1.50× base), so a clean
# multi-x run is meaningfully compounded. Still gated by the same structural
# confirmation (≥200d SMA + fresh high), still inside the per-trade 8×
# notional cap, still floored at breakeven once any add fills.
SYS2_AGGR_ADDON_PEAK_TRIGGERS = (15.0, 35.0, 60.0, 100.0, 160.0)
SYS2_AGGR_ADDON_FRACTIONS = (0.50, 0.40, 0.30, 0.20, 0.10)
# AGGRESSIVE de-risk: shed less early (¼/¼) so a normal correction doesn't
# gut the runner; final rung still a FULL close at the protective level.
SYS2_AGGR_DERISK_STEP_FRACS = (0.25, 0.25, 0.50)
SYS2_STD_DERISK_STEP_FRACS = (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
# Peak-% trailing for the parabolic top. Below the start threshold the fixed
# stop_ladder rungs govern; at/above it the floor also trails the PEAK PRICE
# by at most SYS2_PEAK_TRAIL_DD (price drawdown, scale-invariant) so a +500%
# move isn't capped at the +95% rung, while a normal ~2530% bull correction
# still doesn't knock it out. Floor = max(rung floor, peak-trail floor).
SYS2_PEAK_TRAIL_START_PCT = 80.0 # activate once peak gain ≥ +80%
SYS2_PEAK_TRAIL_DD = 0.30 # give back at most 30% of peak PRICE
# AGGRESSIVE: let it run longer before the trailing top kicks in, and give
# back more, so a multi-x parabolic isn't cut early by a mid-run shakeout.
SYS2_AGGR_PEAK_TRAIL_START_PCT = 60.0
SYS2_AGGR_PEAK_TRAIL_DD = 0.42
def sys2_addon_ladder(mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Pyramiding rungs: (peak_gain_trigger_pct, frac_of_base, is_last)."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
trigs, fracs = SYS2_AGGR_ADDON_PEAK_TRIGGERS, SYS2_AGGR_ADDON_FRACTIONS
else:
trigs, fracs = SYS2_ADDON_PEAK_TRIGGERS, SYS2_ADDON_FRACTIONS
n = len(trigs)
return [(trigs[i], fracs[i], i == n - 1) for i in range(n)]
def sys2_peak_trail(mode: Optional[str] = SYS2_MODE_STANDARD) -> tuple:
"""(activate_peak_gain_pct, price_drawdown_frac) for the parabolic-top
trailing floor. Scale-invariant: works the same at +120% or +900%."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
return (SYS2_AGGR_PEAK_TRAIL_START_PCT, SYS2_AGGR_PEAK_TRAIL_DD)
return (SYS2_PEAK_TRAIL_START_PCT, SYS2_PEAK_TRAIL_DD)
def sys2_derisk_ladder(leverage: int,
mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Downside staged de-risk rungs for the given leverage.
Returns a list of (threshold_signed_pct, frac_of_ORIGINAL_to_close,
is_final), ordered by increasing adversity. threshold is NEGATIVE
(a loss in position direction). The executor converts frac-of-original
into frac-of-current using the trade's remaining_fraction.
"""
p = sys2_protective_stop_pct(leverage)
steps = (SYS2_AGGR_DERISK_STEP_FRACS
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE
else SYS2_STD_DERISK_STEP_FRACS)
return [
(round(-SYS2_DERISK_FRACTIONS[0] * p, 4), steps[0], False),
(round(-SYS2_DERISK_FRACTIONS[1] * p, 4), steps[1], False),
(round(-SYS2_DERISK_FRACTIONS[2] * p, 4), steps[2], True),
]
def get_stop_ladder(category: Optional[str]) -> Optional[list]:
"""Staged stop-loss ladder for a category, or None if it uses trailing.
Sorted ascending by trigger so the monitor can walk it cheaply. The
ladder is a CODE constant (not frozen per-trade): if it's retuned, open
positions adopt the new rungs on the next price tick / restart. That is
intentional — staged-stop levels are a property of the strategy, not of
an individual fill.
"""
prof = get_exit_profile(category)
if not prof.stop_ladder:
return None
return sorted(prof.stop_ladder, key=lambda r: r[0])
# ── System-2 position sizing ────────────────────────────────────────────────
# CRITICAL: System 2 must NOT use regime_filter.calculate_size_multiplier.
# That function asks "is volatility contracted? has price NOT moved?" — both
# FALSE during a reversal (vol expands, price already moved), which would
# SHRINK our rarest, highest-conviction trades. Sizing here is a function of
# (category conviction × signal confidence), nothing else.
# Base conviction per category. Rarer + cleaner setup → bigger base bet.
_CATEGORY_SIZE_BASE: dict[str, float] = {
"rsi_extreme_reversal": 2.5, # ~1-2×/yr/asset, deepest capitulation
"sma_reclaim": 2.0, # clean trend-change marker
"funding_extreme_reversal": 1.4, # more frequent, choppier unwinds
"btc_bottom_reversal_long": 2.3, # 2-of-3 price confluence (AHR999/200WMA/Pi)
"vcp_breakout": 1.0, # continuation, lowest conviction
}
_SYSTEM_2_SIZE_BASE_DEFAULT = 1.2
SYSTEM_2_SIZE_CAP = 4.0
# ── System-2 correlation / concentration cap ────────────────────────────────
# Every asset in REVERSAL_BASKET is high-beta crypto. When the market bottoms,
# RSI/SMA/funding reversals fire on BTC + ETH + SOL in the SAME week — these
# are NOT independent bets, they're one "crypto reversed" thesis. With Fix #1
# sizing each up to 4x, 3 correlated positions = ~10x effective exposure to a
# single macro call. Treat the whole System-2 book as one correlated bucket
# and cap it.
#
# - SYS2_MAX_CONCURRENT: at most this many open System-2 positions at once.
# Beyond this you're not diversifying, just leveraging the same thesis.
# - SYS2_MAX_OPEN_NOTIONAL_MULT: total open System-2 notional must stay
# under this × the wallet's base position_size_usd × default-ish size.
# Acts as a $ ceiling independent of how many positions.
SYS2_MAX_CONCURRENT = 3
SYS2_MAX_OPEN_NOTIONAL_MULT = 8.0 # × base position_size_usd
def system2_size_multiplier(category: Optional[str], confidence: int) -> float:
"""Position multiplier for a System-2 signal.
base(category) × confidence_scale, capped at SYSTEM_2_SIZE_CAP.
confidence_scale: 70→1.0, 85→1.3, 95→1.5, 100→1.6 (linear, floored at 1.0).
A scanner that emits confidence 88 for an rsi_extreme_reversal therefore
sizes 2.5 × 1.36 ≈ 3.4×. Tune the base table against forward-test data.
"""
base = _CATEGORY_SIZE_BASE.get((category or "").lower(), _SYSTEM_2_SIZE_BASE_DEFAULT)
conf_scale = 1.0 + max(0, confidence - 70) / 50.0
return round(min(base * conf_scale, SYSTEM_2_SIZE_CAP), 2)
# ── BTC bottom-reversal ─────────────────────────────────────────────────────
# The old MVRV-Z / STH-SOPR / drawdown state machine was REMOVED. It depended
# on paid on-chain data and was over-engineered. The strategy is now a pure
# 2-of-3 price confluence (AHR999 + 200-Week MA + Pi Cycle Bottom), implemented
# in app/services/bottom_indicators.py and driven by the btc_bottom_reversal
# scanner. There is no state machine and no on-chain dependency.
+280
View File
@@ -0,0 +1,280 @@
"""
Telegram push alerts — send + signal dispatcher.
This module is fire-and-forget by design. `notify_signal()` returns immediately
to the caller (signal ingestion path) and dispatches in a background task. A
DB failure or Telegram API error MUST NOT block a signal from being saved.
The bot token is loaded from `settings.telegram_bot_token`. If empty, every
function in this module degrades into a no-op (and logs once at module load).
notify_signal(post) ← called from /api/signals/ingest
send_test_message(wallet) ← called from /api/telegram/test
send_message(chat_id, text) ← low-level escape hatch
Source → user-toggle mapping:
truth → alert_trump
btc_bottom_reversal → alert_btc_bottom
funding_reversal → alert_funding
kol_divergence (future) → alert_kol_divergence
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal as async_session
from app.models import Post, TelegramBinding
logger = logging.getLogger(__name__)
TG_API = "https://api.telegram.org/bot{token}/{method}"
# Telegram caps a single message at 4096 chars; we render way below this.
MAX_LEN = 3500
# ── Source → preference column mapping ────────────────────────────────────
def _pref_column_for_source(source: str) -> Optional[str]:
"""Which user-toggle column gates this source. None → unknown source,
don't send."""
if source == "truth":
return "alert_trump"
if source == "btc_bottom_reversal":
return "alert_btc_bottom"
if source == "funding_reversal":
return "alert_funding"
if source == "kol_divergence":
return "alert_kol_divergence"
return None
def _is_in_mute_window(now_hour: int, mute_from: Optional[int],
mute_until: Optional[int]) -> bool:
"""Both null → never mute. start<until → mute inside [start, until).
start>until → window wraps midnight (e.g. 23..7 → mute 23, 06)."""
if mute_from is None or mute_until is None:
return False
if mute_from == mute_until:
return False
if mute_from < mute_until:
return mute_from <= now_hour < mute_until
# wraps midnight
return now_hour >= mute_from or now_hour < mute_until
# ── Formatting ────────────────────────────────────────────────────────────
def _signal_emoji(post: Post) -> str:
if post.signal == "buy":
return "🟢"
if post.signal == "short":
return "🔴"
return ""
def _source_label(source: str) -> str:
return {
"truth": "Trump · Truth Social",
"btc_bottom_reversal": "BTC · Macro Bottom",
"funding_reversal": "BTC · Funding Reversal",
"kol_divergence": "KOL · Talks vs Trades",
}.get(source, source)
def format_post(post: Post) -> str:
"""Render a Post into a single Telegram message (HTML parse mode).
Keep it scannable: heading, one-line verdict, the underlying text, link."""
emoji = _signal_emoji(post)
src = _source_label(post.source)
sig = (post.signal or "noise").upper()
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>"
sub = f"<i>{src}</i>"
body = (post.text or "").strip()
if len(body) > 600:
body = body[:600].rstrip() + ""
# Move size hint if present (BTC bottom & funding emit expected_move_pct)
extra = ""
if post.expected_move_pct is not None:
extra = f"\n📐 expected move: <b>{post.expected_move_pct:+.1f}%</b>"
if post.invalidation_price is not None:
extra += f"\n🛑 invalidation @ <code>{post.invalidation_price:g}</code>"
# Deep-link back to the dashboard. Frontend URL comes from settings.
fe = (settings.frontend_url or "").rstrip("/")
link = ""
if fe:
# Use the section that matches the source
path = {
"truth": "/en/trump",
"btc_bottom_reversal": "/en/btc",
"funding_reversal": "/en/btc",
"kol_divergence": "/en/kol",
}.get(post.source, "/en")
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
msg = f"{head}\n{sub}\n\n{body}{extra}{link}"
return msg[:MAX_LEN]
# ── Low-level HTTP ────────────────────────────────────────────────────────
async def send_message(chat_id: int, text: str, *,
parse_mode: str = "HTML",
disable_preview: bool = True) -> bool:
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
any failure (caller decides whether to bump the failure counter)."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="sendMessage")
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json={
"chat_id": chat_id, "text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_preview,
})
if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s",
chat_id, r.status_code, r.text[:200])
return False
return True
except Exception as exc:
logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc)
return False
# ── Dispatcher ────────────────────────────────────────────────────────────
async def _dispatch(post_id: int) -> None:
"""Fan-out a single Post to every eligible subscriber. Always runs in
its own DB session so signal ingestion's session is unaffected."""
if not settings.telegram_bot_token:
return
async with async_session() as db:
post = await db.get(Post, post_id)
if not post:
logger.warning("Telegram dispatch: post id=%d not found", post_id)
return
# Only fan out for actionable signals (not NOISE / null)
if not post.signal or post.signal not in ("buy", "short"):
return
pref_col = _pref_column_for_source(post.source)
if pref_col is None:
logger.debug("Telegram: unknown source %r — not fanning out", post.source)
return
# Build the query: alerts_enabled + the relevant per-source flag.
# min_confidence applies to every source — scanner-emitted signals
# carry their own confidence in the Post.ai_confidence column.
col = getattr(TelegramBinding, pref_col)
base_filters = [
TelegramBinding.alerts_enabled.is_(True),
col.is_(True),
]
# Only apply confidence gate when the post has a real score (> 0).
# Scanner-generated signals (funding, BTC) always carry a score, but
# a Truth-Social post might be dispatched before Claude scores it (score=0).
# In that edge case we let it through so no alert is silently swallowed.
if post.ai_confidence and post.ai_confidence > 0:
base_filters.append(TelegramBinding.min_confidence <= post.ai_confidence)
q = select(TelegramBinding).where(*base_filters)
bindings = (await db.execute(q)).scalars().all()
if not bindings:
return
text = format_post(post)
now = datetime.now(timezone.utc)
hour = now.hour
sent = 0
for b in bindings:
if _is_in_mute_window(hour, b.mute_from_hour, b.mute_until_hour):
continue
ok = await send_message(b.chat_id, text)
# Update audit counters per binding. Single UPDATE per row keeps
# us out of trouble if one user blocks the bot.
if ok:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
last_alert_at=now,
total_alerts_sent=TelegramBinding.total_alerts_sent + 1,
)
)
sent += 1
else:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
total_alerts_failed=TelegramBinding.total_alerts_failed + 1,
)
)
await db.commit()
logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d",
post_id, post.source, sent, len(bindings))
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
back to a no-op if telegram is disabled or no event loop is running.
We pass post.id rather than the post object because the caller's DB
session might close before our background task runs."""
if not settings.telegram_bot_token:
return
if not post or not post.id:
return
try:
asyncio.create_task(_dispatch(post.id))
except RuntimeError:
# No running loop — extremely unusual in our FastAPI context.
logger.warning("notify_signal: no running event loop, skipping post=%s", post.id)
async def send_test_message(wallet: str) -> bool:
"""Triggered from the Settings UI to verify the binding works end-to-end."""
if not settings.telegram_bot_token:
return False
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet.lower())
)).scalar_one_or_none()
if not b:
return False
return await send_message(
b.chat_id,
"✅ <b>Trump Alpha connected.</b>\n\n"
"You'll get push alerts here when signals fire. "
"Use /trump /btc /funding /kol /conf /quiet in this chat to tune "
"which sources and thresholds apply to you.",
)
if not settings.telegram_bot_token:
logger.info("Telegram bot token not set — push alerts disabled.")
+564
View File
@@ -0,0 +1,564 @@
"""
Telegram bot worker — long-polls getUpdates and handles user commands.
Commands supported:
/start CODE → bind this chat_id to the wallet that owns CODE
/start → friendly hello with instructions
/stop → disable alerts (keeps the binding so re-enable is one tap)
/status → show binding status and current preferences
/test → send self a test message to verify formatting
This runs as a single background asyncio task started from main.py lifespan.
Only one instance should run at a time — Telegram's long-poll model assumes
exactly one consumer per bot token. If you horizontally scale the API, switch
to webhook mode (see set_webhook in the Bot API).
The one-time binding codes live in a process-local dict with a 10-minute
TTL. On API restart all pending codes evaporate (the user re-clicks Connect).
This is intentional — codes never touch the DB so a compromised dump can't
hijack future bindings.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal as async_session
from app.models import TelegramBinding, Subscription
from app.services.telegram import send_message, TG_API
logger = logging.getLogger(__name__)
# ── One-time binding codes ────────────────────────────────────────────────
_CODE_TTL_SECONDS = 10 * 60
_CODE_ALPHABET = string.ascii_uppercase + string.digits # 36^6 ≈ 2.1B
_CODE_LEN = 6
# code → (wallet_lower, expires_at)
_pending_codes: dict[str, tuple[str, datetime]] = {}
def _purge_expired_codes() -> None:
now = datetime.now(timezone.utc)
expired = [c for c, (_, exp) in _pending_codes.items() if exp < now]
for c in expired:
_pending_codes.pop(c, None)
def issue_binding_code(wallet: str) -> str:
"""Generate a fresh 6-char code for a wallet. If the same wallet calls
twice within the TTL, the old code is invalidated — only the latest
code works (prevents stale shared links)."""
_purge_expired_codes()
wallet_l = wallet.lower()
# Invalidate any previous code for this wallet
for c, (w, _) in list(_pending_codes.items()):
if w == wallet_l:
_pending_codes.pop(c, None)
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
exp = datetime.now(timezone.utc) + timedelta(seconds=_CODE_TTL_SECONDS)
_pending_codes[code] = (wallet_l, exp)
return code
def _consume_code(code: str) -> Optional[str]:
"""Resolve and remove a code. Returns the wallet, or None if invalid/expired."""
_purge_expired_codes()
rec = _pending_codes.pop(code.upper(), None)
if rec is None:
return None
return rec[0]
# ── Command handlers ──────────────────────────────────────────────────────
HELP_TEXT = (
"👋 <b>Trump Alpha bot</b>\n\n"
"Push alerts for high-conviction crypto signals — Trump posts, BTC bottom "
"reversals, funding extremes, KOL divergence.\n\n"
"<b>Just press /start</b> — you're subscribed. No wallet needed.\n\n"
"<b>Configure:</b>\n"
" /trump on|off Trump · Truth Social posts\n"
" /btc on|off BTC · macro bottom\n"
" /funding on|off BTC · funding reversal\n"
" /kol on|off KOL · talks vs trades\n"
" /conf 0-100 min AI confidence (Trump only)\n"
" /quiet 23 7 mute hours UTC (or 'off' to disable)\n\n"
"<b>Status &amp; control:</b>\n"
" /status — show your preferences\n"
" /stop — pause all alerts\n"
" /test — send a sample alert\n\n"
"<b>Pro features</b> (auto-trade on Hyperliquid):\n"
"Open the dashboard → <i>Settings</i> → <i>Connect Telegram</i> to link "
"your wallet."
)
# Default preferences for a freshly-created walletless binding.
_DEFAULT_PREFS = {
"alerts_enabled": True,
"alert_trump": True,
"alert_btc_bottom": True,
"alert_funding": True,
"alert_kol_divergence": False,
"min_confidence": 70,
}
async def _ensure_binding(chat_id: int, username: Optional[str]) -> TelegramBinding:
"""Create or refresh a walletless binding for this chat. Returns the
binding (always with id set)."""
async with async_session() as db:
existing = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if existing:
# Refresh username + re-enable alerts on subsequent /start
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing.id)
.values(tg_username=username, alerts_enabled=True)
)
await db.commit()
return existing
b = TelegramBinding(
wallet_address=None,
chat_id=chat_id,
tg_username=username,
**_DEFAULT_PREFS,
)
db.add(b)
await db.commit()
await db.refresh(b)
return b
async def _cmd_start(chat_id: int, username: Optional[str], arg: str) -> None:
"""
/start → free-tier walletless binding (anyone can subscribe)
/start CODE → upgrade to wallet-linked binding (Pro features)
"""
arg = arg.strip()
# No arg → free-tier subscribe. Always idempotent.
if not arg:
b = await _ensure_binding(chat_id, username)
if b.wallet_address:
await send_message(
chat_id,
"✅ Already linked — you're getting alerts.\n"
"Send /status to see your settings, /help for commands.",
)
else:
await send_message(
chat_id,
"🎉 <b>You're subscribed!</b>\n\n"
"You'll get alerts for: Trump posts, BTC bottom signals, "
"funding reversals. KOL divergence is off by default (noisier).\n\n"
"Type /help to see every command, or /test to preview an alert.",
)
return
# Arg present → wallet-binding flow (Pro)
wallet = _consume_code(arg)
if not wallet:
await send_message(
chat_id,
"❌ <b>Invalid or expired code.</b>\n\n"
"The 6-char code only comes from the dashboard's "
"<i>Settings → Connect Telegram</i> panel — and it expires after "
"10 minutes.\n\n"
"Don't need wallet features? Just send /start (no code) — alerts "
"are free for everyone.",
)
return
async with async_session() as db:
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if not sub or not sub.active:
await send_message(
chat_id,
"⚠️ <b>This wallet isn't subscribed yet.</b>\n\n"
"Open Settings on the dashboard, click <i>Start trading</i>, "
"then come back and re-connect.",
)
return
existing_by_wallet = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
existing_by_chat = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
# Edge: chat already bound to a DIFFERENT wallet — reject loudly.
if existing_by_chat and existing_by_chat.wallet_address and \
existing_by_chat.wallet_address != wallet:
await send_message(
chat_id,
"❌ This Telegram account is already bound to a different "
"wallet. Run /stop on that wallet first (or unbind via the "
"dashboard).",
)
return
if existing_by_wallet and existing_by_wallet.chat_id != chat_id:
# Wallet was previously bound to a different chat — move it here.
# Also clean up any walletless binding for this chat to avoid a
# duplicate row.
if existing_by_chat:
from sqlalchemy import delete as _delete
await db.execute(_delete(TelegramBinding).where(
TelegramBinding.id == existing_by_chat.id))
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing_by_wallet.id)
.values(chat_id=chat_id, tg_username=username,
alerts_enabled=True,
bound_at=datetime.now(timezone.utc))
)
elif existing_by_chat:
# Free user is upgrading to wallet-linked — just attach the wallet
# to their existing walletless binding, keep their preferences.
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing_by_chat.id)
.values(wallet_address=wallet, tg_username=username,
alerts_enabled=True,
bound_at=datetime.now(timezone.utc))
)
else:
# Brand-new wallet + chat.
db.add(TelegramBinding(
wallet_address=wallet, chat_id=chat_id,
tg_username=username, **_DEFAULT_PREFS,
))
await db.commit()
short = wallet[:6] + "" + wallet[-4:]
await send_message(
chat_id,
f"✅ <b>Wallet linked: {short}</b>\n\n"
"Pro features unlocked. Your existing alert preferences are kept. "
"Manage everything from the dashboard's Settings page or via bot "
"commands. Send /status to see your current setup.",
)
# ── Preference commands ──────────────────────────────────────────────────
async def _set_pref(chat_id: int, field: str, value: bool | int) -> bool:
"""Returns True if the binding existed and was updated."""
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(**{field: value})
)
await db.commit()
return r.rowcount > 0
def _parse_on_off(arg: str) -> Optional[bool]:
a = arg.strip().lower()
if a in ("on", "yes", "enable", "1", "true"): return True
if a in ("off", "no", "disable", "0", "false"): return False
return None
async def _cmd_toggle(chat_id: int, field: str, label: str, arg: str) -> None:
v = _parse_on_off(arg)
if v is None:
await send_message(chat_id,
f"Usage: <code>{label.lower().split()[0]} on</code> or "
f"<code>{label.lower().split()[0]} off</code>")
return
ok = await _set_pref(chat_id, field, v)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
state = "🟢 ON" if v else "🔴 OFF"
await send_message(chat_id, f"{state} · {label}")
async def _cmd_conf(chat_id: int, arg: str) -> None:
try:
n = int(arg.strip())
if not 0 <= n <= 100:
raise ValueError
except ValueError:
await send_message(chat_id,
"Usage: <code>/conf 70</code> (0100). Only Trump posts above "
"this AI confidence will trigger alerts.")
return
ok = await _set_pref(chat_id, "min_confidence", n)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, f"Min confidence set to <b>{n}</b>.")
async def _cmd_quiet(chat_id: int, arg: str) -> None:
a = arg.strip().lower()
if a in ("off", "none", "disable", ""):
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(mute_from_hour=None, mute_until_hour=None)
)
await db.commit()
if not r.rowcount:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, "🔔 Quiet hours disabled.")
return
parts = arg.split()
if len(parts) != 2:
await send_message(chat_id,
"Usage: <code>/quiet 23 7</code> (UTC, e.g. mute 23:0007:00) "
"or <code>/quiet off</code>.")
return
try:
a_h, b_h = int(parts[0]), int(parts[1])
if not (0 <= a_h <= 23 and 0 <= b_h <= 23):
raise ValueError
except ValueError:
await send_message(chat_id, "Hours must be integers 023.")
return
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(mute_from_hour=a_h, mute_until_hour=b_h)
)
await db.commit()
if not r.rowcount:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, f"🌙 Quiet hours: {a_h:02d}:00{b_h:02d}:00 UTC.")
async def _cmd_stop(chat_id: int) -> None:
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(alerts_enabled=False)
)
await db.commit()
if r.rowcount:
await send_message(chat_id,
"🔕 Alerts paused. Send /start any time to re-enable.")
else:
await send_message(chat_id,
"You don't have an active binding here. Send /start to set one up.")
async def _cmd_status(chat_id: int) -> None:
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if not b:
await send_message(chat_id,
"Not subscribed yet. Send /start to begin (no wallet required).")
return
if b.wallet_address:
short = b.wallet_address[:6] + "" + b.wallet_address[-4:]
tier_line = f"Tier: <b>Pro</b> · wallet <code>{short}</code>"
else:
tier_line = "Tier: <b>Free</b> · no wallet linked"
on = "🟢 ON" if b.alerts_enabled else "🔴 OFF"
srcs = []
if b.alert_trump: srcs.append("Trump")
if b.alert_btc_bottom: srcs.append("BTC bottom")
if b.alert_funding: srcs.append("Funding")
if b.alert_kol_divergence: srcs.append("KOL")
src_line = ", ".join(srcs) or "(none — alerts will be silent)"
mute = ""
if b.mute_from_hour is not None and b.mute_until_hour is not None:
mute = f"\n🌙 Quiet hours: {b.mute_from_hour:02d}{b.mute_until_hour:02d} UTC"
await send_message(
chat_id,
f"📡 <b>Status</b>\n\n"
f"{tier_line}\n"
f"Alerts: {on}\n"
f"Sources: {src_line}\n"
f"Min confidence: {b.min_confidence}{mute}\n\n"
f"Sent: {b.total_alerts_sent} · Failed: {b.total_alerts_failed}\n\n"
f"Toggle anything with /trump /btc /funding /kol /conf /quiet — "
f"send /help for the full list.",
)
async def _cmd_test(chat_id: int) -> None:
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if not b:
await send_message(chat_id, "Send /start first to subscribe (no wallet needed).")
return
await send_message(
chat_id,
"🟢 <b>BTC · BUY</b> · conf <b>88</b>\n"
"<i>Trump · Truth Social</i>\n\n"
"BITCOIN is the FUTURE of money. America will LEAD the world in "
"crypto. BIG things coming very soon!\n\n"
"(this is a test message — your actual alerts will look like this)",
)
# ── Long-poll loop ────────────────────────────────────────────────────────
async def _handle_message(msg: dict) -> None:
chat = msg.get("chat") or {}
chat_id = chat.get("id")
if not chat_id:
return
text = (msg.get("text") or "").strip()
if not text:
return
from_user = msg.get("from") or {}
username = from_user.get("username")
# Parse first word as command
parts = text.split(maxsplit=1)
cmd = parts[0].lower()
arg = parts[1] if len(parts) > 1 else ""
# Normalize /command@botname (group-chat syntax) → /command
if "@" in cmd:
cmd = cmd.split("@", 1)[0]
try:
if cmd == "/start":
await _cmd_start(chat_id, username, arg)
elif cmd in ("/stop", "/pause"):
await _cmd_stop(chat_id)
elif cmd in ("/status", "/me"):
await _cmd_status(chat_id)
elif cmd == "/test":
await _cmd_test(chat_id)
elif cmd in ("/help", "/?"):
await send_message(chat_id, HELP_TEXT)
# ── source toggles ───────────────────────────────────────────
elif cmd == "/trump":
await _cmd_toggle(chat_id, "alert_trump", "Trump alerts", arg)
elif cmd == "/btc":
await _cmd_toggle(chat_id, "alert_btc_bottom", "BTC bottom alerts", arg)
elif cmd == "/funding":
await _cmd_toggle(chat_id, "alert_funding", "Funding reversal alerts", arg)
elif cmd == "/kol":
await _cmd_toggle(chat_id, "alert_kol_divergence", "KOL divergence alerts", arg)
# ── numeric / range prefs ────────────────────────────────────
elif cmd == "/conf":
await _cmd_conf(chat_id, arg)
elif cmd == "/quiet":
await _cmd_quiet(chat_id, arg)
else:
if cmd.startswith("/"):
await send_message(chat_id, "Unknown command. Send /help for the list.")
except Exception:
logger.exception("Telegram handler failed for chat=%s text=%r", chat_id, text[:80])
async def run_bot_loop() -> None:
"""Long-poll forever. Idempotent on offsets — Telegram serves the same
update twice only if we don't acknowledge it. Sleep on errors to avoid
hot-looping if the network is down."""
token = settings.telegram_bot_token
if not token:
logger.info("Telegram bot loop not started — TELEGRAM_BOT_TOKEN empty.")
return
logger.info("Telegram bot loop starting (long-poll mode).")
offset: Optional[int] = None
backoff = 1.0
while True:
try:
url = TG_API.format(token=token, method="getUpdates")
params: dict = {"timeout": 25}
if offset is not None:
params["offset"] = offset
async with httpx.AsyncClient(timeout=35) as client:
r = await client.get(url, params=params)
if r.status_code != 200:
logger.warning("Telegram getUpdates HTTP %d: %s", r.status_code, r.text[:200])
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
backoff = 1.0
data = r.json()
for upd in data.get("result", []):
offset = upd["update_id"] + 1
msg = upd.get("message") or upd.get("edited_message")
if msg:
await _handle_message(msg)
except asyncio.CancelledError:
logger.info("Telegram bot loop cancelled.")
raise
except httpx.ReadTimeout:
# Normal — long-poll returned with no updates within timeout. Just retry.
# (Previously caught by the bare Exception below and logged with an
# empty message — "error: — sleeping" — which was opaque.)
continue
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError) as exc:
# Network-level error — expected on transient connectivity issues.
# Compact log to avoid spamming when the network is down.
logger.warning(
"Telegram bot loop network error: %s (%s) — sleeping %.1fs",
type(exc).__name__, exc, backoff,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
except Exception:
# Anything else is unexpected — log the full traceback so we
# can actually diagnose it. Previously this silently swallowed
# exceptions with no message body.
logger.exception("Telegram bot loop unexpected error — sleeping %.1fs", backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
# ── Admin helper ──────────────────────────────────────────────────────────
async def unbind_wallet(wallet: str) -> int:
"""Detach a wallet from its Telegram binding (called by /api/telegram/unbind).
Sets wallet_address = NULL rather than deleting the row. The chat stays
subscribed at the free tier — matches the UI's promise ("Your free
subscription stays active in the bot"). User wanting a full disconnect
sends /stop in the bot.
"""
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.wallet_address == wallet.lower())
.values(wallet_address=None)
)
await db.commit()
return r.rowcount
+397 -23
View File
@@ -1,21 +1,39 @@
"""
Take-profit / stop-loss monitor.
Take-profit / stop-loss / trailing-stop monitor.
Subscribes to the Binance price stream; for every open trade that has TP/SL set,
closes the HL position as soon as the live mark price crosses the threshold.
Subscribes to the Binance price stream; for every open trade, closes the HL
position when the live mark price crosses one of:
1. Fixed stop-loss (always active).
2. Fixed take-profit (optional — set to None for trend capture).
3. Trailing stop (optional — once peak profit ≥ trailing_activate_at_pct,
close if price drops trailing_stop_pct from the peak).
The trailing branch is the centrepiece of the convex-payoff redesign:
fixed TP at +2% kills every potential runner before it starts. Instead we
let unrealised PnL grow, watching the peak, and only exit on a real
retracement.
Lifecycle:
- bot_engine.open_position calls register_trade(...) after each new trade
- price callback in binance.py calls on_price_tick() once per second
- when TP or SL hits, we enqueue close_and_finalize(...) and de-register
- binance.py's price callback calls on_price_tick() once per second
- when a rule fires, _fire_close(...) calls bot_engine.close_and_finalize
"""
import asyncio
import logging
from dataclasses import dataclass
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
logger = logging.getLogger(__name__)
# Throttled peak persistence: write peak_gain_pct to the DB at most once per
# PEAK_FLUSH_SECS per trade, and only after it advances ≥ PEAK_FLUSH_DELTA pp.
# Peak is monotonic so writes are rare after the initial run-up; this is
# enough to keep the post-restart regime correct without per-tick I/O.
PEAK_FLUSH_DELTA = 1.0
PEAK_FLUSH_SECS = 30.0
@dataclass
class WatchedTrade:
@@ -26,14 +44,83 @@ class WatchedTrade:
asset: str
side: str # "long" | "short"
entry_price: float
take_profit_pct: Optional[float]
stop_loss_pct: Optional[float]
take_profit_pct: Optional[float] # legacy fixed TP (may be None)
stop_loss_pct: Optional[float] # always required in practice
trailing_stop_pct: Optional[float] # trail distance, e.g. 2.5
trailing_activate_at_pct: Optional[float] # activation threshold, e.g. 5.0
# System-2 only. "below_entry": exit immediately if price crosses back
# through entry BEFORE trailing arms — the reversal thesis (e.g. SMA
# reclaim) is dead, no reason to wait for the wide hard stop. None for
# System-1 / unset.
invalidation: Optional[str] = None
invalidation_price: Optional[float] = None
# Staged stop-loss ladder (System-2, optional). List of
# (peak_gain_trigger_pct, stop_floor_pct) sorted ascending. When set,
# this trade has NO take-profit and NO from-peak trailing: the only
# market exit is "price fell back to the highest staged floor that the
# peak gain has unlocked". See signal_categories.get_stop_ladder.
stop_ladder: Optional[list] = None
# Staged DOWNSIDE de-risk ladder (System-2, optional). List of
# (threshold_signed_pct_negative, frac_of_original, is_final) ordered by
# increasing adversity. While the trade is underwater (peak below the
# first stop_ladder trigger) it is scaled OUT in partial reduces at each
# rung; the final rung is a full close at the protective level (inside
# liquidation). See signal_categories.sys2_derisk_ladder.
derisk_ladder: Optional[list] = None
derisk_done: int = 0
derisk_in_flight: bool = field(default=False)
# Pyramiding (做对了往上加仓): add to a confirmed winner. List of
# (peak_gain_trigger_pct, frac_of_base, is_last). Only active in the
# profit regime AND while derisk_done==0. Each add blends entry up; the
# monitor then floors the stop at breakeven (eff_stop ≥ 0) so a pyramided
# winner can never become a loser.
addon_ladder: Optional[list] = None
addon_done: int = 0
addon_in_flight: bool = field(default=False)
# Per-trade "Grow" switch. Pyramiding only runs when True. De-risk +
# ratchet stop are unaffected (safety floor is always on). Toggled live
# by the user via POST /positions/{id}/grow.
grow_mode: bool = field(default=False)
# Parabolic-top trailing: (activate_peak_gain_pct, price_drawdown_frac).
# In the profit regime, once peak ≥ activate, the floor also trails the
# peak PRICE by at most drawdown_frac (scale-invariant). None = use only
# the fixed stop_ladder rungs. See signal_categories.sys2_peak_trail.
peak_trail: Optional[tuple] = None
# Throttled persistence of peak_gain_pct (monotonic). peak_persisted is
# the last value written to the DB; peak_persist_ts the last write time.
peak_persisted: float = field(default=0.0)
peak_persist_ts: float = field(default=0.0)
# System-1 (Trump) min-hold floor. Epoch seconds; before this time,
# take-profit and trailing exits are SUPPRESSED (don't scalp out of a
# developing move). Hard stop-loss + invalidation always fire — capital
# protection isn't deferrable. None = no floor (System 2 / legacy).
min_hold_until_ts: Optional[float] = None
# Runtime state — peak unrealised gain seen so far, in %. Used by the
# trailing branch. Long: peak = max(over time) of pct_gain. Short: same,
# but pct_gain is already signed correctly by the caller.
peak_gain_pct: float = field(default=0.0)
trailing_armed: bool = field(default=False)
# Buffer below entry before "below_entry" invalidation fires. Entry price is
# only a PROXY for the true thesis-invalidation level (e.g. the 200d SMA for
# an sma_reclaim). 0.75% is roughly one normal noise band on a major coin —
# big enough to survive the fill-tick wobble, small enough to still cut a
# failed reclaim well before the wide hard stop.
INVALIDATION_BUFFER_PCT = 0.75
# trade_id -> WatchedTrade
_watched: Dict[int, WatchedTrade] = {}
# Strong references to fire-close tasks to prevent GC before completion
# Strong refs to fire-close tasks (prevent GC before completion)
_background_tasks: set = set()
@@ -45,18 +132,58 @@ def register_trade(
asset: str,
side: str,
entry_price: float,
take_profit_pct: Optional[float],
stop_loss_pct: Optional[float],
take_profit_pct: Optional[float],
stop_loss_pct: Optional[float],
trailing_stop_pct: Optional[float] = None,
trailing_activate_at_pct: Optional[float] = None,
invalidation: Optional[str] = None,
invalidation_price: Optional[float] = None,
min_hold_until_ts: Optional[float] = None,
stop_ladder: Optional[list] = None,
derisk_ladder: Optional[list] = None,
derisk_done: int = 0,
addon_ladder: Optional[list] = None,
addon_done: int = 0,
initial_peak: float = 0.0,
peak_trail: Optional[tuple] = None,
grow_mode: bool = False,
) -> None:
if take_profit_pct is None and stop_loss_pct is None:
return # nothing to watch
# Nothing to monitor → skip. We require AT LEAST a stop-loss for any
# registered trade; protocol still works without TP if trailing or a
# staged-stop / de-risk ladder is set.
if (stop_loss_pct is None
and take_profit_pct is None
and trailing_stop_pct is None
and not stop_ladder
and not derisk_ladder):
return
_watched[trade_id] = WatchedTrade(
trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage,
asset=asset, side=side, entry_price=entry_price,
take_profit_pct=take_profit_pct, stop_loss_pct=stop_loss_pct,
take_profit_pct=take_profit_pct,
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct,
trailing_activate_at_pct=trailing_activate_at_pct,
invalidation=invalidation,
invalidation_price=invalidation_price,
min_hold_until_ts=min_hold_until_ts,
stop_ladder=stop_ladder,
derisk_ladder=derisk_ladder,
derisk_done=derisk_done or 0,
addon_ladder=addon_ladder,
addon_done=addon_done or 0,
peak_gain_pct=max(0.0, float(initial_peak or 0.0)),
peak_persisted=max(0.0, float(initial_peak or 0.0)),
peak_trail=peak_trail,
grow_mode=bool(grow_mode),
)
logger.info(
"Watching trade %d (%s %s @ %.4f, sl=%s, tp=%s, trail=%s/@%s)",
trade_id, side, asset, entry_price,
stop_loss_pct, take_profit_pct,
trailing_stop_pct, trailing_activate_at_pct,
)
logger.info("TP/SL watching trade %d (%s %s @ %.2f, tp=%s, sl=%s)",
trade_id, side, asset, entry_price, take_profit_pct, stop_loss_pct)
def unregister(trade_id: int) -> None:
@@ -64,21 +191,163 @@ def unregister(trade_id: int) -> None:
def on_price_tick(asset: str, price: float) -> None:
"""Called from binance.py on every price update. Fires close for any trade that hit TP/SL."""
"""Called from binance.py on every price update.
Iterates watched trades on this asset, evaluates each rule, and triggers
a close on the first match. Rule evaluation order: stop-loss → trailing
→ fixed TP. (Stop-loss first so a sudden gap kills the position before
we celebrate hitting trailing.)
"""
if not _watched:
return
triggered = []
derisk_actions = [] # (wt, step_idx, frac_of_original)
addon_actions = [] # (wt, step_idx, frac_of_base)
for tid, wt in list(_watched.items()):
if wt.asset != asset:
continue
pct = (price - wt.entry_price) / wt.entry_price
signed_pct = pct if wt.side == 'long' else -pct # gain in position's direction
pct_x100 = signed_pct * 100
# Convert raw price → signed gain in position's direction, in %.
raw_pct = (price - wt.entry_price) / wt.entry_price
signed_pct = (raw_pct if wt.side == "long" else -raw_pct) * 100
if wt.take_profit_pct is not None and pct_x100 >= wt.take_profit_pct:
triggered.append((wt, "take_profit"))
elif wt.stop_loss_pct is not None and pct_x100 <= -wt.stop_loss_pct:
# Update peak BEFORE evaluating trailing — a tick that pushes the peak
# AND retraces in the same step should still arm trailing at the new peak.
if signed_pct > wt.peak_gain_pct:
wt.peak_gain_pct = signed_pct
# Throttled monotonic persistence so a restart keeps the regime.
now_s = time.time()
if (wt.peak_gain_pct - wt.peak_persisted >= PEAK_FLUSH_DELTA
and now_s - wt.peak_persist_ts >= PEAK_FLUSH_SECS):
wt.peak_persisted = wt.peak_gain_pct
wt.peak_persist_ts = now_s
_pt = asyncio.create_task(
_persist_peak(wt.trade_id, wt.peak_gain_pct))
_background_tasks.add(_pt)
_pt.add_done_callback(_background_tasks.discard)
# 0. System-2 self-contained exit model: NO take-profit, NO from-peak
# trailing. Two regimes, fully replacing branches 1/1b/2/3:
# • Underwater → STAGED DE-RISK ("分段式减仓"): partial reduces
# at each downside rung, full close at the final (protective,
# inside-liquidation) rung.
# • In profit → STAGED STOP: floor ratchets up as peak gain
# crosses each upside rung; full close when price falls back.
if wt.stop_ladder or wt.derisk_ladder:
first_up = (min(t for t, _ in wt.stop_ladder)
if wt.stop_ladder else None)
in_profit_regime = (first_up is not None
and wt.peak_gain_pct >= first_up)
if wt.derisk_ladder and not in_profit_regime:
ladder = wt.derisk_ladder
# Gap protection: blown straight through to the final
# protective level → full close NOW regardless of step
# bookkeeping (still inside the exchange liquidation line).
if signed_pct <= ladder[-1][0]:
triggered.append((wt, "staged_stop"))
continue
if (not wt.derisk_in_flight
and 0 <= wt.derisk_done < len(ladder)):
thr, frac, is_final = ladder[wt.derisk_done]
if signed_pct <= thr:
if is_final:
triggered.append((wt, "staged_stop"))
continue
# Partial reduce — spawn async, guard re-entry until
# it completes (done-callback clears the flag).
wt.derisk_in_flight = True
derisk_actions.append((wt, wt.derisk_done, frac))
continue # underwater: no profit-ratchet / TP / trailing
# Profit regime — ratchet floor (base + unlocked upside rungs).
eff_stop = -wt.stop_loss_pct if wt.stop_loss_pct is not None else float("-inf")
if wt.stop_ladder:
for trig, floor in wt.stop_ladder: # sorted ascending
if wt.peak_gain_pct >= trig and floor > eff_stop:
eff_stop = floor
# Parabolic-top trailing: once deep in profit, also trail the PEAK
# PRICE by ≤ drawdown_frac (scale-invariant — self-adjusts to a
# +120% or +900% move so the fixed top rung doesn't cap it, while
# a normal ~2530% bull pullback still doesn't trip it).
if wt.peak_trail is not None:
start_pp, dd = wt.peak_trail
if wt.peak_gain_pct >= start_pp:
trail_floor = ((1.0 + wt.peak_gain_pct / 100.0)
* (1.0 - dd) - 1.0) * 100.0
if trail_floor > eff_stop:
eff_stop = trail_floor
# Pyramided → never let a winner become a loser: blended position
# is floored at breakeven once any add-on has filled.
if wt.addon_done > 0 and eff_stop < 0.0:
eff_stop = 0.0
if signed_pct <= eff_stop:
triggered.append((wt, "staged_stop"))
continue
# Pyramiding — add to a confirmed winner. Gated by the per-trade
# Grow switch (off by default — user opts a position in). Evaluated
# ONLY after the stop check above passed (never add on a tick that's
# also exiting). Peak-gain triggered; only while no de-risk has
# fired. Structural confirmation is re-checked in the executor.
if (wt.grow_mode and wt.addon_ladder and wt.derisk_done == 0
and not wt.addon_in_flight
and 0 <= wt.addon_done < len(wt.addon_ladder)):
a_trig, a_frac, _a_last = wt.addon_ladder[wt.addon_done]
if wt.peak_gain_pct >= a_trig:
wt.addon_in_flight = True
addon_actions.append((wt, wt.addon_done, a_frac))
continue
# 1. Stop loss — first priority.
if wt.stop_loss_pct is not None and signed_pct <= -wt.stop_loss_pct:
triggered.append((wt, "stop_loss"))
continue
# 1b. Signal invalidation (System-2 only). Prefer the real thesis
# level when available (e.g. 200d SMA reclaim price). Fall back
# to entry-buffer proxy for legacy rows.
if wt.invalidation == "below_entry" and not wt.trailing_armed:
if wt.invalidation_price is not None:
invalidation_hit = (
(wt.side == "long" and price <= wt.invalidation_price) or
(wt.side == "short" and price >= wt.invalidation_price)
)
if invalidation_hit:
triggered.append((wt, "invalidation"))
continue
elif signed_pct <= -INVALIDATION_BUFFER_PCT:
triggered.append((wt, "invalidation"))
continue
# Min-hold gate (System-1 Trump). Before the floor elapses we let a
# winner develop — suppress TP + trailing. SL / invalidation above
# already ran, so downside is still protected; we only defer the
# PROFIT-side exits here.
if wt.min_hold_until_ts is not None:
import time as _t
if _t.time() < wt.min_hold_until_ts:
continue # still in min-hold; skip TP/trailing this tick
# 2. Trailing stop — only once armed.
if (wt.trailing_stop_pct is not None
and wt.trailing_activate_at_pct is not None):
if not wt.trailing_armed and wt.peak_gain_pct >= wt.trailing_activate_at_pct:
wt.trailing_armed = True
logger.info(
"Trade %d: trailing armed at peak %.2f%% (asset=%s)",
wt.trade_id, wt.peak_gain_pct, asset,
)
if wt.trailing_armed:
drawdown_from_peak = wt.peak_gain_pct - signed_pct
if drawdown_from_peak >= wt.trailing_stop_pct:
triggered.append((wt, "trailing_stop"))
continue
# 3. Fixed TP — legacy / fallback when no trailing config.
if wt.take_profit_pct is not None and signed_pct >= wt.take_profit_pct:
triggered.append((wt, "take_profit"))
continue
for wt, reason in triggered:
unregister(wt.trade_id)
@@ -86,10 +355,115 @@ def on_price_tick(asset: str, price: float) -> None:
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Partial de-risk steps — trade stays registered (it's not closed).
for wt, step_idx, frac in derisk_actions:
task = asyncio.create_task(_fire_partial(wt, step_idx, frac))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Pyramid add-ons — trade stays registered (size grows, entry blends).
for wt, step_idx, frac in addon_actions:
task = asyncio.create_task(_fire_pyramid(wt, step_idx, frac))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
async def _fire_partial(wt: WatchedTrade, step_idx: int, frac: float) -> None:
"""Execute one staged de-risk partial reduce. The trade stays open and
registered; on success advance derisk_done, always clear the in-flight
guard so the next rung can fire on a later tick."""
from app.services.bot_engine import partial_derisk
ok = False
try:
ok = await partial_derisk(
trade_id=wt.trade_id,
api_key=wt.api_key,
asset=wt.asset,
wallet=wt.wallet,
step_idx=step_idx,
frac_of_original=frac,
reason="derisk",
)
except Exception as exc:
logger.error("De-risk partial failed for trade %d step %d: %s",
wt.trade_id, step_idx, exc)
finally:
# Re-fetch: the trade may have been closed/unregistered meanwhile.
live = _watched.get(wt.trade_id)
if live is not None:
if ok and live.derisk_done == step_idx:
live.derisk_done = step_idx + 1
live.derisk_in_flight = False
async def _fire_pyramid(wt: WatchedTrade, step_idx: int, frac: float) -> None:
"""Execute one pyramid add-on. On success re-base the in-memory entry to
the blended average and reset peak to the post-add gain so the ratchet /
regime use the NEW entry (and we stay in the profit regime). Always clear
the in-flight guard."""
from app.services.bot_engine import pyramid_add
ok, new_entry, ref_price = False, None, None
try:
ok, new_entry, ref_price = await pyramid_add(
trade_id=wt.trade_id,
api_key=wt.api_key,
asset=wt.asset,
wallet=wt.wallet,
step_idx=step_idx,
frac_of_base=frac,
reason="pyramid",
)
except Exception as exc:
logger.error("Pyramid add failed for trade %d step %d: %s",
wt.trade_id, step_idx, exc)
finally:
live = _watched.get(wt.trade_id)
if live is not None:
if ok and live.addon_done == step_idx:
# Step is RESOLVED (added, notional-capped, or already-done).
# Advance regardless of whether an add actually filled, else
# the monitor re-schedules this step every tick forever.
if new_entry:
live.entry_price = new_entry
if ref_price:
raw = (ref_price - new_entry) / new_entry
g = (raw if live.side == "long" else -raw) * 100.0
else:
g = 0.0
# Stay in the profit regime; ratchet re-accumulates here.
first_up = (min(t for t, _ in live.stop_ladder)
if live.stop_ladder else 0.0)
live.peak_gain_pct = max(g, first_up)
live.addon_done = step_idx + 1
live.addon_in_flight = False
async def _persist_peak(trade_id: int, peak: float) -> None:
"""Monotonic best-effort write of peak_gain_pct. Race-safe: only raises
the stored value (WHERE peak_gain_pct < :peak), never lowers it."""
try:
from sqlalchemy import update
from app.database import AsyncSessionLocal
from app.models import BotTrade
async with AsyncSessionLocal() as db:
await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.peak_gain_pct < peak)
.values(peak_gain_pct=peak)
)
await db.commit()
except Exception as exc: # never let persistence break the monitor
logger.debug("peak persist failed trade %d: %s", trade_id, exc)
async def _fire_close(wt: WatchedTrade, reason: str) -> None:
from app.services.bot_engine import close_and_finalize
try:
logger.info(
"Closing trade %d on %s (peak=%.2f%%, reason=%s)",
wt.trade_id, wt.asset, wt.peak_gain_pct, reason,
)
await close_and_finalize(
trade_id=wt.trade_id,
api_key=wt.api_key,