Initial working version, positions show.
This commit is contained in:
28
README.md
Normal file
28
README.md
Normal file
@@ -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.
|
||||||
BIN
backend/__pycache__/ib_client.cpython-313.pyc
Normal file
BIN
backend/__pycache__/ib_client.cpython-313.pyc
Normal file
Binary file not shown.
75
backend/app.py
Normal file
75
backend/app.py
Normal 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
233
backend/ib_client.py
Normal 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
|
||||||
32
frontend/app.js
Normal file
32
frontend/app.js
Normal file
@@ -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 = `<td>${p.symbol}</td><td>${p.position}</td><td>${avg}</td><td>${p.account ?? ''}</td>`;
|
||||||
|
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);
|
||||||
28
frontend/index.html
Normal file
28
frontend/index.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>IB Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin: 20px; }
|
||||||
|
input, button { padding: 6px 8px; margin-right: 6px }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 12px }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left }
|
||||||
|
th { background: #f4f4f4 }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Interactive Brokers Dashboard</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Positions</h2>
|
||||||
|
<table id="positions-table">
|
||||||
|
<thead><tr><th>Symbol</th><th>Position</th><th>Average Cost</th><th>Account</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ibapi>=9.80
|
||||||
|
flask>=2.0.0
|
||||||
Reference in New Issue
Block a user