Add READ_ONLY_API environment variable and enhance trading error handling

This commit is contained in:
coddard
2026-05-19 02:41:00 +03:00
parent 32537ef6e2
commit 22ac0da394
5 changed files with 114 additions and 2 deletions
+1
View File
@@ -7,6 +7,7 @@ services:
- ${COMPOSE_APP_ENV_FILE:-docker.env} - ${COMPOSE_APP_ENV_FILE:-docker.env}
environment: environment:
- TRADING_MODE=${TRADING_MODE:-paper} - TRADING_MODE=${TRADING_MODE:-paper}
- READ_ONLY_API=${READ_ONLY_API:-no}
- TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart} - TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart}
- AUTO_RESTART_TIME=${AUTO_RESTART_TIME:-11:59 PM} - AUTO_RESTART_TIME=${AUTO_RESTART_TIME:-11:59 PM}
- RELOGIN_AFTER_TWOFA_TIMEOUT=${RELOGIN_AFTER_TWOFA_TIMEOUT:-yes} - RELOGIN_AFTER_TWOFA_TIMEOUT=${RELOGIN_AFTER_TWOFA_TIMEOUT:-yes}
+62
View File
@@ -24,13 +24,66 @@ class ChartState:
def __init__(self): def __init__(self):
self.latest_tick = None self.latest_tick = None
self.current_ticker = None self.current_ticker = None
self.current_symbol = None
self.current_minute = None self.current_minute = None
self.current_ohlc = None self.current_ohlc = None
self.history_poll_task = None
self.last_realtime_update = 0.0
chart_state = ChartState() 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) @router.get("/", response_class=HTMLResponse)
async def index(request: Request, symbol: str = ""): async def index(request: Request, symbol: str = ""):
return templates.TemplateResponse(request, "index.html", {"symbol": symbol}) return templates.TemplateResponse(request, "index.html", {"symbol": symbol})
@@ -115,8 +168,13 @@ async def subscribe(request: Request):
except Exception as e: except Exception as e:
print(f"Error cancelling real-time bars: {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_minute = None
chart_state.current_ohlc = None chart_state.current_ohlc = None
chart_state.last_realtime_update = 0.0
ticker = ib.reqRealTimeBars(qualified[0], barSize=5, whatToShow="TRADES", useRTH=True) ticker = ib.reqRealTimeBars(qualified[0], barSize=5, whatToShow="TRADES", useRTH=True)
chart_state.current_ticker = ticker chart_state.current_ticker = ticker
@@ -152,9 +210,13 @@ async def subscribe(request: Request):
"close": bar.close, "close": bar.close,
} }
chart_state.last_realtime_update = time.monotonic()
chart_state.latest_tick = chart_state.current_ohlc.copy() chart_state.latest_tick = chart_state.current_ohlc.copy()
ticker.updateEvent += on_bar ticker.updateEvent += on_bar
chart_state.history_poll_task = asyncio.create_task(
_history_fallback_loop(symbol, qualified[0])
)
return {"status": "ok", "symbol": symbol} return {"status": "ok", "symbol": symbol}
except Exception as e: except Exception as e:
+4
View File
@@ -1,3 +1,5 @@
import os
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -10,6 +12,7 @@ router = APIRouter()
async def health(): async def health():
ib_connected = False ib_connected = False
account = None account = None
trading_mode = os.getenv("TRADING_MODE", "unknown").split("#", 1)[0].strip() or "unknown"
try: try:
ib_inst = dependencies.get_ib() ib_inst = dependencies.get_ib()
ib_connected = ib_inst.isConnected() ib_connected = ib_inst.isConnected()
@@ -30,6 +33,7 @@ async def health():
{ {
"status": "ok" if (ib_connected and db_ok) else "degraded", "status": "ok" if (ib_connected and db_ok) else "degraded",
"ib_connected": ib_connected, "ib_connected": ib_connected,
"trading_mode": trading_mode,
"account": account, "account": account,
"db_ok": db_ok, "db_ok": db_ok,
"version": "3.0", "version": "3.0",
+44 -1
View File
@@ -4,7 +4,7 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from ib_async import LimitOrder, MarketOrder, Stock 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}") 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) @router.get("/tradelog", response_class=HTMLResponse)
async def tradelog(request: Request): async def tradelog(request: Request):
cursor = dependencies.get_db_cursor() cursor = dependencies.get_db_cursor()
@@ -145,9 +157,40 @@ async def webhook(request: Request):
primary_trade = trades_list[0] primary_trade = trades_list[0]
for _ in range(60): for _ in range(60):
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
status = _normalized_trade_status(primary_trade)
if status in {"ValidationError", "Cancelled", "ApiCancelled", "Inactive"}:
break
if primary_trade.isDone(): if primary_trade.isDone():
break 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: if not primary_trade.isDone() or not primary_trade.fills:
for t in trades_list: for t in trades_list:
try: try:
+3 -1
View File
@@ -28,7 +28,9 @@
grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' } }, grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' } },
timeScale: { timeVisible: true, secondsVisible: false } timeScale: { timeVisible: true, secondsVisible: false }
}); });
const series = chart.addCandlestickSeries(); const series = typeof chart.addCandlestickSeries === 'function'
? chart.addCandlestickSeries()
: chart.addSeries(LightweightCharts.CandlestickSeries, {});
let eventSource = null; let eventSource = null;