#!/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: ... --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 accts = ib.managedAccounts() acct = accts[0] if accts else "?" is_paper = acct.startswith("DU") # IBKR convention: DU* = paper/demo, U* = live 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 account: {acct} [{'PAPER ✅' if is_paper else 'LIVE ⚠️ — NOT paper!'}] 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 if mode == "mini": if not is_paper: print(" ABORT: account is NOT a paper account (no DU prefix). Refusing to place a test order."); ib.disconnect(); return from ib_async import Stock, MarketOrder c = Stock("SPY", "SMART", "USD"); ib.qualifyContracts(c) print("\nplacing MINI test order: BUY 1 SPY (market) on PAPER...") o = MarketOrder("BUY", 1); o.account = acct tr = ib.placeOrder(c, o) for _ in range(20): ib.sleep(1) if tr.orderStatus.status in ("Filled", "Cancelled", "ApiCancelled", "Inactive"): break fills = [f"{f.execution.shares}@${f.execution.price}" for f in tr.fills] print(f" order status: {tr.orderStatus.status} filled {tr.orderStatus.filled}/1 avgPx ${tr.orderStatus.avgFillPrice or 0:.2f} fills={fills or 'none'}") ib.sleep(1) newpos = {p.contract.symbol: p.position for p in ib.positions()} print(f" positions now: {newpos or 'none'}") print(f" → VALIDATED: this is the {'PAPER' if is_paper else 'LIVE'} env, order routing works." if tr.orderStatus.status == "Filled" else " → order not filled yet (market may be closed — it'll fill at next open; status shows it routed).") 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 # entry from zero uses a small floor (0.5% NLV); rebalancing an existing position uses full hysteresis thresh = (0.005 if cur == 0 else HYST) * nlv if abs(delta * px[t]) > thresh: 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()