5fb1d52026
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>
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""
|
||
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],
|
||
}
|