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

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