feat: IBKR paper-trading bot for the adaptive multi-strat book (ib_async)
Runs the book on IBKR PAPER (realistic fills, no capital): gets target weights from the harness (Yahoo), connects to IB Gateway/TWS paper, rebalances the ETF book (SPY/IEF/GLD/PDBC/DBMF + IBIT for crypto = no exchange counterparty tail). Default dry-run (shows intended orders); 'rebalance' places on paper. Hysteresis 3% of NLV. User runs Gateway + logs in (their creds); bot connects on 127.0.0.1:4002 (paper Gateway) / 7497 (TWS). Best paper-forward: realistic fills + real API, zero risk. Setup steps in the docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
scripts/surfer/ibkr_paper_book.py
Normal file
108
scripts/surfer/ibkr_paper_book.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the adaptive multi-strat book on IBKR PAPER trading (realistic fills, no capital).
|
||||
|
||||
Gets target weights from the harness (Yahoo data), connects to your IB Gateway/TWS paper account,
|
||||
and rebalances the ETF book on it. Crypto sleeve -> IBIT (Bitcoin ETF: in-brokerage, no crypto-
|
||||
exchange counterparty tail). Default DRY-RUN (shows intended orders); pass `rebalance` to place them.
|
||||
|
||||
SETUP (you do this — your credentials, never me):
|
||||
1. Create a free IBKR paper-trading account (or use your live account's paper login).
|
||||
2. Run IB Gateway (or TWS) and log in to the PAPER account.
|
||||
3. In Gateway/TWS: API settings -> enable "ActiveX and Socket Clients", note the port
|
||||
(IB Gateway paper = 4002, TWS paper = 7497). Add 127.0.0.1 to trusted IPs.
|
||||
4. Then: python3 scripts/surfer/ibkr_paper_book.py status (test the connection)
|
||||
python3 scripts/surfer/ibkr_paper_book.py dry (show intended orders)
|
||||
python3 scripts/surfer/ibkr_paper_book.py rebalance (place orders on PAPER)
|
||||
Override port: ... <mode> --port 7497
|
||||
|
||||
Sizing uses Yahoo last-close (consistent with the book); IBKR fills at market. Hysteresis: only
|
||||
trade a name if its target $ drifts > 3% of NLV. Run weekly/monthly via cron once validated.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import build, book_series, INSTR # noqa: E402
|
||||
|
||||
TICKER = {"equity": "SPY", "bond": "IEF", "gold": "GLD", "commod": "PDBC", "trend": "DBMF", "crypto": "IBIT"}
|
||||
HYST = 0.03 # only trade if target weight drifts > 3% of NLV
|
||||
|
||||
|
||||
def yhist_last(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=5d",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
c = [x for x in res["indicators"]["quote"][0]["close"] if x is not None]
|
||||
return float(c[-1])
|
||||
|
||||
|
||||
def target_book():
|
||||
"""{ticker: weight} from the adaptive harness (Yahoo data)."""
|
||||
dates, R = build()
|
||||
book, w, L = book_series(R)
|
||||
return {TICKER[nm]: float(w[j] * L) for j, (_, nm) in enumerate(INSTR)}
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dry"
|
||||
port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else 4002
|
||||
|
||||
tw = target_book()
|
||||
px = {t: yhist_last(t if t != "IBIT" else "IBIT") for t in tw} # IBIT trades on Yahoo too
|
||||
print(f"target weights (adaptive book, {datetime.date.today()}):")
|
||||
for t, w in tw.items():
|
||||
print(f" {t:>6}: {100*w:>5.1f}% (px ~${px[t]:.2f})")
|
||||
|
||||
if mode == "target":
|
||||
return
|
||||
|
||||
from ib_async import IB, Stock, MarketOrder
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect("127.0.0.1", port, clientId=7, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"\nCONNECT FAILED on 127.0.0.1:{port} — is IB Gateway/TWS running + logged in to PAPER, API enabled?")
|
||||
print(f" ({type(e).__name__}: {str(e)[:80]}) | try --port 7497 for TWS paper")
|
||||
return
|
||||
nlv = next((float(v.value) for v in ib.accountSummary() if v.tag == "NetLiquidation"), 0.0)
|
||||
cash = next((float(v.value) for v in ib.accountSummary() if v.tag == "TotalCashValue"), 0.0)
|
||||
pos = {p.contract.symbol: p.position for p in ib.positions()}
|
||||
print(f"\nIBKR PAPER account: NLV ${nlv:,.0f} cash ${cash:,.0f} | positions: {pos or 'none'}")
|
||||
if nlv <= 0:
|
||||
print(" (no NLV — check the paper account is funded with paper cash)"); ib.disconnect(); return
|
||||
|
||||
orders = []
|
||||
for t, w in tw.items():
|
||||
tgt_sh = nlv * w / px[t]
|
||||
cur = pos.get(t, 0.0)
|
||||
delta = tgt_sh - cur
|
||||
if abs(delta * px[t]) > HYST * nlv:
|
||||
orders.append((t, "BUY" if delta > 0 else "SELL", round(abs(delta), 4)))
|
||||
print(f"\nintended orders (hysteresis {int(HYST*100)}% of NLV):")
|
||||
for t, side, qty in orders:
|
||||
print(f" {side:>4} {qty:>9.4f} {t} (~${qty*px[t]:,.0f})")
|
||||
if not orders:
|
||||
print(" none — book already within hysteresis band.")
|
||||
|
||||
if mode == "rebalance" and orders:
|
||||
print("\nPLACING on PAPER...")
|
||||
for t, side, qty in orders:
|
||||
c = Stock(t, "SMART", "USD"); ib.qualifyContracts(c)
|
||||
o = MarketOrder(side, qty); o.account = ib.managedAccounts()[0]
|
||||
tr = ib.placeOrder(c, o); ib.sleep(1)
|
||||
print(f" {side} {qty} {t}: {tr.orderStatus.status}")
|
||||
ib.sleep(3)
|
||||
print("done — check fills in Gateway/TWS.")
|
||||
elif orders:
|
||||
print("\n(dry-run — pass 'rebalance' to actually place these on the paper account.)")
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user