Initial working version, positions show.

This commit is contained in:
2026-04-01 19:17:09 -05:00
commit 28afa137cb
7 changed files with 398 additions and 0 deletions

Binary file not shown.

75
backend/app.py Normal file
View File

@@ -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)

233
backend/ib_client.py Normal file
View File

@@ -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