Initial Checkin

This commit is contained in:
2026-04-01 22:51:21 -05:00
commit aa872aba67
14 changed files with 2250 additions and 0 deletions

3
.env.example Normal file
View File

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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
data/

155
README.md Normal file
View File

@@ -0,0 +1,155 @@
# 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

260
backend/app.py Normal file
View File

@@ -0,0 +1,260 @@
"""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 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
# 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/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)
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)

183
backend/flex_lots.py Normal file
View File

@@ -0,0 +1,183 @@
"""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, market_value, 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)
gain_percent = (float(gain_dollars) / abs(float(cost_basis))) * 100.0
if years < 1.0:
return gain_percent
start_value = abs(float(cost_basis))
if float(cost_basis) < 0:
end_liability = abs(float(market_value)) if market_value is not None else abs(float(cost_basis) + float(gain_dollars))
if start_value <= 0 or end_liability <= 0:
return None
return (((start_value / end_liability) ** (1.0 / years)) - 1.0) * 100.0
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 _signed_quantity(raw_position, side: str, cost_basis, market_value):
quantity = abs(raw_position or 0.0)
if quantity == 0:
return 0.0
normalized_side = (side or "").strip().lower()
if normalized_side == "short" or (raw_position or 0.0) < 0:
return -quantity
if cost_basis is not None and float(cost_basis) < 0:
return -quantity
if market_value is not None and float(market_value) < 0:
return -quantity
return quantity
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
fx_rate_to_base = _as_float(attrs.get("fxRateToBase"), 1.0) or 1.0
raw_position = _as_float(attrs.get("position"), 0.0) or 0.0
side = (attrs.get("side") or "").strip().lower()
mark_price = _as_float(attrs.get("markPrice"))
market_price = mark_price
market_value = _as_float(attrs.get("positionValue"))
cost_basis_base = _as_float(attrs.get("costBasisMoney"))
capital_gain_base = _as_float(attrs.get("unrealizedCapitalGainsPnl"))
gain_dollars = _as_float(attrs.get("fifoPnlUnrealized"))
qty = _signed_quantity(raw_position, side, cost_basis_base, market_value)
# Flex OpenPosition rows mix currencies for non-base holdings:
# mark/positionValue are in instrument currency, while costBasis/openPrice
# are in account base. Reconstruct local-cost values so they line up with TWS.
cost_basis = cost_basis_base
if market_value is not None and capital_gain_base is not None and fx_rate_to_base:
cost_basis = market_value - (capital_gain_base / fx_rate_to_base)
avg_price = None
if qty:
divisor = abs(qty) * multiplier if security_type == "OPT" else abs(qty)
avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None
if avg_price is None:
avg_price = _as_float(attrs.get("costBasisPrice"))
if avg_price is None:
avg_price = _as_float(attrs.get("openPrice"))
buy_time = _normalize_buy_time(attrs.get("openDateTime"))
# Prefer local-currency capital gain to keep gain/cost/value in one currency.
if capital_gain_base is not None and fx_rate_to_base:
gain_dollars = capital_gain_base / fx_rate_to_base
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, market_value, 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),
"currency": attrs.get("currency") or None,
"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)

141
backend/flex_query.py Normal file
View File

@@ -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")),
}

723
backend/ib_client.py Normal file
View File

@@ -0,0 +1,723 @@
"""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: 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()
# 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():
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.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 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
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
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
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
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, 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)
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 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 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
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

573
frontend/app.js Normal file
View File

@@ -0,0 +1,573 @@
const qs = (s) => document.querySelector(s);
const ptbody = qs('#positions-table tbody');
const pthead = qs('#positions-table thead');
const aggregateCheckbox = qs('#aggregate-same-day');
const refreshButton = qs('#refresh-lots');
let latestPositions = [];
let sortState = [];
let filterState = {};
let aggregateSameDay = true;
let columnOrder = [];
let dragColumnKey = null;
let isRefreshing = false;
const STORAGE_KEY = 'ib-dashboard-open-lots-table-state';
const numericColumns = new Set([
'qty',
'avg_price',
'cost_basis',
'market_price',
'market_value',
'gain_dollars',
'gain_percent',
'cagr_percent',
'days_since_buy',
]);
const dateColumns = new Set(['buy_time']);
const columnDefs = [
{ key: 'security_type', label: 'Type', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'STK', label: 'STK' }, { value: 'OPT', label: 'OPT' }] },
{ key: 'display_symbol', label: 'Security', filter: 'text', placeholder: 'AM*' },
{ key: 'currency', label: 'Currency', filter: 'select-dynamic', options: [{ value: '', label: 'All' }] },
{ key: 'underlying_symbol', label: 'Underlying', filter: 'text', placeholder: 'A*' },
{ key: 'qty', label: 'Qty', filter: 'text', placeholder: '>100', numeric: true },
{ key: 'avg_price', label: 'Avg Price', filter: 'text', placeholder: '<20', numeric: true },
{ key: 'cost_basis', label: 'Cost Basis', filter: 'text', placeholder: '>1000', numeric: true },
{ key: 'market_price', label: 'Market Price', filter: 'text', placeholder: '<10', numeric: true },
{ key: 'market_value', label: 'Market Value', filter: 'text', placeholder: '>5000', numeric: true },
{ key: 'gain_dollars', label: 'Gain $', filter: 'text', placeholder: '>0', numeric: true },
{ key: 'gain_percent', label: 'Gain %', filter: 'text', placeholder: '>10', numeric: true },
{ key: 'cagr_percent', label: 'CAGR %', filter: 'text', placeholder: '>5', numeric: true },
{ key: 'days_since_buy', label: 'Days Since Buy', filter: 'text', placeholder: '>365', numeric: true },
{ key: 'ltcg', label: 'LTCG', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'X', label: 'Long-term' }, { value: '!X', label: 'Short-term' }] },
{ key: 'buy_time', label: 'Buy Time', filter: 'text', placeholder: '>2025-01-01', date: true },
];
const columnAccessors = {
security_type: (p) => p.security_type ?? '',
display_symbol: (p) => p.display_symbol ?? p.symbol ?? '',
currency: (p) => p.currency ?? '',
underlying_symbol: (p) => p.underlying_symbol ?? '',
qty: (p) => p.qty,
avg_price: (p) => p.avg_price,
cost_basis: (p) => p.cost_basis,
market_price: (p) => p.market_price,
market_value: (p) => p.market_value,
gain_dollars: (p) => p.gain_dollars,
gain_percent: (p) => p.gain_percent,
cagr_percent: (p) => p.cagr_percent,
days_since_buy: (p) => p.days_since_buy,
ltcg: (p) => p.ltcg ?? '',
buy_time: (p) => p.buy_time ?? '',
};
const defaultColumnOrder = columnDefs.map((column) => column.key);
function getActiveColumns() {
const ordered = columnOrder.length ? columnOrder : defaultColumnOrder;
return ordered
.map((key) => columnDefs.find((column) => column.key === key))
.filter(Boolean);
}
function asNumber(value) {
if (value == null || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatNumber(value, digits = 2) {
const num = asNumber(value);
if (num == null) return '';
return num.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function formatPercent(value) {
const num = asNumber(value);
if (num == null) return '';
return `${formatNumber(num, 2)}%`;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function saveTableState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
sortState,
filterState,
aggregateSameDay,
columnOrder,
}));
} catch (error) {
console.warn('Failed to save table state', error);
}
}
function loadTableState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.sortState)) {
sortState = parsed.sortState.filter((rule) => rule && typeof rule.column === 'string' && typeof rule.direction === 'string');
}
if (parsed.filterState && typeof parsed.filterState === 'object') {
filterState = Object.fromEntries(
Object.entries(parsed.filterState).filter(([, value]) => typeof value === 'string' && value.trim() !== '')
);
}
if (typeof parsed.aggregateSameDay === 'boolean') {
aggregateSameDay = parsed.aggregateSameDay;
}
if (Array.isArray(parsed.columnOrder)) {
const valid = parsed.columnOrder.filter((key) => defaultColumnOrder.includes(key));
if (valid.length) {
const missing = defaultColumnOrder.filter((key) => !valid.includes(key));
columnOrder = [...valid, ...missing];
}
}
} catch (error) {
console.warn('Failed to load table state', error);
}
}
async function fetchJson(path) {
const res = await fetch(path);
if (!res.ok) throw new Error('Network response not ok');
return res.json();
}
function compareValues(a, b) {
const aMissing = a == null || a === '';
const bMissing = b == null || b === '';
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function escapeRegex(text) {
return text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
function wildcardToRegex(pattern) {
const escaped = escapeRegex(pattern).replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${escaped}$`, 'i');
}
function parseComparator(expression) {
const match = String(expression).trim().match(/^(<=|>=|<|>|=)\s*(.+)$/);
if (!match) return null;
return { operator: match[1], value: match[2].trim() };
}
function compareByOperator(left, operator, right) {
if (left == null || right == null) return false;
switch (operator) {
case '<': return left < right;
case '<=': return left <= right;
case '>': return left > right;
case '>=': return left >= right;
case '=': return left === right;
default: return false;
}
}
function matchesFilter(column, rawValue, expression) {
const filter = String(expression ?? '').trim();
if (!filter) return true;
const value = rawValue ?? '';
if (column === 'ltcg' && filter === '!X') {
return String(value).trim().toUpperCase() !== 'X';
}
if (numericColumns.has(column)) {
const numericValue = asNumber(value);
const comparator = parseComparator(filter);
if (comparator) return compareByOperator(numericValue, comparator.operator, asNumber(comparator.value));
return numericValue === asNumber(filter);
}
if (dateColumns.has(column)) {
const normalizedValue = String(value);
const comparator = parseComparator(filter);
if (comparator) return compareByOperator(normalizedValue, comparator.operator, comparator.value);
return wildcardToRegex(filter).test(normalizedValue);
}
return wildcardToRegex(filter).test(String(value));
}
function filterPositions(list) {
return list.filter((row) => Object.entries(filterState).every(([column, expression]) => {
const accessor = columnAccessors[column];
if (!accessor) return true;
return matchesFilter(column, accessor(row), expression);
}));
}
function toDateOnly(value) {
return String(value ?? '').slice(0, 10);
}
function aggregateLots(list) {
if (!aggregateSameDay) return [...list];
const groups = new Map();
for (const lot of list) {
const buyDate = toDateOnly(lot.buy_time);
const key = `${lot.instrument_key ?? ''}|${buyDate}`;
const existing = groups.get(key);
if (!existing) {
groups.set(key, {
...lot,
buy_time: buyDate || lot.buy_time,
});
continue;
}
existing.qty = (existing.qty ?? 0) + (lot.qty ?? 0);
existing.cost_basis = (existing.cost_basis ?? 0) + (lot.cost_basis ?? 0);
existing.market_value = (existing.market_value ?? 0) + (lot.market_value ?? 0);
existing.gain_dollars = (existing.gain_dollars ?? 0) + (lot.gain_dollars ?? 0);
existing.days_since_buy = Math.min(
asNumber(existing.days_since_buy) ?? Number.MAX_SAFE_INTEGER,
asNumber(lot.days_since_buy) ?? Number.MAX_SAFE_INTEGER,
);
existing.ltcg = existing.ltcg === 'X' && lot.ltcg === 'X' ? 'X' : '';
const qty = asNumber(existing.qty);
const costBasis = asNumber(existing.cost_basis);
const marketValue = asNumber(existing.market_value);
existing.avg_price = qty ? Math.abs(costBasis / qty) : null;
existing.market_price = qty ? Math.abs(marketValue / qty) : null;
existing.gain_percent = costBasis ? (existing.gain_dollars / Math.abs(costBasis)) * 100 : null;
const cagrBase = Math.abs(costBasis ?? 0);
if (cagrBase > 0 && existing.gain_dollars != null && existing.days_since_buy != null) {
const years = Math.max(existing.days_since_buy / 365.25, 1e-9);
if (years < 1) {
existing.cagr_percent = existing.gain_percent;
} else if ((costBasis ?? 0) < 0) {
const endLiability = Math.abs(existing.market_value ?? 0);
existing.cagr_percent = endLiability > 0
? (((cagrBase / endLiability) ** (1 / years)) - 1) * 100
: null;
} else {
const endingValue = cagrBase + existing.gain_dollars;
existing.cagr_percent = endingValue > 0
? (((endingValue / cagrBase) ** (1 / years)) - 1) * 100
: null;
}
} else {
existing.cagr_percent = null;
}
}
return Array.from(groups.values());
}
function sortPositions(list) {
if (!sortState.length) return [...list];
return [...list].sort((left, right) => {
for (const rule of sortState) {
const accessor = columnAccessors[rule.column];
if (!accessor) continue;
const leftValue = accessor(left);
const rightValue = accessor(right);
const result = numericColumns.has(rule.column)
? compareValues(asNumber(leftValue), asNumber(rightValue))
: compareValues(leftValue, rightValue);
if (result !== 0) return rule.direction === 'asc' ? result : -result;
}
return 0;
});
}
function getCurrencyOptions() {
const currencies = [...new Set(latestPositions.map((position) => position.currency).filter(Boolean))].sort((a, b) => a.localeCompare(b));
return [{ value: '', label: 'All' }, ...currencies.map((currency) => ({ value: currency, label: currency }))];
}
function captureFocusState() {
const active = document.activeElement;
if (!active || !active.classList || !active.classList.contains('filter-input')) {
return null;
}
return {
column: active.dataset.column,
selectionStart: typeof active.selectionStart === 'number' ? active.selectionStart : null,
selectionEnd: typeof active.selectionEnd === 'number' ? active.selectionEnd : null,
};
}
function restoreFocusState(state) {
if (!state?.column) return;
const input = document.querySelector(`.filter-input[data-column="${state.column}"]`);
if (!input) return;
input.focus();
if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof input.setSelectionRange === 'function') {
input.setSelectionRange(state.selectionStart, state.selectionEnd);
}
}
function createFilterControl(column) {
const value = filterState[column.key] ?? '';
if (column.filter === 'select' || column.filter === 'select-dynamic') {
const select = document.createElement('select');
select.className = 'filter-input filter-select';
select.dataset.column = column.key;
const options = column.filter === 'select-dynamic' ? getCurrencyOptions() : column.options;
for (const option of options) {
const element = document.createElement('option');
element.value = option.value;
element.textContent = option.label;
select.appendChild(element);
}
select.value = value;
if (value) {
select.classList.add('filter-active');
}
select.addEventListener('change', onFilterInput);
return select;
}
const input = document.createElement('input');
input.className = 'filter-input filter-text';
input.dataset.column = column.key;
input.placeholder = column.placeholder ?? '';
input.value = value;
if (value) {
input.classList.add('filter-active');
}
input.addEventListener('input', onFilterInput);
return input;
}
function renderHeader() {
pthead.innerHTML = '';
const sortRow = document.createElement('tr');
sortRow.className = 'sort-row';
const filterRow = document.createElement('tr');
filterRow.className = 'filter-row';
for (const column of getActiveColumns()) {
const sortTh = document.createElement('th');
sortTh.dataset.column = column.key;
sortTh.draggable = true;
if (column.numeric) sortTh.classList.add('num');
const label = document.createElement('span');
label.textContent = column.label;
sortTh.appendChild(label);
const ruleIndex = sortState.findIndex((rule) => rule.column === column.key);
if (ruleIndex !== -1) {
const rule = sortState[ruleIndex];
const meta = document.createElement('span');
meta.className = 'sort-meta';
meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`;
sortTh.appendChild(meta);
}
sortTh.addEventListener('click', (event) => {
if (event.target.closest('.drag-handle')) return;
cycleSort(column.key, event.shiftKey);
});
sortTh.addEventListener('dragstart', onHeaderDragStart);
sortTh.addEventListener('dragover', onHeaderDragOver);
sortTh.addEventListener('drop', onHeaderDrop);
sortTh.addEventListener('dragend', onHeaderDragEnd);
sortRow.appendChild(sortTh);
const filterTh = document.createElement('th');
if (column.numeric) filterTh.classList.add('num');
filterTh.appendChild(createFilterControl(column));
filterRow.appendChild(filterTh);
}
pthead.appendChild(sortRow);
pthead.appendChild(filterRow);
}
function renderCell(columnKey, row) {
switch (columnKey) {
case 'security_type':
return escapeHtml(row.security_type ?? '');
case 'display_symbol':
return escapeHtml(row.display_symbol ?? row.symbol ?? '');
case 'currency':
return escapeHtml(row.currency ?? '');
case 'underlying_symbol':
return escapeHtml(row.underlying_symbol ?? '');
case 'qty':
return formatNumber(row.qty, 2);
case 'avg_price':
return formatNumber(row.avg_price ?? row.averageCost ?? row.average_cost, 2);
case 'cost_basis':
return formatNumber(row.cost_basis, 2);
case 'market_price':
return formatNumber(row.market_price, 2);
case 'market_value':
return formatNumber(row.market_value, 2);
case 'gain_dollars':
return formatNumber(row.gain_dollars, 2);
case 'gain_percent':
return formatPercent(row.gain_percent);
case 'cagr_percent':
return formatPercent(row.cagr_percent);
case 'days_since_buy':
return formatNumber(row.days_since_buy, 0);
case 'ltcg':
return row.ltcg === 'X'
? '<span class="ltcg-icon good" title="Long-term capital gains eligible">&#10003;</span>'
: '<span class="ltcg-icon bad" title="Less than one year old">&#10007;</span>';
case 'buy_time':
return escapeHtml(row.buy_time ?? '');
default:
return '';
}
}
function renderPositions(list) {
const focusState = captureFocusState();
renderHeader();
ptbody.innerHTML = '';
const activeColumns = getActiveColumns();
for (const rowData of sortPositions(filterPositions(aggregateLots(list)))) {
const row = document.createElement('tr');
for (const column of activeColumns) {
const cell = document.createElement('td');
if (column.numeric) cell.className = 'num';
if (column.key === 'ltcg') cell.className = 'icon-cell';
cell.innerHTML = renderCell(column.key, rowData);
row.appendChild(cell);
}
ptbody.appendChild(row);
}
restoreFocusState(focusState);
}
function cycleSort(column, multi) {
const existingIndex = sortState.findIndex((rule) => rule.column === column);
if (!multi) {
if (existingIndex === -1) {
sortState = [{ column, direction: 'asc' }];
} else {
const current = sortState[existingIndex];
if (current.direction === 'asc') {
sortState = [{ column, direction: 'desc' }];
} else {
sortState = [];
}
}
renderPositions(latestPositions);
saveTableState();
return;
}
if (existingIndex === -1) {
sortState.push({ column, direction: 'asc' });
} else {
const current = sortState[existingIndex];
if (current.direction === 'asc') {
sortState[existingIndex] = { column, direction: 'desc' };
} else {
sortState.splice(existingIndex, 1);
}
}
renderPositions(latestPositions);
saveTableState();
}
function onFilterInput(event) {
const input = event.currentTarget;
const value = input.value.trim();
if (value) {
filterState[input.dataset.column] = value;
} else {
delete filterState[input.dataset.column];
}
saveTableState();
renderPositions(latestPositions);
}
function onHeaderDragStart(event) {
dragColumnKey = event.currentTarget.dataset.column;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', dragColumnKey);
event.currentTarget.classList.add('dragging');
}
function onHeaderDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
function onHeaderDrop(event) {
event.preventDefault();
const targetKey = event.currentTarget.dataset.column;
const sourceKey = dragColumnKey || event.dataTransfer.getData('text/plain');
if (!sourceKey || sourceKey === targetKey) return;
const currentOrder = getActiveColumns().map((column) => column.key);
const sourceIndex = currentOrder.indexOf(sourceKey);
const targetIndex = currentOrder.indexOf(targetKey);
if (sourceIndex === -1 || targetIndex === -1) return;
currentOrder.splice(targetIndex, 0, currentOrder.splice(sourceIndex, 1)[0]);
columnOrder = currentOrder;
saveTableState();
renderPositions(latestPositions);
}
function onHeaderDragEnd(event) {
dragColumnKey = null;
event.currentTarget.classList.remove('dragging');
}
async function pollPositions() {
if (isRefreshing) return;
isRefreshing = true;
if (refreshButton) refreshButton.disabled = true;
try {
const positions = await fetchJson('/api/lots');
latestPositions = Array.isArray(positions) ? positions : [];
renderPositions(latestPositions);
} catch (e) {
console.warn('Positions polling error', e);
} finally {
isRefreshing = false;
if (refreshButton) refreshButton.disabled = false;
}
}
loadTableState();
if (!columnOrder.length) {
columnOrder = [...defaultColumnOrder];
}
if (aggregateCheckbox) {
aggregateCheckbox.checked = aggregateSameDay;
aggregateCheckbox.addEventListener('change', () => {
aggregateSameDay = aggregateCheckbox.checked;
saveTableState();
renderPositions(latestPositions);
});
}
if (refreshButton) {
refreshButton.addEventListener('click', () => {
pollPositions();
});
}
pollPositions();

208
frontend/index.html Normal file
View File

@@ -0,0 +1,208 @@
<!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>
:root { --sort-row-height: 43px; --filter-row-height: 38px; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin: 20px; }
input, button { padding: 6px 8px; margin-right: 6px }
.table-wrap {
position: relative;
max-height: calc(100vh - 140px);
overflow: auto;
border: 1px solid #ddd;
margin-top: 12px;
background: #fff;
}
table {
border-collapse: separate;
border-spacing: 0;
width: max-content;
margin-top: 0;
background: #fff;
table-layout: auto;
}
th, td {
padding: 8px;
text-align: left;
border-right: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
th:first-child, td:first-child { border-left: 1px solid #ddd }
.section-head { display: flex; align-items: center; gap: 10px }
.help-wrap { position: relative; display: inline-flex; align-items: center }
.aggregate-toggle { display: inline-flex; align-items: center; gap: 8px; font-weight: 600 }
.aggregate-toggle input { margin: 0 }
.refresh-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #bfc7d1;
border-radius: 8px;
background: #f7f9fb;
color: #19324d;
font-size: 14px;
font-weight: 700;
cursor: pointer;
margin-right: 0;
}
.refresh-button:hover { background: #eef3f8 }
.refresh-button:disabled {
cursor: wait;
opacity: 0.7;
}
.refresh-icon {
font-size: 16px;
line-height: 1;
}
.help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: #1f9d3a;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1;
cursor: help;
}
.help-pane {
display: none;
position: absolute;
top: 50%;
left: calc(100% + 12px);
right: auto;
transform: translateY(-50%);
width: 340px;
padding: 12px 14px;
border: 1px solid #cfd8cf;
background: #fbfffb;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
z-index: 5;
font-size: 13px;
line-height: 1.45;
}
.help-wrap:hover .help-pane { display: block }
.help-pane p { margin: 0 0 8px 0 }
.help-pane p:last-child { margin-bottom: 0 }
th {
background: #f4f4f4;
position: sticky;
top: 0;
z-index: 4;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.dragging { opacity: 0.55 }
thead tr.sort-row th {
top: 0;
height: var(--sort-row-height);
min-height: var(--sort-row-height);
box-sizing: border-box;
box-shadow: inset 0 -1px 0 #d9d9d9;
}
th:hover { background: #e9e9e9 }
th .sort-meta { color: #666; font-size: 12px; margin-left: 6px }
thead { position: relative; z-index: 2 }
thead tr.filter-row th {
top: var(--sort-row-height);
background: #fafafa;
cursor: default;
z-index: 3;
padding: 6px;
height: var(--filter-row-height);
min-height: var(--filter-row-height);
box-sizing: border-box;
box-shadow: inset 0 -1px 0 #d9d9d9;
}
thead tr.filter-row th:hover { background: #fafafa }
.filter-input {
width: 100%;
min-width: 0;
margin: 0;
padding: 5px 6px;
box-sizing: border-box;
font-size: 12px;
border: 1px solid #cfd3d7;
background: #fff;
}
.filter-input.filter-text { width: 9ch }
.filter-input.filter-select { width: auto; min-width: 5.5ch; max-width: 100% }
.filter-input.filter-active {
background: #f9e1e8;
border-color: #e5b8c7;
}
tbody tr:nth-child(odd) { background: #ffffff }
tbody tr:nth-child(even) { background: #f3f3f3 }
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap }
td.icon-cell { text-align: center }
.ltcg-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
font-size: 14px;
font-weight: 700;
line-height: 1;
}
.ltcg-icon.good {
background: #e5f7e9;
color: #188038;
border: 1px solid #b7e0c1;
}
.ltcg-icon.bad {
background: #fdeaea;
color: #c5221f;
border: 1px solid #efb7b7;
}
</style>
</head>
<body>
<h1>Interactive Brokers Dashboard</h1>
<section>
<div class="section-head">
<h2>Open Lots</h2>
<div class="help-wrap">
<span class="help-badge">?</span>
<div class="help-pane">
<p><strong>Sorting:</strong> click a column header to sort ascending, click again for descending, click a third time to remove it. Hold <code>Shift</code> while clicking to add second and third sort levels.</p>
<p><strong>Text filters:</strong> use wildcards with <code>*</code> and <code>?</code>. Example: <code>AM*</code> or <code>*2027*</code>.</p>
<p><strong>Numeric filters:</strong> use values like <code>&gt;100</code>, <code>&lt;=0</code>, <code>50</code>.</p>
<p><strong>Date filters:</strong> use either wildcards like <code>2025-07*</code> or comparisons like <code>&gt;2025-01-01</code>.</p>
</div>
</div>
<label class="aggregate-toggle">
<input id="aggregate-same-day" type="checkbox" checked />
<span>Aggregate same-day lots</span>
</label>
<button id="refresh-lots" class="refresh-button" type="button" title="Refresh lots">
<span class="refresh-icon">&#x21bb;</span>
<span>Refresh</span>
</button>
</div>
<div class="table-wrap">
<table id="positions-table">
<thead>
<tr class="sort-row">
</tr>
<tr class="filter-row">
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<script src="/static/app.js"></script>
</body>
</html>

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
ibapi>=9.80
flask>=2.0.0