From 28afa137cb70a48d51659610c77e3f8ebe0bac76 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 1 Apr 2026 19:17:09 -0500 Subject: [PATCH] Initial working version, positions show. --- README.md | 28 +++ backend/__pycache__/ib_client.cpython-313.pyc | Bin 0 -> 11863 bytes backend/app.py | 75 ++++++ backend/ib_client.py | 233 ++++++++++++++++++ frontend/app.js | 32 +++ frontend/index.html | 28 +++ requirements.txt | 2 + 7 files changed, 398 insertions(+) create mode 100644 README.md create mode 100644 backend/__pycache__/ib_client.cpython-313.pyc create mode 100644 backend/app.py create mode 100644 backend/ib_client.py create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a52067 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# IB Dashboard + +Simple Interactive Brokers dashboard using vanilla JavaScript frontend and a small Python backend. + +Requirements +- Python 3.8+ +- IB Gateway or TWS running locally on port 7496 + +Install + +```bash +python -m pip install -r requirements.txt +``` + +Run + +```bash +python backend/app.py +# then open http://127.0.0.1:8000 in your browser +``` + +Usage +- Enter symbols (comma-separated) and click Subscribe. +- The page polls for quotes and positions every second. + +Notes and limitations +- This is a minimal example. It uses polling rather than websockets or SSE. +- The backend relies on `ibapi` to talk to the local IB Gateway/TWS. Make sure the gateway is configured to accept API connections. diff --git a/backend/__pycache__/ib_client.cpython-313.pyc b/backend/__pycache__/ib_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fd8146dca44190a2f8fe9fd3021879338f72340 GIT binary patch literal 11863 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 literal 0 HcmV?d00001 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..f248b90 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,75 @@ +"""Flask backend for IB dashboard. + +Provides simple REST endpoints that the vanilla-JS frontend polls: +- GET /api/quotes -> latest market quotes +- GET /api/positions -> latest positions +- POST /api/subscribe { symbols: ["AAPL","MSFT"] } -> start subscribing + +This app expects an IB Gateway or TWS running locally on port 7496. +""" +from flask import Flask, jsonify, request, send_from_directory +import os +import threading + +from ib_client import IBClient +import time + + +# Use an absolute path for the static folder so Flask can reliably serve files +STATIC_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend")) +app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path="/static") + +# instantiate the IB client with a process-unique client id to avoid +# collisions when Flask's reloader spawns multiple processes. +ib = IBClient(client_id=os.getpid()) +# Start connection only in the main process (Werkzeug sets WERKZEUG_RUN_MAIN) +if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not os.environ.get("WERKZEUG_RUN_MAIN"): + # attempt to start the connection; failures are logged by the client + try: + ib.connect_and_start() + except Exception: + pass + + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/api/quotes") +def api_quotes(): + return jsonify(ib.get_quotes()) + + +@app.route("/api/positions") +def api_positions(): + # request a fresh positions snapshot from IB and wait briefly for callbacks + try: + ib.request_positions() + except Exception: + pass + # wait up to 2 seconds for positions to arrive + deadline = time.time() + 2.0 + while time.time() < deadline: + positions = ib.get_positions() + if positions: + return jsonify(positions) + time.sleep(0.1) + # return whatever we have (possibly empty) + return jsonify(ib.get_positions()) + + +@app.route("/api/subscribe", methods=["POST"]) +def api_subscribe(): + data = request.get_json(force=True) + symbols = data.get("symbols", []) if isinstance(data, dict) else [] + if not isinstance(symbols, list): + return jsonify({"error": "symbols must be a list"}), 400 + for s in symbols: + ib.subscribe_market_data(s) + return jsonify({"subscribed": symbols}) + + +if __name__ == "__main__": + # simple dev server; disable reloader to avoid duplicate IB client instances + app.run(debug=True, port=8000, use_reloader=False) diff --git a/backend/ib_client.py b/backend/ib_client.py new file mode 100644 index 0000000..410a11b --- /dev/null +++ b/backend/ib_client.py @@ -0,0 +1,233 @@ +"""Lightweight IB client wrapper using ibapi. + +This wraps a minimal subset of EClient/EWrapper to subscribe to market +data and track positions in-memory. It's designed to be used by a +small Flask backend that serves a browser dashboard. + +Notes: +- Keep behavior simple: subscribe to market data for provided symbols. +- Store latest quotes in `self.quotes` and positions in `self.positions`. +""" +import logging +import threading +import time +from typing import Dict, List + +from ibapi.client import EClient +from ibapi.contract import Contract +from ibapi.wrapper import EWrapper + + +class IBClient(EWrapper, EClient): + def __init__(self, host: str = "127.0.0.1", port: int = 7496, client_id: int = 1): + EClient.__init__(self, self) + # normalize/validate connection params + if not host: + host = "127.0.0.1" + if not isinstance(port, int): + try: + port = int(port) + except Exception: + port = 7496 + self.host = host + self.port = port + self.client_id = client_id + + self._thread = None + self._next_req_id = 1 + + # connection sync + self._connected_event = threading.Event() + + # subscriptions requested before connection + self._pending_subscriptions = set() + + # in-memory stores + self.quotes: Dict[str, Dict] = {} + self.positions: List[Dict] = [] + + # tracking for subscriptions: reqId -> symbol + self._req_map: Dict[int, str] = {} + self._requested_symbols = set() + + # basic logging + logging.basicConfig(level=logging.INFO) + + def connect_and_start(self) -> None: + if not self.isConnected(): + logging.info("Connecting to IB gateway %s:%s", self.host, self.port) + self.connect(self.host, self.port, self.client_id) + self._thread = threading.Thread(target=self.run, daemon=True) + self._thread.start() + # don't block here; nextValidId will mark connection ready + + def disconnect_and_stop(self) -> None: + try: + if self.isConnected(): + self.disconnect() + except Exception: + pass + + # ---- EWrapper overrides (minimal) ---- + def nextValidId(self, orderId: int): + super().nextValidId(orderId) + # ensure we start with a reasonably large req id to avoid collisions + if orderId and orderId > self._next_req_id: + self._next_req_id = orderId + # mark connection as ready + try: + self._connected_event.set() + except Exception: + pass + # process any pending subscriptions + if self._pending_subscriptions: + pending = list(self._pending_subscriptions) + self._pending_subscriptions.clear() + for s in pending: + try: + self.subscribe_market_data(s) + except Exception: + logging.exception("Failed to process pending subscription %s", s) + + def error(self, reqId, errorCode, errorString): + logging.error("IB error (req=%s code=%s): %s", reqId, errorCode, errorString) + + def tickPrice(self, reqId, tickType, price, attrib): + # store last price when available + if price is None: + return + symbol = self._req_map.get(reqId) + if not symbol: + return + data = self.quotes.setdefault(symbol, {}) + data["last"] = price + + def tickSize(self, reqId, tickType, size): + symbol = self._req_map.get(reqId) + if not symbol: + return + data = self.quotes.setdefault(symbol, {}) + data["size"] = size + + def updatePortfolio(self, contract, position, marketPrice, marketValue, averageCost, unrealizedPNL, realizedPNL, accountName): + # simple representation of the position + if not contract or not contract.symbol: + return + pos = { + "symbol": contract.symbol, + "position": position, + "marketPrice": marketPrice, + "marketValue": marketValue, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("symbol") == pos["symbol"]: + self.positions[i] = pos + break + else: + self.positions.append(pos) + + def positionEnd(self): + # called when a batch of position updates is finished + logging.info("Position update batch complete. %d positions stored", len(self.positions)) + + def position(self, account, contract, position, avgCost): + """Handle positions returned by `reqPositions()`. + + Signature from EWrapper: position(self, account, contract, position, avgCost) + """ + try: + if not contract or not getattr(contract, 'symbol', None): + return + pos = { + "symbol": contract.symbol, + "position": position, + "averageCost": avgCost, + "account": account, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("symbol") == pos["symbol"] and p.get("account") == account: + self.positions[i] = pos + break + else: + self.positions.append(pos) + except Exception: + logging.exception("Error handling position callback") + + # ---- helpers ---- + def _new_req_id(self) -> int: + rid = self._next_req_id + self._next_req_id += 1 + return rid + + def _make_stock_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> Contract: + c = Contract() + c.symbol = symbol + c.secType = "STK" + c.exchange = exchange + c.currency = currency + return c + + def subscribe_market_data(self, symbol: str) -> None: + """Subscribe to basic market data for `symbol` (in USD on SMART exchange). + + Repeated calls for the same symbol are ignored. + """ + if symbol in self._requested_symbols: + return + # if not connected yet, queue the subscription + if not self._connected_event.is_set(): + logging.info("Not connected yet; queuing subscription for %s", symbol) + self._pending_subscriptions.add(symbol) + # ensure we start connection + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection while queuing subscription") + return + + req_id = self._new_req_id() + self._req_map[req_id] = symbol + contract = self._make_stock_contract(symbol) + # request real-time market data (empty generic tick list) + try: + self.reqMarketDataType(1) + self.reqMktData(req_id, contract, "", False, False, []) + self._requested_symbols.add(symbol) + logging.info("Subscribed to market data for %s (req=%s)", symbol, req_id) + except Exception as e: + logging.exception("Failed to subscribe to %s: %s", symbol, e) + + def request_positions(self) -> None: + """Ask the IB API to send current positions (triggers updatePortfolio callbacks).""" + if not self._connected_event.is_set(): + logging.info("Not connected yet; will request positions only if connection parameters are valid") + # validate host/port before trying to connect + if not isinstance(self.host, str) or not isinstance(self.port, int): + logging.error("Invalid connection parameters: host=%r port=%r", self.host, self.port) + return + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for positions request") + # schedule a delayed reqPositions after connection is established + def _delayed(): + if self._connected_event.wait(5): + try: + self.reqPositions() + except Exception: + logging.exception("Failed to request positions after connect") + threading.Thread(target=_delayed, daemon=True).start() + return + + try: + self.reqPositions() + except Exception: + logging.exception("Failed to request positions") + + def get_quotes(self) -> Dict[str, Dict]: + return self.quotes + + def get_positions(self) -> List[Dict]: + return self.positions diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..fef9c41 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,32 @@ +// Minimal vanilla-JS frontend to poll only positions and render the positions table +const qs = (s) => document.querySelector(s); +const ptbody = qs('#positions-table tbody'); + +async function fetchJson(path) { + const res = await fetch(path); + if (!res.ok) throw new Error('Network response not ok'); + return res.json(); +} + +function renderPositions(list) { + ptbody.innerHTML = ''; + for (const p of list) { + const row = document.createElement('tr'); + const avg = p.averageCost ?? p.average_cost ?? ''; + row.innerHTML = `${p.symbol}${p.position}${avg}${p.account ?? ''}`; + ptbody.appendChild(row); + } +} + +async function pollPositions() { + try { + const positions = await fetchJson('/api/positions'); + renderPositions(positions); + } catch (e) { + console.warn('Positions polling error', e); + } +} + +// start polling every 2 seconds +pollPositions(); +setInterval(pollPositions, 2000); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0137b56 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + IB Dashboard + + + +

Interactive Brokers Dashboard

+ +
+

Positions

+ + + +
SymbolPositionAverage CostAccount
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..823b594 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +ibapi>=9.80 +flask>=2.0.0 \ No newline at end of file