From 22ac0da39464f8b34d4083db2525e258033da7ee Mon Sep 17 00:00:00 2001 From: coddard Date: Tue, 19 May 2026 02:41:00 +0300 Subject: [PATCH] Add READ_ONLY_API environment variable and enhance trading error handling --- docker-compose.yml | 1 + routers/charts.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ routers/health.py | 4 +++ routers/trades.py | 45 +++++++++++++++++++++++++++++++- templates/index.html | 4 ++- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7e4938d..94d637e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - ${COMPOSE_APP_ENV_FILE:-docker.env} environment: - TRADING_MODE=${TRADING_MODE:-paper} + - READ_ONLY_API=${READ_ONLY_API:-no} - TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart} - AUTO_RESTART_TIME=${AUTO_RESTART_TIME:-11:59 PM} - RELOGIN_AFTER_TWOFA_TIMEOUT=${RELOGIN_AFTER_TWOFA_TIMEOUT:-yes} diff --git a/routers/charts.py b/routers/charts.py index 5ffde0f..fc799d7 100644 --- a/routers/charts.py +++ b/routers/charts.py @@ -24,13 +24,66 @@ class ChartState: def __init__(self): self.latest_tick = None self.current_ticker = None + self.current_symbol = None self.current_minute = None self.current_ohlc = None + self.history_poll_task = None + self.last_realtime_update = 0.0 chart_state = ChartState() +async def _stop_history_poll_task() -> None: + task = chart_state.history_poll_task + chart_state.history_poll_task = None + if task is None or task.done(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +async def _history_fallback_loop(symbol: str, contract) -> None: + ib = dependencies.get_ib() + try: + while chart_state.current_symbol == symbol: + # Only fall back when real-time bars have not updated recently. + if chart_state.last_realtime_update and ( + time.monotonic() - chart_state.last_realtime_update + ) < 20: + await asyncio.sleep(15) + continue + + try: + bars = await ib.reqHistoricalDataAsync( + contract, + endDateTime="", + durationStr="1 D", + barSizeSetting="1 min", + whatToShow="TRADES", + useRTH=True, + formatDate=1, + ) + if bars: + last_bar = bars[-1] + chart_state.latest_tick = { + "time": int(last_bar.date.timestamp()), + "open": last_bar.open, + "high": last_bar.high, + "low": last_bar.low, + "close": last_bar.close, + } + except Exception as exc: + logger.warning(f"Historical fallback update failed for {symbol}: {exc}") + + await asyncio.sleep(15) + except asyncio.CancelledError: + pass + + @router.get("/", response_class=HTMLResponse) async def index(request: Request, symbol: str = ""): return templates.TemplateResponse(request, "index.html", {"symbol": symbol}) @@ -115,8 +168,13 @@ async def subscribe(request: Request): except Exception as e: print(f"Error cancelling real-time bars: {e}") + await _stop_history_poll_task() + + chart_state.current_symbol = symbol + chart_state.latest_tick = None chart_state.current_minute = None chart_state.current_ohlc = None + chart_state.last_realtime_update = 0.0 ticker = ib.reqRealTimeBars(qualified[0], barSize=5, whatToShow="TRADES", useRTH=True) chart_state.current_ticker = ticker @@ -152,9 +210,13 @@ async def subscribe(request: Request): "close": bar.close, } + chart_state.last_realtime_update = time.monotonic() chart_state.latest_tick = chart_state.current_ohlc.copy() ticker.updateEvent += on_bar + chart_state.history_poll_task = asyncio.create_task( + _history_fallback_loop(symbol, qualified[0]) + ) return {"status": "ok", "symbol": symbol} except Exception as e: diff --git a/routers/health.py b/routers/health.py index 038ca97..f6e9efa 100644 --- a/routers/health.py +++ b/routers/health.py @@ -1,3 +1,5 @@ +import os + from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -10,6 +12,7 @@ router = APIRouter() async def health(): ib_connected = False account = None + trading_mode = os.getenv("TRADING_MODE", "unknown").split("#", 1)[0].strip() or "unknown" try: ib_inst = dependencies.get_ib() ib_connected = ib_inst.isConnected() @@ -30,6 +33,7 @@ async def health(): { "status": "ok" if (ib_connected and db_ok) else "degraded", "ib_connected": ib_connected, + "trading_mode": trading_mode, "account": account, "db_ok": db_ok, "version": "3.0", diff --git a/routers/trades.py b/routers/trades.py index 5d32ecb..0315e73 100644 --- a/routers/trades.py +++ b/routers/trades.py @@ -4,7 +4,7 @@ import os from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from ib_async import LimitOrder, MarketOrder, Stock @@ -34,6 +34,18 @@ def log_trade(symbol, action, quantity, price, cursor, conn, lock): logger.info(f"Logged trade: {action} {quantity} {symbol} @ ${price:.2f}") +def _latest_trade_message(trade) -> str: + for entry in reversed(getattr(trade, "log", []) or []): + message = getattr(entry, "message", "") + if message: + return message + return "" + + +def _normalized_trade_status(trade) -> str: + return (getattr(getattr(trade, "orderStatus", None), "status", "") or "").strip() + + @router.get("/tradelog", response_class=HTMLResponse) async def tradelog(request: Request): cursor = dependencies.get_db_cursor() @@ -145,9 +157,40 @@ async def webhook(request: Request): primary_trade = trades_list[0] for _ in range(60): await asyncio.sleep(0.5) + status = _normalized_trade_status(primary_trade) + if status in {"ValidationError", "Cancelled", "ApiCancelled", "Inactive"}: + break if primary_trade.isDone(): break + status = _normalized_trade_status(primary_trade) + if status == "ValidationError": + for t in trades_list: + try: + ib.cancelOrder(t.order) + except Exception: + pass + detail = _latest_trade_message(primary_trade) or "Order was rejected by IBKR" + raise HTTPException(status_code=409, detail=detail) + + if status in {"Cancelled", "ApiCancelled", "Inactive"} and not primary_trade.fills: + detail = _latest_trade_message(primary_trade) or f"Order became {status}" + raise HTTPException(status_code=409, detail=detail) + + if status in {"PendingSubmit", "PreSubmitted", "Submitted"} and not primary_trade.fills: + return JSONResponse( + status_code=202, + content={ + "status": "accepted", + "symbol": symbol, + "action": action, + "quantity": quantity, + "order_type": order_type, + "order_status": status, + "message": _latest_trade_message(primary_trade), + }, + ) + if not primary_trade.isDone() or not primary_trade.fills: for t in trades_list: try: diff --git a/templates/index.html b/templates/index.html index c09d0e4..0f82c2a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,7 +28,9 @@ grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' } }, timeScale: { timeVisible: true, secondsVisible: false } }); - const series = chart.addCandlestickSeries(); + const series = typeof chart.addCandlestickSeries === 'function' + ? chart.addCandlestickSeries() + : chart.addSeries(LightweightCharts.CandlestickSeries, {}); let eventSource = null;