commit 28afa137cb70a48d51659610c77e3f8ebe0bac76 Author: Joe Tretter Date: Wed Apr 1 19:17:09 2026 -0500 Initial working version, positions show. 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 0000000..1fd8146 Binary files /dev/null and b/backend/__pycache__/ib_client.cpython-313.pyc differ 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