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
+195
View File
@@ -0,0 +1,195 @@
"""
Backtest: replay AI signals against actual price moves stored in the DB.
Zero AI calls. Uses pre-computed `price_impact_m5/m15/m1h` peak excursions
that the price_impact_monitor already filled in for every relevant post.
Methodology (be transparent — this is for honest disclosure):
• Universe : posts with signal in (buy/sell/short) and m1h filled
• Side : buy → long, sell/short → short
• Entry : price_at_post (close of the minute candle when post landed)
• Exit window : 1 hour (matches live bot MAX_HOLD_SECONDS)
• The peak_m1h field is the *side-adjusted* max favorable excursion (MFE) in %
(i.e. positive means market moved in the bot's predicted direction)
• Fees : 9 bps round-trip (HL taker × 2), matches live bot
• TP simulation : if peak ≥ TP threshold, exit at TP; else conservative 0
(we don't have the actual close price, so 0 = breakeven
on direction — captures only the trades that hit TP)
Caveats reported up front:
• MFE is the peak during the window. We don't have the trough (MAE) or the
final close price, so we cannot fully simulate stop-loss behavior or
"what if you held the full hour with no TP". This is a TP-only sim.
• No slippage modeled beyond the 9 bps fee. Real fills on illiquid moments
add 1-3 bps more.
• Sample is whatever's in the DB right now (~70 actionable signals).
"""
import sqlite3
import statistics
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "trumpsignal.db"
FEE_BPS_ROUND_TRIP = 0.0009 # 9 bps = HL taker × 2
TP_LEVELS = [0.10, 0.20, 0.30, 0.50, 1.00] # in percent
def fetch_signals():
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
rows = con.execute("""
SELECT id, signal, price_impact_asset, price_at_post,
price_impact_m5, price_impact_m15, price_impact_m1h,
ai_confidence, published_at, text
FROM posts
WHERE signal IN ('buy', 'short', 'sell')
AND price_at_post IS NOT NULL
AND price_impact_m1h IS NOT NULL
ORDER BY published_at
""").fetchall()
con.close()
return [dict(r) for r in rows]
def simulate(rows, tp_pct, window_field="price_impact_m1h"):
"""For each signal: if MFE in window ≥ tp_pct, count as +tp_pct fill,
else count as 0 (no fill, but we still pay fees if we'd opened — we model
"no entry" so fees aren't charged on misses; this is generous, see below).
Note on the "fees on misses" question: the bot opens the position the
moment the signal fires. Even if TP isn't hit and you exit at the 1h mark,
you paid the round-trip fees. So a stricter sim charges fees on EVERY
trade. We do that — this is the conservative interpretation.
"""
pnl_pcts = []
for r in rows:
peak = r[window_field] # already side-adjusted, in %
if peak is None:
continue
# If MFE crosses TP, exit at TP. Otherwise we hold to window expiry —
# but we don't have the close price, so use peak/2 as a midpoint estimate
# (it must be between 0 and peak by definition; assume linear-ish reversion).
if peak >= tp_pct:
gross = tp_pct
else:
gross = peak / 2.0 if peak > 0 else peak # peak<0 means market moved against → loss
net_pct = gross / 100.0 - FEE_BPS_ROUND_TRIP
pnl_pcts.append(net_pct * 100) # back to percent for display
return pnl_pcts
def stats(pcts):
if not pcts:
return None
n = len(pcts)
wins = sum(1 for p in pcts if p > 0)
losses = sum(1 for p in pcts if p < 0)
flat = n - wins - losses
win_rate = wins / n
mean = statistics.mean(pcts)
median = statistics.median(pcts)
pmax = max(pcts)
pmin = min(pcts)
# Profit factor: sum of wins / abs(sum of losses)
gross_win = sum(p for p in pcts if p > 0)
gross_loss = abs(sum(p for p in pcts if p < 0))
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
# Cumulative PnL (linear sum, not compounded — conservative)
total = sum(pcts)
return {
"n": n,
"wins": wins,
"losses": losses,
"flat": flat,
"win_rate_pct": round(win_rate * 100, 1),
"mean_pct": round(mean, 3),
"median_pct": round(median, 3),
"max_pct": round(pmax, 3),
"min_pct": round(pmin, 3),
"profit_factor": round(pf, 2) if pf != float("inf") else "",
"total_pct": round(total, 2),
}
def main():
rows = fetch_signals()
print(f"BACKTEST — TrumpSignal AI strategy on Truth Social posts")
print(f"=" * 70)
print(f"Universe: {len(rows)} actionable signals (buy/sell/short)")
by_signal = {}
for r in rows:
by_signal[r['signal']] = by_signal.get(r['signal'], 0) + 1
print(f" by signal: {by_signal}")
by_asset = {}
for r in rows:
a = r['price_impact_asset'] or 'UNKNOWN'
by_asset[a] = by_asset.get(a, 0) + 1
print(f" by asset: {by_asset}")
if rows:
print(f" date span: {rows[0]['published_at'][:10]}{rows[-1]['published_at'][:10]}")
print()
print(f"\n=== MFE distribution (m1h window, side-adjusted) ===")
raw_peaks = [r['price_impact_m1h'] for r in rows]
moved_correct = sum(1 for p in raw_peaks if p > 0)
print(f" trades where market moved in predicted direction: "
f"{moved_correct}/{len(raw_peaks)} = {round(moved_correct/len(raw_peaks)*100,1)}%")
for thresh in [0.1, 0.2, 0.3, 0.5, 1.0, 2.0]:
hit = sum(1 for p in raw_peaks if p >= thresh)
print(f" MFE ≥ {thresh:>4.1f}% : {hit:>3}/{len(raw_peaks)} ({round(hit/len(raw_peaks)*100,1)}%)")
print(f" mean MFE : {round(statistics.mean(raw_peaks),3)}%")
print(f" median MFE : {round(statistics.median(raw_peaks),3)}%")
print(f" max MFE : {round(max(raw_peaks),3)}%")
print(f" min MFE : {round(min(raw_peaks),3)}%")
print(f"\n=== Strategy comparison: different TP thresholds (1h window, 9 bps fees) ===")
print(f" {'TP%':>5} {'n':>4} {'win%':>6} {'mean':>7} {'median':>7} "
f"{'max':>7} {'min':>7} {'PF':>5} {'total%':>8}")
print(f" {'-'*5} {'-'*4} {'-'*6} {'-'*7} {'-'*7} "
f"{'-'*7} {'-'*7} {'-'*5} {'-'*8}")
for tp in TP_LEVELS:
s = stats(simulate(rows, tp))
if s:
print(f" {tp:>5.2f} {s['n']:>4} {s['win_rate_pct']:>5.1f}% "
f"{s['mean_pct']:>+6.3f}% {s['median_pct']:>+6.3f}% "
f"{s['max_pct']:>+6.3f}% {s['min_pct']:>+6.3f}% "
f"{str(s['profit_factor']):>5} {s['total_pct']:>+7.2f}%")
# Also break down by signal direction
print(f"\n=== By signal direction (TP=0.30%, 1h window) ===")
for sig in ['buy', 'sell', 'short']:
sub = [r for r in rows if r['signal'] == sig]
if not sub: continue
s = stats(simulate(sub, 0.30))
print(f" {sig:>5}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
# Confidence-bucket performance
print(f"\n=== By AI confidence bucket (TP=0.30%, 1h window) ===")
for lo, hi in [(0,49), (50,69), (70,89), (90,100)]:
sub = [r for r in rows if lo <= (r['ai_confidence'] or 0) <= hi]
if not sub:
print(f" conf {lo:>3}-{hi:<3}: (empty)")
continue
s = stats(simulate(sub, 0.30))
print(f" conf {lo:>3}-{hi:<3}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
# Window comparison: which TP horizon best captures the move?
print(f"\n=== Best window comparison (TP=0.30%) ===")
print(f" Same TP, different exit windows. Tells you which horizon to trade.")
for win, label in [("price_impact_m5", "5min"), ("price_impact_m15", "15min"), ("price_impact_m1h", "1hour")]:
s = stats(simulate(rows, 0.30, window_field=win))
if s:
print(f" {label:>6}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
print(f"\n{'=' * 70}")
print(f"DISCLAIMER: This is a TP-only simulation using max favorable excursion")
print(f" data. Real performance will differ — no trough/MAE captured, no")
print(f" slippage beyond fees. Sample = {len(rows)}, drawn from live AI scoring")
print(f" on actual Trump posts {rows[0]['published_at'][:10] if rows else ''}"
f"{rows[-1]['published_at'][:10] if rows else ''}.")
if __name__ == "__main__":
main()
+214
View File
@@ -0,0 +1,214 @@
"""
Past-7-day backtest under user-specified params:
- Margin: $100, leverage 20x → notional $2000 per trade
- SL: -30% on margin = -1.5% on notional move
- TP: "exit at the future peak" (look-ahead — see caveat at end)
- Window: 1 hour after publish
- Fees: 9 bps round-trip (HL taker × 2)
We only have the price_impact_m5/m15/m1h fields, which store the MAXIMUM
FAVORABLE EXCURSION (MFE). We do NOT have the trough / max ADVERSE excursion
(MAE), so we cannot detect a real intra-hour SL hit. That makes the
optimistic sim a CEILING, not a real-world outcome.
We compute three scenarios so you can see the honest spread:
A) "Perfect-foresight": exit at MFE peak. SL ignored (assume MAE = 0).
This is what the user asked for. Upper bound only.
B) "Pessimistic": if MFE < 0, assume SL hit (-1.5% × $2000 = -$30).
If MFE ≥ 0, exit at MFE peak.
Closer to reality but still optimistic on the wins.
C) "Realistic fixed TP": TP at +1.5%, SL at -1.5% (symmetric).
If MFE ≥ TP → win. Else → unknown end-of-window
price, conservative model: take MFE × 0.5 as exit.
This is the closest to a real bot's behavior.
"""
import sqlite3
import statistics
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "trumpsignal.db"
NOTIONAL = 2000.0
MARGIN = 100.0
SL_PCT = 1.5 # 1.5% notional move = -30% margin
FEE_BPS_RT = 0.0009 # 9 bps × $2000 = $1.80 per trade
def fetch_week():
"""Last 7 days of actionable signals, anchored to the LATEST post in the
DB (not wall-clock now) so the script works regardless of how stale the
snapshot is."""
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
latest = con.execute("SELECT MAX(published_at) FROM posts").fetchone()[0]
rows = con.execute("""
SELECT id, signal, ai_confidence, price_at_post,
price_impact_m5, price_impact_m15, price_impact_m1h,
published_at, text
FROM posts
WHERE signal IN ('buy', 'short') -- exclude sell (semantic bug)
AND price_at_post IS NOT NULL
AND price_impact_m1h IS NOT NULL
AND published_at >= datetime(?, '-7 days')
ORDER BY published_at
""", (latest,)).fetchall()
con.close()
return [dict(r) for r in rows], latest
def trade_pnl_usd(gross_pct: float) -> float:
"""Convert a notional % move into $ PnL on $2000 notional, after fees."""
return NOTIONAL * (gross_pct / 100.0) - NOTIONAL * FEE_BPS_RT
def sim_perfect(rows):
"""A) Exit at peak. No SL. (User's request — look-ahead bias.)"""
pnls = []
for r in rows:
peak = r["price_impact_m1h"] # already side-adjusted, %
# Even peak < 0 still "exits at peak" per user spec
pnls.append(trade_pnl_usd(peak))
return pnls
def sim_pessimistic(rows):
"""B) If peak < 0, assume SL hit. Else exit at peak."""
pnls = []
for r in rows:
peak = r["price_impact_m1h"]
if peak < 0:
pnls.append(trade_pnl_usd(-SL_PCT)) # SL = -$30 + fees
else:
pnls.append(trade_pnl_usd(peak))
return pnls
def sim_fixed_tp(rows, tp_pct=1.5):
"""C) TP=tp_pct, SL=-1.5%. Real-bot behavior."""
pnls = []
for r in rows:
peak = r["price_impact_m1h"]
if peak >= tp_pct:
pnls.append(trade_pnl_usd(tp_pct)) # TP hit
elif peak < 0:
pnls.append(trade_pnl_usd(-SL_PCT)) # SL likely
else:
# Held the hour, peak was below TP → exit somewhere between
# 0 and peak. Use peak/2 as a midpoint estimate (conservative).
pnls.append(trade_pnl_usd(peak / 2.0))
return pnls
def stats(pnls, label):
n = len(pnls)
if n == 0:
return None
wins = sum(1 for p in pnls if p > 0)
losses = sum(1 for p in pnls if p < 0)
total = sum(pnls)
biggest_win = max(pnls)
biggest_loss = min(pnls)
win_rate = wins / n * 100
# Margin return: total $ / total margin used
total_margin_used = MARGIN * n
roi_on_margin = (total / total_margin_used) * 100
return {
"label": label,
"n": n,
"wins": wins,
"losses": losses,
"win_rate_pct": round(win_rate, 1),
"total_usd": round(total, 2),
"avg_per_trade_usd": round(total / n, 2),
"biggest_win_usd": round(biggest_win, 2),
"biggest_loss_usd": round(biggest_loss, 2),
"roi_on_margin_pct": round(roi_on_margin, 1),
}
def print_stats(s):
if s is None:
print(" (no trades)")
return
print(f" n trades: {s['n']}")
print(f" win rate: {s['win_rate_pct']}% ({s['wins']}W / {s['losses']}L)")
print(f" total PnL (USD): ${s['total_usd']:+,.2f}")
print(f" avg per trade: ${s['avg_per_trade_usd']:+,.2f}")
print(f" biggest win: ${s['biggest_win_usd']:+,.2f}")
print(f" biggest loss: ${s['biggest_loss_usd']:+,.2f}")
print(f" ROI on margin: {s['roi_on_margin_pct']:+.1f}% "
f"(${s['total_usd']:+.0f} on ${MARGIN * s['n']:.0f} total margin used)")
def main():
rows, anchor = fetch_week()
print("=" * 72)
print(f"PAST-7-DAY BACKTEST — TrumpSignal AI strategy")
print(f"(7 days back from latest DB post: {anchor[:19]})")
print("=" * 72)
print(f"Position size: ${MARGIN} margin × 20x leverage = ${NOTIONAL:.0f} notional")
print(f"Stop loss: -30% margin = -{SL_PCT}% notional")
print(f"Take profit: see scenarios below")
print(f"Fees: 9 bps round-trip = ${NOTIONAL * FEE_BPS_RT:.2f} per trade")
print(f"Hold window: 1 hour")
print(f"Sample: {len(rows)} actionable signals (buy/short, sell excluded)")
if rows:
print(f"Date span: {rows[0]['published_at'][:10]}{rows[-1]['published_at'][:10]}")
print()
if not rows:
print("⚠️ No actionable signals in the last 7 days.")
print(" Either Trump didn't post anything market-relevant, or all signals")
print(" were 'hold'. Cannot run backtest.")
return
print("=" * 72)
print("SCENARIO A — 'Perfect foresight' (exit at peak, ignore SL) ← user spec")
print(" This is the THEORETICAL CEILING. No real bot can hit this.")
print("=" * 72)
print_stats(stats(sim_perfect(rows), "perfect"))
print()
print("=" * 72)
print("SCENARIO B — 'Pessimistic' (SL hits if peak<0, else exit at peak)")
print(" Closer to honest, but wins are still cherry-picked at peak.")
print("=" * 72)
print_stats(stats(sim_pessimistic(rows), "pessimistic"))
print()
print("=" * 72)
print("SCENARIO C — 'Realistic fixed TP/SL' (TP=+1.5%, SL=-1.5%)")
print(" Closest to what a real bot with these params would yield.")
print(" THIS is the only one defensible for marketing.")
print("=" * 72)
print_stats(stats(sim_fixed_tp(rows, tp_pct=1.5), "realistic"))
print()
# Also try a couple of TP variations to find sweet spot
print("=" * 72)
print("Realistic sim — TP threshold sweep (SL fixed at -1.5%)")
print("=" * 72)
print(f" {'TP%':>6} {'n':>4} {'win%':>6} {'total$':>10} {'avg$':>8} {'ROI%margin':>11}")
for tp in [0.5, 1.0, 1.5, 2.0, 3.0]:
s = stats(sim_fixed_tp(rows, tp), f"tp={tp}")
print(f" {tp:>5.1f}% {s['n']:>4} {s['win_rate_pct']:>5.1f}% "
f"${s['total_usd']:>+8.2f} ${s['avg_per_trade_usd']:>+6.2f} "
f"{s['roi_on_margin_pct']:>+9.1f}%")
print()
print("=" * 72)
print("BOTTOM LINE")
print("=" * 72)
perfect = stats(sim_perfect(rows), "")
realistic = stats(sim_fixed_tp(rows, 1.5), "")
print(f" Perfect-foresight ceiling: ${perfect['total_usd']:+,.2f} "
f"(do NOT put on homepage — look-ahead bias)")
print(f" Realistic fixed TP=1.5%: ${realistic['total_usd']:+,.2f} "
f"(this is the marketable number, IF positive)")
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
"""
Backtest: Reversal + Breakout signals on Binance Futures 5m klines
Covers 2023-01-01 to present across SOL, ETH, AVAX, LINK, DOGE
"""
import io
import time
import zipfile
import subprocess
import warnings
import pandas as pd
import numpy as np
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from tabulate import tabulate
warnings.filterwarnings("ignore")
# ── Config ────────────────────────────────────────────────────────────────────
SYMBOLS = ["ETHUSDT", "LINKUSDT", "AVAXUSDT", "SOLUSDT", "DOGEUSDT"]
BTC_SYMBOL = "BTCUSDT"
START_TS = int(datetime(2023, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)
END_TS = int(datetime(2025, 12, 31, tzinfo=timezone.utc).timestamp() * 1000)
STOP_LOSS = -0.03 # -3%
TAKE_PROFIT = 0.035 # +3.5%
MAX_HOLD_CANDLES = 288 # 24h at 5m
TREND_MA_PERIOD = 48 # 4h trend filter (48 x 5m = 4h)
BTC_TREND_MA = 288 # BTC 24h trend filter (288 x 5m = 24h)
# Signal params
REVERSAL_TAKER_BUY_THRESH = 0.65
REVERSAL_PREV_TAKER_MAX = 0.45
REVERSAL_MA_PERIOD = 20
REVERSAL_4H_DECLINE = -0.05
BREAKOUT_BB_PERIOD = 20
BREAKOUT_BB_SQUEEZE_PCT = 20 # bottom 20% of BB width history (60 candles)
BREAKOUT_VOLUME_MULT = 2.5
BREAKOUT_TAKER_BUY_THRESH = 0.60
DATA_BASE = "https://data.binance.vision/data/futures/um/monthly/klines"
CACHE_DIR = "/tmp/binance_klines_cache"
KLINE_COLS = [
"open_time", "open", "high", "low", "close", "volume",
"close_time", "quote_volume", "trades",
"taker_buy_base", "taker_buy_quote", "ignore"
]
# ── Data fetch ────────────────────────────────────────────────────────────────
def _fetch_month(symbol: str, year: int, month: int):
"""Download one monthly zip from data.binance.vision and return DataFrame."""
url = f"{DATA_BASE}/{symbol}/5m/{symbol}-5m-{year}-{month:02d}.zip"
for attempt in range(3):
result = subprocess.run(
["curl", "-s", "-x", "http://127.0.0.1:7890",
"--max-time", "120", "--output", "-", url],
capture_output=True,
)
if result.returncode == 0 and result.stdout:
try:
with zipfile.ZipFile(io.BytesIO(result.stdout)) as zf:
csv_name = zf.namelist()[0]
with zf.open(csv_name) as f:
df = pd.read_csv(f, names=KLINE_COLS, skiprows=1)
return df
except Exception:
pass
time.sleep(2 ** attempt)
return None
def fetch_klines(symbol: str, start_ms: int, end_ms: int) -> pd.DataFrame:
import os
os.makedirs(CACHE_DIR, exist_ok=True)
cache_file = f"{CACHE_DIR}/{symbol}.pkl"
start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
end_dt = datetime.fromtimestamp(end_ms / 1000, tz=timezone.utc)
# Load from cache if available
if os.path.exists(cache_file):
print(f" Loading {symbol} from cache...", end="", flush=True)
df = pd.read_pickle(cache_file)
df = df[(df.index >= pd.Timestamp(start_dt)) & (df.index <= pd.Timestamp(end_dt))]
print(f" {len(df)} candles (cached)")
return df
frames = []
cur = start_dt.replace(day=1)
print(f" Fetching {symbol}", end="", flush=True)
while cur <= end_dt:
df = _fetch_month(symbol, cur.year, cur.month)
if df is not None:
frames.append(df)
print(".", end="", flush=True)
else:
print("x", end="", flush=True)
cur += relativedelta(months=1)
if not frames:
print(f" FAILED")
return pd.DataFrame()
df = pd.concat(frames, ignore_index=True)
for col in ["open", "high", "low", "close", "volume", "taker_buy_base"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms", utc=True)
df = df.set_index("open_time").sort_index()
# Save to cache before filtering
df.to_pickle(cache_file)
df = df[(df.index >= pd.Timestamp(start_dt)) & (df.index <= pd.Timestamp(end_dt))]
print(f" {len(df)} candles")
return df
# ── Indicators ────────────────────────────────────────────────────────────────
def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
# Taker buy ratio
df["tbr"] = df["taker_buy_base"] / df["volume"].replace(0, np.nan)
# MA20
df["ma20"] = df["close"].rolling(REVERSAL_MA_PERIOD).mean()
# 4h trend MA (48 x 5m candles)
df["ma_trend"] = df["close"].rolling(TREND_MA_PERIOD).mean()
# 4h decline: compare current close to close 48 candles ago (48 * 5m = 4h)
df["decline_4h"] = df["close"].pct_change(48)
# Bollinger Bands
rolling = df["close"].rolling(BREAKOUT_BB_PERIOD)
df["bb_mid"] = rolling.mean()
df["bb_std"] = rolling.std()
df["bb_upper"] = df["bb_mid"] + 2 * df["bb_std"]
df["bb_width"] = (4 * df["bb_std"]) / df["bb_mid"]
# BB width percentile over past 60 candles
df["bb_width_pct"] = df["bb_width"].rolling(60).rank(pct=True) * 100
# Volume MA20
df["vol_ma20"] = df["volume"].rolling(20).mean()
return df
# ── Signal detection ──────────────────────────────────────────────────────────
def detect_signals(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
# Previous 3 candle TBR max
df["tbr_prev3_max"] = df["tbr"].shift(1).rolling(3).max()
# Trend up filter: price above 4h MA
trend_up = df["close"] > df["ma_trend"]
# Reversal signal
df["sig_reversal"] = (
(df["tbr"] > REVERSAL_TAKER_BUY_THRESH) &
(df["tbr_prev3_max"] < REVERSAL_PREV_TAKER_MAX) &
(df["close"] < df["ma20"]) &
(df["decline_4h"] < REVERSAL_4H_DECLINE) &
trend_up
)
# Breakout signal
df["sig_breakout"] = (
(df["bb_width_pct"] < BREAKOUT_BB_SQUEEZE_PCT) &
(df["volume"] > BREAKOUT_VOLUME_MULT * df["vol_ma20"]) &
(df["tbr"] > BREAKOUT_TAKER_BUY_THRESH) &
(df["close"] > df["bb_upper"]) &
trend_up
)
df["signal"] = np.where(
df["sig_reversal"], "reversal",
np.where(df["sig_breakout"], "breakout", None)
)
return df
# ── Trade simulation ──────────────────────────────────────────────────────────
def simulate_trades(df: pd.DataFrame, symbol: str) -> list[dict]:
trades = []
in_trade = False
entry_price = 0.0
entry_time = None
entry_signal = None
hold_count = 0
closes = df["close"].values
signals = df["signal"].values
times = df.index
for i in range(1, len(df)):
if in_trade:
hold_count += 1
pct = (closes[i] - entry_price) / entry_price
hit_sl = pct <= STOP_LOSS
hit_tp = pct >= TAKE_PROFIT
hit_max = hold_count >= MAX_HOLD_CANDLES
if hit_sl or hit_tp or hit_max:
reason = "SL" if hit_sl else ("TP" if hit_tp else "MAX")
trades.append({
"symbol": symbol,
"signal": entry_signal,
"entry_time": entry_time,
"exit_time": times[i],
"entry_price": entry_price,
"exit_price": closes[i],
"pct": pct,
"result": "win" if pct > 0 else "loss",
"reason": reason,
"month": entry_time.strftime("%Y-%m"),
})
in_trade = False
# Enter on next candle after signal
if not in_trade and i > 0 and signals[i - 1] is not None:
in_trade = True
entry_price = closes[i] # next candle open ≈ prev close (5m)
entry_time = times[i]
entry_signal = signals[i - 1]
hold_count = 0
return trades
# ── Analysis ──────────────────────────────────────────────────────────────────
def analyze(trades: list[dict]) -> None:
if not trades:
print("No trades found.")
return
df = pd.DataFrame(trades)
print("\n" + "=" * 70)
print("OVERALL SUMMARY")
print("=" * 70)
for sig_type in ["reversal", "breakout", "all"]:
sub = df if sig_type == "all" else df[df["signal"] == sig_type]
if sub.empty:
continue
wins = (sub["result"] == "win").sum()
total = len(sub)
avg_pct = sub["pct"].mean() * 100
total_pct = sub["pct"].sum() * 100
print(f"\n[{sig_type.upper()}] trades={total} win_rate={wins/total:.1%}"
f" avg={avg_pct:.2f}% total_return={total_pct:.1f}%")
print("\n" + "=" * 70)
print("MONTHLY BREAKDOWN (all signals combined)")
print("=" * 70)
monthly = (
df.groupby("month")
.agg(
trades=("pct", "count"),
wins=("result", lambda x: (x == "win").sum()),
avg_pct=("pct", lambda x: x.mean() * 100),
total_pct=("pct", lambda x: x.sum() * 100),
)
.reset_index()
)
monthly["win_rate"] = monthly["wins"] / monthly["trades"]
monthly["avg_pct"] = monthly["avg_pct"].map("{:.2f}%".format)
monthly["total_pct"] = monthly["total_pct"].map("{:.1f}%".format)
monthly["win_rate"] = monthly["win_rate"].map("{:.1%}".format)
print(tabulate(monthly, headers="keys", tablefmt="simple", showindex=False))
print("\n" + "=" * 70)
print("PER SYMBOL SUMMARY")
print("=" * 70)
by_sym = (
df.groupby("symbol")
.agg(
trades=("pct", "count"),
wins=("result", lambda x: (x == "win").sum()),
avg_pct=("pct", lambda x: x.mean() * 100),
total_pct=("pct", lambda x: x.sum() * 100),
)
.reset_index()
)
by_sym["win_rate"] = by_sym["wins"] / by_sym["trades"]
by_sym["avg_pct"] = by_sym["avg_pct"].map("{:.2f}%".format)
by_sym["total_pct"] = by_sym["total_pct"].map("{:.1f}%".format)
by_sym["win_rate"] = by_sym["win_rate"].map("{:.1%}".format)
print(tabulate(by_sym, headers="keys", tablefmt="simple", showindex=False))
print("\n" + "=" * 70)
print("EXIT REASON BREAKDOWN")
print("=" * 70)
print(df.groupby(["signal", "reason"]).size().to_string())
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
all_trades = []
# ── Load BTC trend ────────────────────────────────────────────────────────
print("Loading BTC trend data...")
btc = fetch_klines(BTC_SYMBOL, START_TS, END_TS)
if btc.empty:
print(" BTC data failed, running without BTC filter")
btc_trend = None
else:
btc["btc_ma"] = btc["close"].rolling(BTC_TREND_MA).mean()
btc_trend = (btc["close"] > btc["btc_ma"]).rename("btc_uptrend")
print(f" BTC uptrend {btc_trend.mean():.1%} of the time")
# ── Per-symbol backtest ───────────────────────────────────────────────────
for symbol in SYMBOLS:
df = fetch_klines(symbol, START_TS, END_TS)
if df.empty:
print(f" → skipped")
continue
df = add_indicators(df)
df = detect_signals(df)
# Apply BTC trend filter
if btc_trend is not None:
btc_aligned = btc_trend.reindex(df.index, method="ffill")
df.loc[~btc_aligned.fillna(False), "signal"] = None
filtered = df["signal"].notna().sum()
else:
filtered = df["signal"].notna().sum()
sig_count = df["signal"].notna().sum()
print(f"{sig_count} signals after BTC filter (was {filtered})")
trades = simulate_trades(df, symbol)
print(f"{len(trades)} trades executed")
all_trades.extend(trades)
analyze(all_trades)
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Daily DB backup. Cron-friendly. Detects SQLite vs Postgres from DATABASE_URL.
#
# Example crontab:
# 15 3 * * * /path/to/scripts/backup_db.sh >> /var/log/trumpsignal-backup.log 2>&1
#
# Retention: keeps the last RETAIN_DAYS daily backups (default 14).
# Storage: writes to $BACKUP_DIR (default ./backups/).
#
# DATABASE_URL must be available in env. Loads .env if present.
set -euo pipefail
cd "$(dirname "$0")/.."
# Load .env if it exists (so cron jobs without inherited env still work)
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
: "${DATABASE_URL:?DATABASE_URL not set — refusing to back up nothing}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
RETAIN_DAYS="${RETAIN_DAYS:-14}"
TS="$(date -u +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
if [[ "$DATABASE_URL" == sqlite* ]]; then
# Extract path from sqlite+aiosqlite:///./trumpsignal.db
DB_PATH="$(echo "$DATABASE_URL" | sed -E 's#^sqlite(\+[^:]+)?:///+##')"
if [[ ! -f "$DB_PATH" ]]; then
echo "[backup] SQLite file not found: $DB_PATH" >&2
exit 1
fi
OUT="$BACKUP_DIR/sqlite_${TS}.db"
# sqlite3 .backup is online-safe (uses backup API, not a raw copy).
# Fall back to cp if sqlite3 CLI isn't installed.
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$DB_PATH" ".backup '$OUT'"
else
cp "$DB_PATH" "$OUT"
fi
gzip -f "$OUT"
echo "[backup] wrote $OUT.gz ($(du -h "$OUT.gz" | cut -f1))"
elif [[ "$DATABASE_URL" == postgres* ]] || [[ "$DATABASE_URL" == postgresql* ]]; then
OUT="$BACKUP_DIR/pg_${TS}.sql.gz"
# pg_dump reads DATABASE_URL natively if we strip the driver prefix.
PG_URL="${DATABASE_URL/+asyncpg/}"
PG_URL="${PG_URL/+psycopg2/}"
pg_dump "$PG_URL" --no-owner --no-privileges | gzip > "$OUT"
echo "[backup] wrote $OUT ($(du -h "$OUT" | cut -f1))"
else
echo "[backup] unsupported DATABASE_URL scheme: $DATABASE_URL" >&2
exit 1
fi
# Prune backups older than RETAIN_DAYS days
find "$BACKUP_DIR" -name "*.gz" -mtime "+$RETAIN_DAYS" -print -delete
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Pre-launch readiness check.
Run this RIGHT BEFORE flipping production traffic. Verifies that:
* Every required env var is set
* KEK / shared secrets have plausible entropy (not "change_me")
* DB is reachable + schema is at the latest Alembic head
* The Telegram bot token authenticates with the actual @username
* AI provider answers with a real model id
* No leftover open positions in bot_trades that the bot doesn't know about
Exits non-zero on any failure so you can wire it into CI / a deploy gate.
DATABASE_URL=... venv/bin/python scripts/preflight.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import httpx
from sqlalchemy import text
from app.config import settings
from app.database import AsyncSessionLocal, engine
# Vars that MUST be non-empty for production. If you genuinely don't need one
# (e.g. running without Telegram), comment it out — don't push junk values.
REQUIRED = [
("database_url", "Where the API stores everything"),
("frontend_url", "Used as the single CORS origin"),
("encryption_key", "KEK for HL API key envelope encryption"),
("ingest_api_key", "Shared secret for /api/signals/ingest"),
]
# Required for the listed feature; non-fatal but logged as a warning.
OPTIONAL_BUT_RECOMMENDED = [
("ai_api_key", "Trump signal AI scoring (or anthropic_api_key)"),
("anthropic_api_key", "Required if ai_api_key empty AND you want KOL analysis"),
("telegram_bot_token", "Telegram push alerts (whole feature disabled if empty)"),
("telegram_bot_username","Required for the dashboard's Telegram connect deep link"),
("etherscan_api_key", "KOL on-chain (talks-vs-trades) — Ethereum side"),
# NOTE: glassnode_api_key is no longer required — the BTC bottom-reversal
# scanner switched to AHR999 + 200-week MA + Pi Cycle Bottom (all derived
# from public price candles, no Glassnode call). The setting is kept on
# config.Settings for future Glassnode-backed signals but isn't checked.
]
# These should NEVER appear unchanged in production.
SUSPECT_DEFAULTS = {
"change_me_in_production",
"your_key_here",
"CHANGE_ME",
"",
}
def red(s): return f"\033[31m{s}\033[0m"
def green(s): return f"\033[32m{s}\033[0m"
def yellow(s): return f"\033[33m{s}\033[0m"
async def check_env() -> list[str]:
errors = []
print("── env vars ──────────────────────────────────────")
for name, why in REQUIRED:
v = getattr(settings, name, "")
if not v or v in SUSPECT_DEFAULTS:
print(red(f"{name:25s} EMPTY or default — required: {why}"))
errors.append(f"{name} empty")
else:
shown = v if len(v) < 30 else v[:8] + "" + v[-4:]
print(green(f"{name:25s} = {shown}"))
print()
print("── recommended (warnings only) ────────────────────")
for name, why in OPTIONAL_BUT_RECOMMENDED:
v = getattr(settings, name, "")
if not v:
print(yellow(f" ! {name:25s} empty — {why}"))
else:
print(green(f"{name:25s} set"))
return errors
async def check_db() -> list[str]:
errors = []
print()
print("── database ──────────────────────────────────────")
try:
async with AsyncSessionLocal() as db:
await db.execute(text("SELECT 1"))
print(green(" ✓ DB reachable"))
except Exception as exc:
print(red(f" ✗ DB unreachable: {exc}"))
errors.append("db unreachable")
return errors
# Schema check — current head
try:
async with engine.begin() as conn:
r = await conn.execute(text("SELECT version_num FROM alembic_version"))
row = r.fetchone()
current = row[0] if row else None
# Read the latest version from alembic/versions/
versions_dir = Path(__file__).resolve().parent.parent / "alembic" / "versions"
head_files = sorted(p.stem for p in versions_dir.glob("*.py")
if p.stem != "__init__")
latest_prefix = max(int(f.split("_")[0]) for f in head_files
if f.split("_")[0].isdigit())
if current and current.startswith(f"{latest_prefix:03d}".rstrip("0") or "0"):
print(green(f" ✓ alembic at head {current}"))
elif current:
print(yellow(f" ! alembic at {current} but versions/ has up to {latest_prefix:03d}"))
print(yellow(f" → run: alembic upgrade head"))
else:
print(red(" ✗ alembic_version table empty — schema unmanaged"))
errors.append("schema unmanaged")
except Exception as exc:
print(yellow(f" ! schema version check failed: {exc}"))
# Orphan open positions
try:
async with AsyncSessionLocal() as db:
r = await db.execute(text(
"SELECT COUNT(*) FROM bot_trades WHERE closed_at IS NULL"
))
n = r.scalar() or 0
if n:
print(yellow(f" ! {n} open bot_trades rows — verify these match HL state before launch"))
else:
print(green(" ✓ no orphan open positions"))
except Exception as exc:
print(yellow(f" ! open-positions check failed: {exc}"))
return errors
async def check_telegram() -> list[str]:
errors = []
print()
print("── telegram ──────────────────────────────────────")
if not settings.telegram_bot_token:
print(yellow(" ! token empty — skipping"))
return errors
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"https://api.telegram.org/bot{settings.telegram_bot_token}/getMe"
)
if r.status_code != 200:
print(red(f" ✗ getMe HTTP {r.status_code}: {r.text[:120]}"))
errors.append("telegram auth failed")
return errors
data = r.json()
if not data.get("ok"):
print(red(f" ✗ getMe returned ok=false: {data}"))
errors.append("telegram auth failed")
return errors
bot_username = data["result"]["username"]
print(green(f" ✓ token authenticates as @{bot_username}"))
if settings.telegram_bot_username and bot_username != settings.telegram_bot_username:
print(red(f" ✗ TELEGRAM_BOT_USERNAME='{settings.telegram_bot_username}'"
f" but token is for @{bot_username}"))
errors.append("telegram username mismatch")
except Exception as exc:
print(red(f" ✗ telegram check failed: {exc}"))
errors.append("telegram unreachable")
return errors
async def check_ai() -> list[str]:
errors = []
print()
print("── ai provider ───────────────────────────────────")
if settings.anthropic_api_key:
# Cheap, free models endpoint check.
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
"https://api.anthropic.com/v1/models",
headers={
"x-api-key": settings.anthropic_api_key,
"anthropic-version": "2023-06-01",
},
)
if r.status_code == 200:
print(green(" ✓ anthropic_api_key authenticates"))
else:
print(red(f" ✗ anthropic /v1/models HTTP {r.status_code}: {r.text[:120]}"))
errors.append("anthropic auth failed")
except Exception as exc:
print(red(f" ✗ anthropic check failed: {exc}"))
elif settings.ai_api_key:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"{settings.ai_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {settings.ai_api_key}"},
)
if r.status_code == 200:
print(green(f" ✓ ai_api_key authenticates at {settings.ai_base_url}"))
else:
print(red(f" ✗ /models HTTP {r.status_code}: {r.text[:120]}"))
errors.append("ai auth failed")
except Exception as exc:
print(red(f" ✗ ai check failed: {exc}"))
else:
print(yellow(" ! no ai provider key set — Trump signals will not be scored"))
return errors
async def main() -> int:
print(f"Preflight check — env: {settings.environment}\n")
if settings.environment != "production":
print(yellow("WARNING: settings.environment is not 'production'. Some dev-only"))
print(yellow(" endpoints (/api/dev/*) and auto-create_all() are enabled.\n"))
all_errors: list[str] = []
all_errors += await check_env()
all_errors += await check_db()
all_errors += await check_telegram()
all_errors += await check_ai()
await engine.dispose()
print()
if all_errors:
print(red(f"{len(all_errors)} fatal issue(s):"))
for e in all_errors:
print(red(f" - {e}"))
return 1
print(green("✅ All checks passed. Safe to launch."))
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Seed the kol_wallets table with publicly attributed on-chain addresses.
Why this exists:
The talks-vs-trades divergence module can only fire on KOLs whose wallets
we know. As of 2026-05-24 only 3 KOLs had wallets configured, which is
why we had ~3 divergence detections across the whole dataset.
Adding a new wallet here REQUIRES:
1. Public attribution — Arkham label, the KOL's own X bio, a public
investigation by ZachXBT / Inspex / similar, or the KOL's ENS clearly
visible in transactions.
2. The `handle` field MUST exactly match a `handle` value in
app/services/kol_substack.py KOL_FEEDS — otherwise the divergence
scanner cannot match "post by handle X" against "wallet activity by
handle X".
3. The `source_url` must link to that public attestation. Don't add
speculative addresses, even if "everyone knows" — misattribution
damages both the KOL and our credibility.
Idempotent: runs UPSERT-style. Existing rows for (handle, address) are
preserved; only new ones are inserted.
Usage:
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/seed_kol_wallets.py
"""
from __future__ import annotations
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import KolWallet
# ────────────────────────────────────────────────────────────────────────────
# Verified seed list.
#
# Each entry MUST have a working source_url. If you can't link to a public
# attestation, DON'T add it.
#
# To find more candidates yourself:
# • https://platform.arkhamintelligence.com/ — search by handle, click
# "labels" tab. Labels prefixed with "Entity:" are Arkham-verified.
# • https://etherscan.io/labelcloud — Etherscan public label registry.
# • ZachXBT investigations on X — he posts the underlying tx evidence.
# • Many KOLs put their address in their X bio or pinned tweet.
#
# When the KOL's handle in KOL_FEEDS doesn't match the on-chain handle here,
# add it under the handle that's IN KOL_FEEDS — divergence join is by handle.
# ────────────────────────────────────────────────────────────────────────────
SEED_WALLETS: list[dict] = [
# ── Already in DB (kept here so the script is self-documenting) ──────
{
"handle": "cryptohayes",
"chain": "ethereum",
"address": "0xa86e3d1c80a750a310b484fb9bdc470753a7506f",
"label": "Arthur Hayes (main)",
"source_url": "https://etherscan.io/address/0xa86e3d1c80a750a310b484fb9bdc470753a7506f",
},
{
"handle": "cryptohayes",
"chain": "ethereum",
"address": "0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587",
"label": "Arthur Hayes (secondary)",
"source_url": "https://etherscan.io/address/0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587",
},
{
"handle": "andrewkang",
"chain": "ethereum",
"address": "0xff3879b8a363aed92a6eaba8f61f1a96a9ec3c1e",
"label": "Andrew Kang (beanwhale.eth)",
"source_url": "https://etherscan.io/address/0xff3879b8a363aed92a6eaba8f61f1a96a9ec3c1e",
},
{
"handle": "murad",
"chain": "ethereum",
"address": "0x93f019699ef400df7dc3477dbb6400ed9445a657",
"label": "Murad Mahmudov (via ZachXBT investigation)",
"source_url": "https://www.blocmates.com/news-posts/24million-in-memecoins-zachxbt-unveils-murad-mahmudov-s-alleged-wallets",
},
# ── Add new VERIFIED entries below this line ─────────────────────────
#
# Template — copy, replace fields, push only after verifying source_url:
#
# {
# "handle": "<matches handle in KOL_FEEDS>",
# "chain": "ethereum", # or "solana", "base", "arbitrum"
# "address": "0x...",
# "label": "<KOL display name (annotation)>",
# "source_url": "<public link proving attribution>",
# },
#
# Candidates to research (NOT seeded — verify before adding):
#
# • niccarter — Nic Carter has spoken openly about his wallet
# history on podcasts; check Coin Center filings.
# • pomp — Pompliano has a public BTC-only treasury; less
# useful for ETH-side divergence detection.
# • dragonfly — Dragonfly Capital portfolio wallets often
# labeled on Arkham as "Dragonfly Fund".
# • placeholder — Placeholder VC fund wallets per their public
# investment disclosures.
# • eugene — Eugene Ng Ah Sio — verify before adding.
#
# Also worth tracking even if not in KOL_FEEDS (would need to add the
# corresponding feed entry first or they'll never have post-side data):
#
# • justinsuntron — Tron founder, very active ETH trader, well-labeled.
# • cobie — Jordan Fish, address known via Arkham labels.
# • gcr / sam — anon traders, no reliably verifiable address.
]
async def main() -> int:
inserted = 0
skipped = 0
async with AsyncSessionLocal() as session:
for entry in SEED_WALLETS:
# Idempotency: skip if (handle, address) already present.
existing = await session.execute(
select(KolWallet).where(
KolWallet.handle == entry["handle"],
KolWallet.address == entry["address"].lower(),
)
)
if existing.scalar_one_or_none():
skipped += 1
continue
row = KolWallet(
handle=entry["handle"],
chain=entry["chain"],
address=entry["address"].lower(),
label=entry["label"],
source_url=entry["source_url"],
active=True,
added_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
session.add(row)
inserted += 1
print(f" + {entry['handle']:18s} {entry['address']} ({entry['label']})")
await session.commit()
print()
print(f"Inserted {inserted} wallets, skipped {skipped} existing.")
print()
print("Next step: edit SEED_WALLETS above and re-run. Each new wallet")
print("MUST cite a public attestation in source_url — see the docstring.")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+196
View File
@@ -0,0 +1,196 @@
"""
System-2 生命周期·小额真单端到端验证
验证我们新写的三个动钱路径在真实 Hyperliquid 上的行为,并和账面记账对账:
开仓 → 加仓(pyramid) → 部分减仓(de-risk) → 全平
每一步都把"预期""HL 实际"并排打印,并用和 bot_engine 完全一致的
公式做 PnL 自洽校验。
用法:
source venv/bin/activate
HL_API_PRIVATE_KEY="0x..." HL_ACCOUNT_ADDRESS="0x..." \
python scripts/verify_sys2_lifecycle.py # 干跑(不下单, 只打印计划)
... python scripts/verify_sys2_lifecycle.py --live # 真下单(小额, 需确认)
安全:
- 默认 DRY-RUN, 不下任何单
- --live 才真下单, 且会要求手动输入 YES 确认
- 名义金额默认 $20, 上限 $40 (超过需 --force), 杠杆默认 2x
- 任何异常 / 结束都会尝试把仓位平掉 (best-effort flatten)
- mainnet/testnet 跟随 settings.hl_mainnet
"""
import argparse
import asyncio
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.config import settings
from app.services.hyperliquid import HyperliquidTrader
from app.services.bot_engine import HL_TAKER_FEE_RATE
ASSET = "BTC"
SIDE = "long"
HARD_CAP_USD = 40.0
def _slice_pnl(notional: float, entry: float, exit_px: float, side: str) -> float:
"""Identical to bot_engine: notional × signed move round-trip taker."""
pct = (exit_px - entry) / entry if entry else 0.0
signed = pct if side == "long" else -pct
return notional * signed - notional * HL_TAKER_FEE_RATE * 2
def _pos(positions: list):
return next((p for p in positions if p.get("coin") == ASSET), None)
def _row(label, expected, actual):
print(f" {label:<28} expected={expected!s:<22} actual={actual!s}")
async def _flatten(trader, why: str):
try:
pos = _pos(await trader.get_open_positions())
if pos:
print(f"\n🧹 安全平仓 ({why}) — 当前 szi={pos['szi']}")
r = await trader.close_position(ASSET)
print(f" 平仓结果: {r}")
else:
print(f"\n🧹 无残留仓位 ({why})")
except Exception as exc:
print(f"\n⚠️ 安全平仓失败 ({why}): {exc} — 请手动检查 HL!")
async def main(live: bool, size_usd: float, leverage: int, force: bool):
api_key = os.getenv("HL_API_PRIVATE_KEY") or os.getenv("HL_API_KEY", "")
account = os.getenv("HL_ACCOUNT_ADDRESS", "")
if not api_key or not account:
print("❌ 需要 HL_API_PRIVATE_KEY 和 HL_ACCOUNT_ADDRESS 环境变量")
sys.exit(1)
if size_usd > HARD_CAP_USD and not force:
print(f"❌ size_usd ${size_usd} 超过安全上限 ${HARD_CAP_USD}(加 --force 才允许)")
sys.exit(1)
net = "MAINNET 真钱" if settings.hl_mainnet else "TESTNET"
add_usd = round(size_usd * 0.30, 2) # 模拟 pyramid 第1档 (+30% base)
derisk_frac_of_cur = 1.0 / 3.0 # 模拟 de-risk 第1档 (减当前 1/3)
print("=" * 64)
print(f"System-2 生命周期验证 · {net} · {ASSET} {SIDE} {leverage}x")
print(f"计划: 开 ${size_usd} → 加 ${add_usd} → 减当前 1/3 → 全平")
print(f"模式: {'🔴 LIVE 真下单' if live else '🟢 DRY-RUN 仅打印'}")
print("=" * 64)
if not live:
print("\n干跑结束。确认计划无误后加 --live 真跑(小额)。")
return
confirm = input(f"\n⚠️ 将在 {net} 下真单(约 ${size_usd})。输入大写 YES 继续: ")
if confirm.strip() != "YES":
print("已取消。")
return
trader = HyperliquidTrader(
api_private_key=api_key, account_address=account,
leverage=leverage, mainnet=settings.hl_mainnet,
)
bal = await trader.get_balance()
print(f"\n账户可用 USDC: ${bal:.2f}")
if bal < size_usd:
print("❌ 余额不足,放弃。")
return
if _pos(await trader.get_open_positions()):
print(f"❌ 已存在 {ASSET} 持仓 — 为避免干扰,请先手动清空后再跑。")
return
try:
# ── 1. 开仓 ──────────────────────────────────────────────────────
print("\n[1] 开仓 open_position")
o = await trader.open_position(ASSET, SIDE, size_usd)
entry = float(o["fill_price"])
base_coins = float(o["size_coins"])
base_notional = base_coins * entry
pos = _pos(await trader.get_open_positions())
_row("entry fill", "~mkt", entry)
_row("size_coins", f"~{size_usd/entry:.6f}", base_coins)
_row("HL szi", f"~{base_coins:.6f}", pos and pos["szi"])
assert pos and abs(abs(pos["szi"]) - base_coins) / base_coins < 0.05, "开仓后 HL 仓位不符"
# ── 2. 加仓 (pyramid 第1档) ──────────────────────────────────────
print("\n[2] 加仓 open_position(模拟 pyramid +30%")
a = await trader.open_position(ASSET, SIDE, add_usd)
add_fill = float(a["fill_price"])
add_coins = float(a["size_coins"])
actual_add_notional = add_coins * add_fill # ← 和修过的 pyramid_add 一致
# 混合均价:按名义加权(与 bot_engine.pyramid_add 完全相同的公式)
old_notional = base_notional
new_notional = old_notional + actual_add_notional
blended = (old_notional * entry + actual_add_notional * add_fill) / new_notional
pos = _pos(await trader.get_open_positions())
exp_coins = base_coins + add_coins
_row("add fill", "~mkt", add_fill)
_row("add size_coins", f"~{add_usd/add_fill:.6f}", add_coins)
_row("blended entry", f"{blended:.2f}", "(账面)")
_row("HL szi", f"~{exp_coins:.6f}", pos and pos["szi"])
assert pos and abs(abs(pos["szi"]) - exp_coins) / exp_coins < 0.05, "加仓后 HL 仓位不符"
# ── 3. 部分减仓 (de-risk 第1档:减当前 1/3) ──────────────────────
print("\n[3] 部分减仓 reduce_position(1/3)")
pre_coins = abs(pos["szi"])
r = await trader.reduce_position(ASSET, derisk_frac_of_cur)
cut_fill = float(r["fill_price"])
closed_frac = float(r["closed_fraction"])
pos = _pos(await trader.get_open_positions())
post_coins = abs(pos["szi"]) if pos else 0.0
actually_cut = pre_coins - post_coins
slice_notional = actually_cut * cut_fill
slice_pnl = _slice_pnl(slice_notional, blended, cut_fill, SIDE)
_row("reduce fill", "~mkt", cut_fill)
_row("closed_fraction", f"~{derisk_frac_of_cur:.3f}", round(closed_frac, 4))
_row("coins cut", f"~{pre_coins/3:.6f}", round(actually_cut, 6))
_row("剩余 szi", f"~{pre_coins*2/3:.6f}", pos and pos["szi"])
_row("该片已实现PnL($)", "", round(slice_pnl, 4))
assert 0.25 < closed_frac < 0.42, "减仓比例偏离 1/3 过大"
# ── 4. 全平 ──────────────────────────────────────────────────────
print("\n[4] 全平 close_position")
c = await trader.close_position(ASSET)
close_fill = float(c["fill_price"])
remaining_notional = post_coins * close_fill
remaining_pnl = _slice_pnl(remaining_notional, blended, close_fill, SIDE)
pos = _pos(await trader.get_open_positions())
_row("close fill", "~mkt", close_fill)
_row("收尾后持仓", "None", pos)
assert pos is None, "全平后仍有残留仓位!"
total_pnl = slice_pnl + remaining_pnl
print("\n" + "=" * 64)
print("对账小结(账面 vs 行为)")
print(f" 混合均价 : {blended:.2f}")
print(f" 分片PnL(减1/3) : {slice_pnl:+.4f} USD")
print(f" 收尾PnL(剩2/3) : {remaining_pnl:+.4f} USD")
print(f" 合计PnL : {total_pnl:+.4f} USD(应≈HL账户实际变动,含滑点/费)")
print(" ✅ 开/加/减/平 四步与 HL 实际持仓全部一致")
print("=" * 64)
print("\n说明: 这里用市价瞬时往返,PnL≈ -往返taker费(~0.09%)±滑点,属正常。")
except AssertionError as e:
print(f"\n❌ 校验失败: {e}")
except Exception as e:
print(f"\n❌ 异常: {e}")
finally:
await _flatten(trader, "收尾")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--live", action="store_true", help="真下单(默认干跑)")
ap.add_argument("--size", type=float, default=20.0, help="开仓名义USD(默认20")
ap.add_argument("--leverage", type=int, default=2, help="杠杆(默认2x")
ap.add_argument("--force", action="store_true", help="允许 size 超过安全上限")
a = ap.parse_args()
asyncio.run(main(a.live, a.size, a.leverage, a.force))