diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84792df --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +IB_FLEX_TOKEN=your_flex_web_service_token_here +IB_FLEX_QUERY_ID=your_saved_flex_query_id_here +IB_FLEX_DATA_DIR=data/flex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1ac045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +data/ \ No newline at end of file diff --git a/README.md b/README.md index 7a52067..3699aa4 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,155 @@ -# 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. +# IB Dashboard + +Interactive Brokers dashboard with a small Flask backend and a vanilla JavaScript frontend. + +The project currently uses two data paths: +- TWS / IB Gateway API for live positions and pricing +- IB Flex Web Service infrastructure for durable historical trade retrieval + +The Flex path is important for lot history because the socket API execution stream is not reliable enough on its own to reconstruct all historical lots in every setup. + +## Requirements + +- Python 3.8+ +- IB Gateway or TWS running locally on port `7496` +- For full historical lots: a saved IB Flex Query plus a Flex Web Service token + +## 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. + +## Current API Endpoints + +- `GET /api/quotes` +- `GET /api/positions` +- `GET /api/lots` +- `GET /api/executions-debug` +- `GET /api/flex/status` +- `POST /api/flex/sync` + +## Flex Query Setup + +This app now includes the infrastructure needed to pull historical trade data from IB Flex Web Service and save the raw XML locally. This is the recommended foundation for reconstructing lots regardless of how long ago they were acquired. + +### 1. Create a Flex Query in IB Client Portal + +In IB Client Portal: + +1. Open `Performance & Reports`. +2. Go to `Flex Queries`. +3. Create a new query for trades / executions. +4. Include enough fields to reconstruct lots. At minimum, include: + - account + - symbol + - underlying symbol if available + - asset / security type + - trade date / trade time + - quantity + - trade price + - buy/sell side + - strike + - expiry + - put/call + - multiplier + - conid if available + - local symbol / trading class if available +5. Save the query and note the `Query ID`. + +### 2. Create a Flex Web Service Token + +In IB Client Portal: + +1. Open the Flex Web Service section. +2. Generate or copy your Flex Web Service token. +3. Keep it secure. Treat it like a credential. + +IB reference: +- Flex Web Service overview: `https://ibkrcampus.com/campus/glossary-terms/flex-web-service/` + +### 3. Configure the App + +Use the included `.env.example` as a template. + +Set these environment variables before starting the backend: + +- `IB_FLEX_TOKEN` +- `IB_FLEX_QUERY_ID` +- `IB_FLEX_DATA_DIR` + +Example PowerShell session: + +```powershell +$env:IB_FLEX_TOKEN="your-token" +$env:IB_FLEX_QUERY_ID="123456" +$env:IB_FLEX_DATA_DIR="data/flex" +python backend/app.py +``` + +### 4. Verify Configuration + +Check the Flex status endpoint: + +```text +GET /api/flex/status +``` + +If configured correctly, it returns: +- `configured: true` +- the configured query id +- the local data directory +- the latest downloaded file if one exists + +### 5. Trigger a Flex Sync + +Trigger a manual download of the saved Flex Query: + +```text +POST /api/flex/sync +``` + +On success, the backend saves the raw XML file under `data/flex/` and returns: +- `ok: true` +- the saved file path +- the request reference code + +## What The Flex Infrastructure Does Today + +The current implementation provides: + +- environment-driven Flex configuration +- a backend Flex client for `SendRequest` and `GetStatement` +- local persistence of the downloaded XML files under `data/flex/` +- Flask endpoints to inspect config and trigger a sync + +Files added for this: + +- [backend/flex_query.py](backend/flex_query.py) +- [.env.example](.env.example) +- [data/flex/.gitkeep](data/flex/.gitkeep) + +## What Still Needs To Be Wired In + +The Flex Query data is not yet merged into `/api/lots`. + +The next implementation step is: + +1. Parse the saved Flex XML. +2. Normalize trades into a local fill ledger. +3. Build lots from that persistent history. +4. Use that ledger as the primary source for `buy_time`, `cost_basis`, `CAGR`, and true open lots. + +## Notes + +- The frontend currently focuses on the lot table. +- The backend still uses the IB socket API for live positions and prices. +- The Flex path is intended to become the historical source of truth for lots. diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..2a09d32 Binary files /dev/null and b/backend/__pycache__/app.cpython-313.pyc differ diff --git a/backend/__pycache__/flex_lots.cpython-313.pyc b/backend/__pycache__/flex_lots.cpython-313.pyc new file mode 100644 index 0000000..1253ee3 Binary files /dev/null and b/backend/__pycache__/flex_lots.cpython-313.pyc differ diff --git a/backend/__pycache__/flex_query.cpython-313.pyc b/backend/__pycache__/flex_query.cpython-313.pyc new file mode 100644 index 0000000..20b345c Binary files /dev/null and b/backend/__pycache__/flex_query.cpython-313.pyc differ diff --git a/backend/__pycache__/ib_client.cpython-313.pyc b/backend/__pycache__/ib_client.cpython-313.pyc index 1fd8146..61b5bb1 100644 Binary files a/backend/__pycache__/ib_client.cpython-313.pyc and b/backend/__pycache__/ib_client.cpython-313.pyc differ diff --git a/backend/app.py b/backend/app.py index f248b90..c29d6dc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,16 +7,40 @@ Provides simple REST endpoints that the vanilla-JS frontend polls: 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")) +from flask import Flask, jsonify, request, send_from_directory +import os +import threading + +from flex_lots import load_latest_flex_lots +from flex_query import FlexQueryError, fetch_flex_statement, get_flex_status +from ib_client import IBClient +import time + + +def load_dotenv_file() -> None: + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + if not os.path.exists(env_path): + return + try: + with open(env_path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + except Exception: + pass + + +load_dotenv_file() + + +# 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 @@ -59,6 +83,167 @@ def api_positions(): return jsonify(ib.get_positions()) +@app.route("/api/lots") +def api_lots(): + flex_status = get_flex_status() + if flex_status.get("configured") and flex_status.get("data_dir"): + latest_file, flex_lots = load_latest_flex_lots(flex_status["data_dir"]) + if latest_file and flex_lots: + return jsonify(flex_lots) + + # request a fresh positions snapshot first so all account holdings are covered, + # even when IB does not return historical executions for every open position. + try: + ib.request_positions() + except Exception: + pass + positions_deadline = time.time() + 2.0 + while time.time() < positions_deadline: + if ib.get_positions(): + break + time.sleep(0.1) + + for p in ib.get_positions(): + account = p.get("account") if isinstance(p, dict) else None + if account: + try: + ib.request_account_updates(account) + except Exception: + pass + + # subscribe market data for all current positions to improve gain calculations + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if instrument_key: + try: + ib.subscribe_market_data(p) + except Exception: + pass + quotes_deadline = time.time() + 2.0 + position_symbols = { + p.get("instrument_key") + for p in ib.get_positions() + if isinstance(p, dict) and p.get("instrument_key") + } + while time.time() < quotes_deadline: + quotes = ib.get_quotes() + if any(instrument_key in quotes for instrument_key in position_symbols): + break + time.sleep(0.1) + + # request prior close for positions still missing a usable quote + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if not instrument_key: + continue + quote = ib.get_quotes().get(instrument_key, {}) + if quote.get("last") is None and quote.get("close") is None and quote.get("mark") is None: + try: + ib.request_historical_close(p) + except Exception: + pass + historical_deadline = time.time() + 2.0 + while time.time() < historical_deadline: + missing = [] + quotes = ib.get_quotes() + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if not instrument_key: + continue + quote = quotes.get(instrument_key, {}) + if ( + quote.get("last") is None + and quote.get("close") is None + and quote.get("mark") is None + and instrument_key not in ib.historical_closes + ): + missing.append(instrument_key) + if not missing: + break + time.sleep(0.1) + + # request executions and wait briefly for callbacks + try: + ib.request_executions(start_time="20000101 00:00:00") + except Exception: + pass + deadline = time.time() + 3.0 + while time.time() < deadline: + lots = ib.compute_open_lots(include_position_fallback=False) + if lots: + return jsonify(lots) + time.sleep(0.1) + return jsonify(ib.compute_open_lots(include_position_fallback=False)) + + +@app.route("/api/executions-debug") +def api_executions_debug(): + start_time = request.args.get("start_time", "20000101 00:00:00") + try: + ib.request_executions(start_time=start_time) + except Exception: + pass + deadline = time.time() + 5.0 + while time.time() < deadline: + time.sleep(0.1) + executions = list(ib.executions) + summary = {} + for e in executions: + key = e.get("instrument_key") or e.get("symbol") or "UNKNOWN" + item = summary.setdefault(key, { + "instrument_key": e.get("instrument_key"), + "symbol": e.get("symbol"), + "security_type": e.get("security_type"), + "display_symbol": e.get("display_symbol"), + "count": 0, + "first_time": None, + "last_time": None, + }) + item["count"] += 1 + etime = e.get("time") + if etime: + if item["first_time"] is None or etime < item["first_time"]: + item["first_time"] = etime + if item["last_time"] is None or etime > item["last_time"]: + item["last_time"] = etime + return jsonify({ + "start_time": start_time, + "execution_count": len(executions), + "executions": executions, + "summary": list(summary.values()), + }) + + +@app.route("/api/flex/status") +def api_flex_status(): + return jsonify(get_flex_status()) + + +@app.route("/api/flex/lots-debug") +def api_flex_lots_debug(): + status = get_flex_status() + if not status.get("configured") or not status.get("data_dir"): + return jsonify({"configured": False, "lots": [], "latest_file": None}) + latest_file, lots = load_latest_flex_lots(status["data_dir"]) + return jsonify({ + "configured": True, + "latest_file": latest_file, + "lot_count": len(lots), + "lots": lots, + }) + + +@app.route("/api/flex/sync", methods=["POST"]) +def api_flex_sync(): + try: + result = fetch_flex_statement() + return jsonify({"ok": True, **result}) + except FlexQueryError as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 500 + + @app.route("/api/subscribe", methods=["POST"]) def api_subscribe(): data = request.get_json(force=True) diff --git a/backend/flex_lots.py b/backend/flex_lots.py new file mode 100644 index 0000000..e8055ee --- /dev/null +++ b/backend/flex_lots.py @@ -0,0 +1,144 @@ +"""Parse lot-level open positions from IB Flex XML files.""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import xml.etree.ElementTree as ET + + +def _as_float(value, default=None): + try: + if value in (None, ""): + return default + return float(value) + except Exception: + return default + + +def _normalize_expiry(expiry: str | None) -> str | None: + expiry = (expiry or "").strip() + if len(expiry) == 8 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}-{expiry[6:8]}" + return expiry or None + + +def _normalize_buy_time(open_dt: str | None) -> str | None: + open_dt = (open_dt or "").strip() + if not open_dt or ";" not in open_dt: + return open_dt or None + date_part, time_part = open_dt.split(";", 1) + if len(date_part) == 8 and len(time_part) == 6: + return f"{date_part[0:4]}-{date_part[4:6]}-{date_part[6:8]} {time_part[0:2]}:{time_part[2:4]}:{time_part[4:6]}" + return open_dt + + +def _compute_cagr_percent(cost_basis, gain_dollars, buy_time: str | None): + if cost_basis in (None, 0) or gain_dollars is None or not buy_time: + return None + try: + start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + years = max((now - start).total_seconds() / (365.25 * 24 * 3600), 1e-9) + start_value = abs(float(cost_basis)) + end_value = start_value + float(gain_dollars) + if start_value <= 0 or end_value <= 0: + return None + return ((end_value / start_value) ** (1.0 / years) - 1.0) * 100.0 + except Exception: + return None + + +def _compute_days_since_buy(buy_time: str | None): + if not buy_time: + return None + try: + start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + return max((now - start).days, 0) + except Exception: + return None + + +def _display_symbol(attrs: dict) -> str: + description = (attrs.get("description") or "").strip() + if description: + return description + return (attrs.get("underlyingSymbol") or attrs.get("symbol") or "").strip() + + +def _instrument_key(attrs: dict) -> str: + conid = (attrs.get("conid") or "").strip() + if conid: + return f"CONID:{conid}" + return (attrs.get("symbol") or "").strip() + + +def parse_flex_lots(xml_path: str | Path) -> list[dict]: + root = ET.parse(xml_path).getroot() + lots: list[dict] = [] + for node in root.findall(".//OpenPosition"): + attrs = node.attrib + if (attrs.get("levelOfDetail") or "").upper() != "LOT": + continue + + security_type = (attrs.get("assetCategory") or "").upper() or "STK" + multiplier = _as_float(attrs.get("multiplier"), 1.0) or 1.0 + raw_position = _as_float(attrs.get("position"), 0.0) or 0.0 + side = (attrs.get("side") or "").strip().lower() + qty = -abs(raw_position) if side == "short" or raw_position < 0 else abs(raw_position) + + mark_price = _as_float(attrs.get("markPrice")) + market_price = mark_price + market_value = _as_float(attrs.get("positionValue")) + cost_basis = _as_float(attrs.get("costBasisMoney")) + avg_price = _as_float(attrs.get("costBasisPrice")) + if avg_price is None: + open_price = _as_float(attrs.get("openPrice")) + avg_price = open_price + if avg_price is None and qty: + divisor = qty * multiplier if security_type == "OPT" else qty + avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None + buy_time = _normalize_buy_time(attrs.get("openDateTime")) + gain_dollars = _as_float(attrs.get("fifoPnlUnrealized")) + gain_percent = None + if cost_basis not in (None, 0) and gain_dollars is not None: + gain_percent = (gain_dollars / abs(cost_basis)) * 100.0 + cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, buy_time) + days_since_buy = _compute_days_since_buy(buy_time) + ltcg = "X" if days_since_buy is not None and days_since_buy >= 365 else "" + + lots.append({ + "instrument_key": _instrument_key(attrs), + "con_id": _as_float(attrs.get("conid")), + "symbol": attrs.get("symbol"), + "display_symbol": _display_symbol(attrs), + "security_type": security_type, + "underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"), + "expiry": _normalize_expiry(attrs.get("expiry")), + "strike": _as_float(attrs.get("strike")), + "option_type": (attrs.get("putCall") or "").upper() or None, + "multiplier": str(int(multiplier)) if float(multiplier).is_integer() else str(multiplier), + "qty": qty, + "avg_price": avg_price, + "cost_basis": cost_basis, + "market_price": market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": cagr_percent, + "days_since_buy": days_since_buy, + "ltcg": ltcg, + "buy_time": buy_time, + }) + return lots + + +def load_latest_flex_lots(data_dir: str | Path) -> tuple[str | None, list[dict]]: + data_path = Path(data_dir) + if not data_path.exists(): + return None, [] + xml_files = sorted(data_path.glob("flex_*.xml")) + if not xml_files: + return None, [] + latest = xml_files[-1] + return str(latest), parse_flex_lots(latest) diff --git a/backend/flex_query.py b/backend/flex_query.py new file mode 100644 index 0000000..7dbafb1 --- /dev/null +++ b/backend/flex_query.py @@ -0,0 +1,141 @@ +"""Utilities for Interactive Brokers Flex Web Service integration. + +This module provides the first layer of infrastructure for pulling +historical trade data from a saved Flex Query. It intentionally keeps the +surface area small so the app can validate configuration, trigger a sync, +and persist the raw XML responses locally before the data is wired into the +lot engine. +""" +from __future__ import annotations + +import os +import time +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +FLEX_BASE_URL = "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService" +FLEX_SEND_REQUEST_URL = f"{FLEX_BASE_URL}/SendRequest" +FLEX_GET_STATEMENT_URL = f"{FLEX_BASE_URL}/GetStatement" + + +@dataclass +class FlexConfig: + token: str + query_id: str + data_dir: Path + poll_interval_seconds: float = 2.0 + max_polls: int = 30 + + +class FlexQueryError(RuntimeError): + pass + + +def load_flex_config() -> FlexConfig | None: + token = os.environ.get("IB_FLEX_TOKEN", "").strip() + query_id = os.environ.get("IB_FLEX_QUERY_ID", "").strip() + if not token or not query_id: + return None + data_dir = Path(os.environ.get("IB_FLEX_DATA_DIR", Path(__file__).resolve().parent.parent / "data" / "flex")) + return FlexConfig(token=token, query_id=query_id, data_dir=data_dir) + + +def get_flex_status() -> dict: + config = load_flex_config() + if config is None: + return { + "configured": False, + "missing": [ + name + for name in ("IB_FLEX_TOKEN", "IB_FLEX_QUERY_ID") + if not os.environ.get(name, "").strip() + ], + } + latest_file = None + latest_mtime = None + if config.data_dir.exists(): + xml_files = sorted(config.data_dir.glob("flex_*.xml")) + if xml_files: + latest = xml_files[-1] + latest_file = str(latest) + latest_mtime = latest.stat().st_mtime + return { + "configured": True, + "query_id": config.query_id, + "data_dir": str(config.data_dir), + "latest_file": latest_file, + "latest_timestamp": latest_mtime, + } + + +def _http_get(url: str, params: dict[str, str]) -> bytes: + full_url = f"{url}?{urllib.parse.urlencode(params)}" + request = urllib.request.Request( + full_url, + headers={"User-Agent": "IB-Dashboard/1.0"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def _parse_send_request(xml_bytes: bytes) -> tuple[str, str]: + root = ET.fromstring(xml_bytes) + status = (root.findtext("Status") or "").strip() + if status.lower() != "success": + raise FlexQueryError(root.findtext("ErrorMessage") or "Flex send-request failed") + reference_code = (root.findtext("ReferenceCode") or "").strip() + if not reference_code: + raise FlexQueryError("Flex send-request returned no ReferenceCode") + return status, reference_code + + +def _parse_statement_status(xml_bytes: bytes) -> tuple[bool, str]: + root = ET.fromstring(xml_bytes) + if root.tag == "FlexStatementResponse": + error_message = (root.findtext("ErrorMessage") or "").strip() + if error_message: + raise FlexQueryError(error_message) + return False, "" + return True, xml_bytes.decode("utf-8", errors="replace") + + +def fetch_flex_statement() -> dict: + config = load_flex_config() + if config is None: + raise FlexQueryError("Flex Query is not configured") + + config.data_dir.mkdir(parents=True, exist_ok=True) + + _, reference_code = _parse_send_request(_http_get( + FLEX_SEND_REQUEST_URL, + {"t": config.token, "q": config.query_id, "v": "3"}, + )) + + statement_xml = None + for _ in range(config.max_polls): + xml_bytes = _http_get( + FLEX_GET_STATEMENT_URL, + {"t": config.token, "q": reference_code, "v": "3"}, + ) + done, payload = _parse_statement_status(xml_bytes) + if done: + statement_xml = payload + break + time.sleep(config.poll_interval_seconds) + + if statement_xml is None: + raise FlexQueryError("Timed out waiting for Flex statement generation") + + timestamp = time.strftime("%Y%m%d_%H%M%S") + destination = config.data_dir / f"flex_{timestamp}.xml" + destination.write_text(statement_xml, encoding="utf-8") + + return { + "saved_to": str(destination), + "reference_code": reference_code, + "bytes": len(statement_xml.encode("utf-8")), + } diff --git a/backend/ib_client.py b/backend/ib_client.py index 410a11b..6a12b92 100644 --- a/backend/ib_client.py +++ b/backend/ib_client.py @@ -8,17 +8,17 @@ 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 +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): +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 @@ -40,18 +40,153 @@ class IBClient(EWrapper, EClient): self._connected_event = threading.Event() # subscriptions requested before connection - self._pending_subscriptions = set() + self._pending_subscriptions: Dict[str, Dict] = {} # in-memory stores self.quotes: Dict[str, Dict] = {} self.positions: List[Dict] = [] + # store executions (append as execDetails callbacks arrive) + self.executions: List[Dict] = [] + self._execution_ids = set() + self.historical_closes: Dict[str, float] = {} + + # tracking for subscriptions: reqId -> symbol + self._req_map: Dict[int, str] = {} + self._historical_req_map: Dict[int, str] = {} + self._requested_symbols = set() + self._requested_accounts = set() + self._requested_historical = set() - # tracking for subscriptions: reqId -> symbol - self._req_map: Dict[int, str] = {} - self._requested_symbols = set() - - # basic logging - logging.basicConfig(level=logging.INFO) + # basic logging + logging.basicConfig(level=logging.INFO) + + def _normalize_expiry(self, expiry): + expiry = str(expiry or "").strip() + if len(expiry) == 8 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}-{expiry[6:8]}" + if len(expiry) == 6 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}" + return expiry + + def _contract_key(self, contract) -> str: + if not contract: + return "" + con_id = getattr(contract, "conId", None) + if con_id: + return f"CONID:{con_id}" + sec_type = (getattr(contract, "secType", None) or "").upper() + symbol = getattr(contract, "symbol", None) or "" + if sec_type == "OPT": + expiry = self._normalize_expiry(getattr(contract, "lastTradeDateOrContractMonth", None)) + strike = getattr(contract, "strike", None) + right = getattr(contract, "right", None) or "" + multiplier = getattr(contract, "multiplier", None) or "" + return f"OPT:{symbol}:{expiry}:{strike}:{right}:{multiplier}" + return f"{sec_type or 'STK'}:{symbol}" + + def _contract_to_record(self, contract) -> Dict: + if not contract: + return {} + sec_type = (getattr(contract, "secType", None) or "STK").upper() + symbol = getattr(contract, "symbol", None) or "" + if sec_type == "OPT": + expiry = self._normalize_expiry(getattr(contract, "lastTradeDateOrContractMonth", None)) + strike = getattr(contract, "strike", None) + right = (getattr(contract, "right", None) or "").upper() + multiplier = getattr(contract, "multiplier", None) + else: + expiry = None + strike = None + right = None + multiplier = None + local_symbol = getattr(contract, "localSymbol", None) + rec = { + "instrument_key": self._contract_key(contract), + "con_id": getattr(contract, "conId", None) or None, + "symbol": symbol, + "security_type": sec_type, + "underlying_symbol": symbol, + "expiry": expiry or None, + "strike": float(strike) if strike not in (None, "") else None, + "option_type": right or None, + "multiplier": str(multiplier) if multiplier not in (None, "") else None, + "local_symbol": local_symbol or None, + "exchange": getattr(contract, "exchange", None) or None, + "primary_exchange": getattr(contract, "primaryExchange", None) or None, + "currency": getattr(contract, "currency", None) or None, + "trading_class": getattr(contract, "tradingClass", None) or None, + } + rec["display_symbol"] = self._format_display_symbol(rec) + return rec + + def _format_display_symbol(self, rec: Dict) -> str: + sec_type = (rec.get("security_type") or "STK").upper() + symbol = rec.get("underlying_symbol") or rec.get("symbol") or "" + if sec_type == "OPT": + expiry = rec.get("expiry") or "?" + strike = rec.get("strike") + if isinstance(strike, float) and strike.is_integer(): + strike_str = str(int(strike)) + else: + strike_str = str(strike) if strike is not None else "?" + option_type = rec.get("option_type") or "?" + return f"{symbol} {expiry} {strike_str} {option_type}" + return symbol + + def _price_multiplier(self, rec: Dict) -> float: + sec_type = (rec.get("security_type") or "STK").upper() + if sec_type != "OPT": + return 1.0 + multiplier = rec.get("multiplier") + try: + return float(multiplier) if multiplier not in (None, "", 0, "0") else 100.0 + except Exception: + return 100.0 + + def _make_contract_from_record(self, rec: Dict) -> Contract: + sec_type = (rec.get("security_type") or "STK").upper() + con_id = rec.get("con_id") + if con_id: + c = Contract() + c.conId = int(con_id) + c.secType = sec_type + c.exchange = rec.get("exchange") or "SMART" + currency = rec.get("currency") + if currency: + c.currency = currency + return c + if sec_type == "OPT": + c = Contract() + c.symbol = rec.get("underlying_symbol") or rec.get("symbol") + c.secType = "OPT" + c.exchange = rec.get("exchange") or "SMART" + c.currency = rec.get("currency") or "USD" + expiry = rec.get("expiry") or "" + c.lastTradeDateOrContractMonth = expiry.replace("-", "") + strike = rec.get("strike") + if strike is not None: + c.strike = float(strike) + option_type = rec.get("option_type") + if option_type: + c.right = option_type + multiplier = rec.get("multiplier") + if multiplier: + c.multiplier = str(multiplier) + local_symbol = rec.get("local_symbol") + if local_symbol: + c.localSymbol = local_symbol + primary_exchange = rec.get("primary_exchange") + if primary_exchange: + c.primaryExchange = primary_exchange + trading_class = rec.get("trading_class") + if trading_class: + c.tradingClass = trading_class + return c + return self._make_stock_contract( + rec.get("symbol") or rec.get("underlying_symbol"), + exchange=rec.get("exchange") or "SMART", + currency=rec.get("currency") or "USD", + ) def connect_and_start(self) -> None: if not self.isConnected(): @@ -80,52 +215,64 @@ class IBClient(EWrapper, EClient): 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) + if self._pending_subscriptions: + pending = list(self._pending_subscriptions.values()) + 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 tickPrice(self, reqId, tickType, price, attrib): + # store last or closing price when available + if price is None or price == -1: + return + instrument_key = self._req_map.get(reqId) + if not instrument_key: + return + data = self.quotes.setdefault(instrument_key, {}) + if tickType in (4, 68): + data["last"] = price + elif tickType in (9, 75): + data["close"] = price + elif tickType in (1, 66): + data["bid"] = price + elif tickType in (2, 67): + data["ask"] = price + elif tickType == 37: + data["mark"] = price + + def tickSize(self, reqId, tickType, size): + instrument_key = self._req_map.get(reqId) + if not instrument_key: + return + data = self.quotes.setdefault(instrument_key, {}) + 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) + if not contract or not contract.symbol: + return + contract_rec = self._contract_to_record(contract) + pos = { + **contract_rec, + "position": position, + "marketPrice": marketPrice, + "marketValue": marketValue, + "averageCost": averageCost, + "account": accountName, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("instrument_key") == pos["instrument_key"] and p.get("account") == accountName: + self.positions[i] = pos + break + else: + self.positions.append(pos) def positionEnd(self): # called when a batch of position updates is finished @@ -136,25 +283,58 @@ class IBClient(EWrapper, EClient): 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) + try: + if not contract or not getattr(contract, 'symbol', None): + return + contract_rec = self._contract_to_record(contract) + pos = { + **contract_rec, + "position": position, + "averageCost": avgCost, + "account": account, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("instrument_key") == pos["instrument_key"] and p.get("account") == account: + self.positions[i] = pos + break + else: + self.positions.append(pos) except Exception: logging.exception("Error handling position callback") + def execDetails(self, reqId, contract, execution): + """Capture execution details for building lots. + + `execution` is an `Execution` object from ibapi which contains + .shares, .price, .side, .execId, .time (may be empty) + """ + try: + symbol = getattr(contract, "symbol", None) + if not symbol: + return + contract_rec = self._contract_to_record(contract) + entry = { + "execId": getattr(execution, "execId", None), + **contract_rec, + "shares": float(getattr(execution, "shares", 0)), + "price": float(getattr(execution, "price", 0.0)), + "side": getattr(execution, "side", ""), + "time": getattr(execution, "time", None), + "permId": getattr(execution, "permId", None), + } + exec_id = entry.get("execId") + if exec_id and exec_id in self._execution_ids: + return + if exec_id: + self._execution_ids.add(exec_id) + self.executions.append(entry) + except Exception: + logging.exception("Failed to record execution detail") + + def execDetailsEnd(self, reqId): + logging.info("Finished receiving executions (req=%s). %d execs total", reqId, len(self.executions)) + # ---- helpers ---- def _new_req_id(self) -> int: rid = self._next_req_id @@ -169,38 +349,45 @@ class IBClient(EWrapper, EClient): 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). + def subscribe_market_data(self, contract_or_record) -> None: + """Subscribe to basic market data for a contract or simple symbol string. + + Repeated calls for the same symbol are ignored. + """ + if isinstance(contract_or_record, str): + contract = self._make_stock_contract(contract_or_record) + queued_record = {"symbol": contract_or_record, "security_type": "STK"} + instrument_key = self._contract_key(contract) + else: + contract = self._make_contract_from_record(contract_or_record) + queued_record = dict(contract_or_record) + instrument_key = self._contract_key(contract) + if instrument_key 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", instrument_key) + self._pending_subscriptions[instrument_key] = queued_record + # 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] = instrument_key + # prefer delayed data when live market data is not available + try: + self.reqMarketDataType(3) + self.reqMktData(req_id, contract, "", False, False, []) + self._requested_symbols.add(instrument_key) + logging.info("Subscribed to market data for %s (req=%s)", instrument_key, req_id) + except Exception as e: + logging.exception("Failed to subscribe to %s: %s", instrument_key, e) - 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).""" + 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 @@ -221,13 +408,316 @@ class IBClient(EWrapper, EClient): threading.Thread(target=_delayed, daemon=True).start() return - try: - self.reqPositions() - except Exception: - logging.exception("Failed to request positions") + try: + self.reqPositions() + except Exception: + logging.exception("Failed to request positions") + + def request_account_updates(self, account: str) -> None: + """Subscribe to portfolio/account updates for a specific account.""" + if not account or account in self._requested_accounts: + return + if not self._connected_event.is_set(): + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for account updates") + return + try: + self.reqAccountUpdates(True, account) + self._requested_accounts.add(account) + except Exception: + logging.exception("Failed to request account updates for %s", account) - def get_quotes(self) -> Dict[str, Dict]: - return self.quotes + def request_executions(self, start_time: str | None = None) -> None: + """Request executions from IB (will trigger execDetails callbacks). + + Uses an empty ExecutionFilter to fetch recent executions. The results + arrive via `execDetails` callbacks. + """ + try: + # import here to avoid module-level dependency issues + from ibapi.execution import ExecutionFilter + filt = ExecutionFilter() + if start_time: + filt.time = start_time + rid = self._new_req_id() + self.reqExecutions(rid, filt) + except Exception: + logging.exception("Failed to request executions") + + def historicalData(self, reqId, bar): + try: + instrument_key = self._historical_req_map.get(reqId) + if not instrument_key: + return + close = getattr(bar, "close", None) + if close not in (None, -1): + self.historical_closes[instrument_key] = float(close) + except Exception: + logging.exception("Failed to process historical data") + + def historicalDataEnd(self, reqId, start, end): + self._historical_req_map.pop(reqId, None) + + def request_historical_close(self, contract_or_record) -> None: + if isinstance(contract_or_record, str): + contract = self._make_stock_contract(contract_or_record) + else: + contract = self._make_contract_from_record(contract_or_record) + instrument_key = self._contract_key(contract) + if not instrument_key or instrument_key in self._requested_historical: + return + if not self._connected_event.is_set(): + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for historical close") + return + try: + req_id = self._new_req_id() + self._historical_req_map[req_id] = instrument_key + self._requested_historical.add(instrument_key) + self.reqHistoricalData(req_id, contract, "", "2 D", "1 day", "TRADES", 0, 1, False, []) + except Exception: + logging.exception("Failed to request historical close for %s", instrument_key) + + def get_quotes(self): + return dict(self.quotes) + + def get_positions(self): + return list(self.positions) + + def compute_open_lots(self, include_position_fallback: bool = False): + """Compute open lots using FIFO matching from recorded executions. + + Returns a list of lots with: symbol, qty, cost_basis, avg_price, market_price, + market_value, gain_dollars, gain_percent, cagr_percent, buy_time + """ + # group executions by symbol + from collections import defaultdict + from datetime import datetime - def get_positions(self) -> List[Dict]: - return self.positions + by_symbol = defaultdict(list) + for e in self.executions: + by_symbol[e["instrument_key"]].append(e) + + # include symbols from positions as well so we can fallback + position_symbols = set() + position_by_symbol = {} + for p in self.positions: + instrument_key = p.get("instrument_key") + if not instrument_key: + continue + position_symbols.add(instrument_key) + if instrument_key not in position_by_symbol: + position_by_symbol[instrument_key] = p + + lots = [] + all_symbols = set(by_symbol.keys()) | position_symbols + for instrument_key in all_symbols: + execs = by_symbol.get(instrument_key, []) + position = position_by_symbol.get(instrument_key) or {} + meta = execs[0] if execs else position + # sort by time if available, else keep order + def _time_key(x): + t = x.get("time") + if not t: + return 0 + # try parsing common formats + for fmt in ("%Y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y%m%d-%H:%M:%S"): + try: + return datetime.strptime(t, fmt).timestamp() + except Exception: + continue + try: + return float(t) + except Exception: + return 0 + + execs_sorted = sorted(execs, key=_time_key) if execs else [] + # build open lots using FIFO for both long and short positions + open_longs = [] + open_shorts = [] + for e in execs_sorted: + side = (e.get("side") or "").upper() + shares = abs(float(e.get("shares", 0) or 0)) + price = float(e.get("price", 0.0) or 0.0) + time_str = e.get("time") + if side == "BOT" or side == "BUY": + qty_to_buy = shares + while qty_to_buy > 0 and open_shorts: + s = open_shorts[0] + matched = min(qty_to_buy, abs(s["qty"])) + s["qty"] += matched + qty_to_buy -= matched + if abs(s["qty"]) < 1e-9: + open_shorts.pop(0) + if qty_to_buy > 0: + open_longs.append({"qty": qty_to_buy, "price": price, "time": time_str}) + else: + qty_to_sell = shares + while qty_to_sell > 0 and open_longs: + b = open_longs[0] + matched = min(qty_to_sell, b["qty"]) + b["qty"] -= matched + qty_to_sell -= matched + if b["qty"] < 1e-9: + open_longs.pop(0) + if qty_to_sell > 0: + open_shorts.append({"qty": -qty_to_sell, "price": price, "time": time_str}) + + # remaining buys are open lots + # ensure market data available for symbol + if instrument_key not in self.quotes: + try: + self.subscribe_market_data(meta) + except Exception: + pass + market_price = None + q = self.quotes.get(instrument_key) + if q: + market_price = q.get("last") + if market_price is None: + market_price = q.get("close") + if market_price is None: + market_price = q.get("mark") + if market_price is None: + market_price = self.historical_closes.get(instrument_key) + if market_price is None: + market_price = position.get("marketPrice") + + try: + position_qty = float(position.get("position") or 0.0) + except Exception: + position_qty = 0.0 + try: + position_market_value = float(position.get("marketValue")) if position.get("marketValue") is not None else None + except Exception: + position_market_value = None + + known_market_value_remaining = position_market_value + first_exec_time = execs_sorted[0].get("time") if execs_sorted else None + price_multiplier = self._price_multiplier(meta or position) + + if execs_sorted: + open_lots = open_longs + open_shorts + for b in open_lots: + qty = float(b["qty"]) + avg_price = float(b["price"]) * price_multiplier + cost_basis = qty * avg_price + effective_market_price = None if market_price is None else market_price * price_multiplier + market_value = None if effective_market_price is None else qty * effective_market_price + if market_value is None and known_market_value_remaining is not None and position_qty: + market_value = known_market_value_remaining * (qty / position_qty) + if known_market_value_remaining is not None and market_value is not None: + known_market_value_remaining -= market_value + gain_dollars = None if market_value is None else market_value - cost_basis + gain_percent = None + cagr_percent = None + base_cost = abs(cost_basis) + if base_cost and market_value is not None: + try: + gain_percent = (gain_dollars / base_cost) * 100 + except Exception: + gain_percent = None + # estimate CAGR using buy time if available + buy_time = b.get("time") + if buy_time and market_value is not None and base_cost > 0: + try: + # parse time similar to above + for fmt in ("%Y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y%m%d-%H:%M:%S"): + try: + dt = datetime.strptime(buy_time, fmt) + break + except Exception: + dt = None + if dt is None: + # try numeric timestamp + try: + dt = datetime.fromtimestamp(float(buy_time)) + except Exception: + dt = None + if dt is not None: + years = max((datetime.utcnow() - dt).days / 365.0, 1e-6) + try: + ending_value = base_cost + gain_dollars + if ending_value > 0: + cagr = (ending_value / base_cost) ** (1.0 / years) - 1.0 + cagr_percent = cagr * 100.0 + else: + cagr_percent = None + except Exception: + cagr_percent = None + except Exception: + cagr_percent = None + + lots.append({ + "symbol": meta.get("symbol"), + "display_symbol": meta.get("display_symbol") or self._format_display_symbol(meta), + "security_type": meta.get("security_type"), + "underlying_symbol": meta.get("underlying_symbol"), + "expiry": meta.get("expiry"), + "strike": meta.get("strike"), + "option_type": meta.get("option_type"), + "instrument_key": instrument_key, + "qty": qty, + "avg_price": avg_price, + "cost_basis": cost_basis, + "market_price": effective_market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": cagr_percent, + "buy_time": buy_time, + }) + elif include_position_fallback: + # fallback: use positions data to create a single lot per symbol + for p in self.positions: + if p.get("instrument_key") != instrument_key: + continue + qty = p.get("position") or 0 + avg_price = p.get("averageCost") or p.get("average_cost") or 0.0 + try: + qtyf = float(qty) + except Exception: + qtyf = 0.0 + try: + avg_pricef = float(avg_price) + except Exception: + avg_pricef = 0.0 + cost_basis = qtyf * avg_pricef + effective_market_price = None if market_price is None else market_price * self._price_multiplier(p) + if effective_market_price is not None: + market_value = qtyf * effective_market_price + else: + market_value = position_market_value + gain_dollars = None if market_value is None else market_value - cost_basis + gain_percent = None + base_cost = abs(cost_basis) + if base_cost and market_value is not None: + try: + gain_percent = (gain_dollars / base_cost) * 100 + except Exception: + gain_percent = None + lots.append({ + "symbol": p.get("symbol"), + "display_symbol": p.get("display_symbol") or self._format_display_symbol(p), + "security_type": p.get("security_type"), + "underlying_symbol": p.get("underlying_symbol"), + "expiry": p.get("expiry"), + "strike": p.get("strike"), + "option_type": p.get("option_type"), + "instrument_key": instrument_key, + "qty": qtyf, + "avg_price": avg_pricef, + "cost_basis": cost_basis, + "market_price": effective_market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": None, + "buy_time": first_exec_time, + }) + + return lots diff --git a/frontend/app.js b/frontend/app.js index fef9c41..452c4fb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,32 +1,255 @@ -// 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 = `
| Symbol | Position | Average Cost | Account |
|---|