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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+62
@@ -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
|
||||
Executable
+239
@@ -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()))
|
||||
Executable
+162
@@ -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()))
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user