Generally quite nicely working already

This commit is contained in:
2026-04-01 21:44:21 -05:00
parent 28afa137cb
commit 1835fb89d8
13 changed files with 1686 additions and 198 deletions

View File

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