Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 61 additions & 27 deletions backend_api_python/app/routes/ibkr.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
through someone else's IBKR/TWS account.
"""

from flask import jsonify, request
import os

from flask import jsonify, request, g
from app.openapi.blueprint import HumanBlueprint as Blueprint
from app.utils.auth import login_required

from app.utils.logger import get_logger
from app.utils.broker_session import BrokerSessionRegistry
from app.services.ibkr_trading import IBKRClient, IBKRConfig
from app.services.ibkr_trading.account import flatten_account_summary
from app.services.ibkr_trading.config import build_ibkr_config
from app.services.ibkr_trading.session import get_or_create_session

logger = get_logger(__name__)

Expand All @@ -35,10 +39,40 @@ def _placeholder_status():
}


def _connect_error_message(config) -> str:
"""Explain a failed connect, including the containerised-loopback trap."""
target = f"{config.host}:{config.port}"
if config.host in ("127.0.0.1", "localhost", "::1") and os.path.exists("/.dockerenv"):
return (
f"Cannot reach TWS/Gateway at {target}. The backend runs in a container, "
"where 127.0.0.1 is the container itself. Use the host address "
"(host.docker.internal on Docker Desktop) and save it on the IBKR credential."
)
return (
f"Cannot reach TWS/Gateway at {target}. Check that it is running and that "
"its API port accepts connections from this host."
)


def _require_connected_client():
"""Return a live session, reconnecting from stored credentials if needed.

The session lives in process memory, so a request served by a different
worker than the one that handled ``POST /connect`` would otherwise report
"Not connected". Reconnecting on demand keeps every endpoint usable
regardless of which worker picks it up.
"""
client = _sessions.get()
if client is None or not client.connected:
if client is not None and client.connected:
return client, None

try:
client = get_or_create_session(build_ibkr_config({}, user_id=g.user_id))
except Exception as exc:
logger.info(f"IBKR auto-connect failed: {exc}")
return None, (jsonify({"success": False, "error": "Not connected to IBKR"}), 400)

_sessions.set(client)
return client, None


Expand Down Expand Up @@ -79,31 +113,24 @@ def connect():
"""
try:
data = request.get_json() or {}
config = build_ibkr_config(data, user_id=g.user_id)

config = IBKRConfig(
host=data.get('host', '127.0.0.1'),
port=int(data.get('port', 7497)),
client_id=int(data.get('clientId', 1)),
account=data.get('account', ''),
readonly=data.get('readonly', False),
)

client = IBKRClient(config)
success = client.connect()

if success:
_sessions.set(client)
return jsonify({
"success": True,
"message": "Connected successfully",
"data": client.get_connection_status()
})
else:
try:
client = get_or_create_session(config)
except ConnectionError as exc:
logger.warning(f"IBKR connect rejected: {exc}")
return jsonify({
"success": False,
"error": "Connection failed. Please check if TWS/Gateway is running."
"error": _connect_error_message(config),
}), 400

_sessions.set(client)
return jsonify({
"success": True,
"message": "Connected successfully",
"data": client.get_connection_status()
})

except ImportError:
return jsonify({
"success": False,
Expand Down Expand Up @@ -146,10 +173,17 @@ def get_account():
if err is not None:
return err

return jsonify({
"success": True,
"data": client.get_account_summary()
})
data = client.get_account_summary()
if not isinstance(data, dict) or not data.get("success"):
error = (data or {}).get("error") if isinstance(data, dict) else ""
return jsonify({
"success": False,
"error": error or "Failed to read the IBKR account summary",
}), 400

# The UI reads flat numeric fields; ``summary`` stays for agent/MCP callers.
data.update(flatten_account_summary(data.get("summary")))
return jsonify({"success": True, "data": data})
except Exception as e:
logger.error(f"Get account info failed: {e}")
return jsonify({
Expand Down
103 changes: 103 additions & 0 deletions backend_api_python/app/services/ibkr_trading/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Normalization of IBKR account summary tags.

``IB.accountSummary()`` returns tag rows (``NetLiquidation``, ``BuyingPower``,
...) whose values are strings paired with a currency. The broker-account UI
reads flat numeric fields off the account payload -- the same shape the Alpaca
route already returns -- so the nested tag map alone renders as "--".

This module maps the tags the UI needs onto flat fields. The original
``summary`` map stays in the response for agent/MCP consumers.
"""

from __future__ import annotations

from typing import Any, Dict, Mapping, Optional, Tuple

# Flat field -> IB tags, in priority order.
_FLAT_FIELDS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("net_liquidation", ("NetLiquidation",)),
("total_cash_value", ("TotalCashValue",)),
("buying_power", ("BuyingPower",)),
("init_margin_req", ("InitMarginReq", "FullInitMarginReq")),
("maint_margin_req", ("MaintMarginReq", "FullMaintMarginReq")),
("available_funds", ("AvailableFunds", "FullAvailableFunds")),
("excess_liquidity", ("ExcessLiquidity", "FullExcessLiquidity")),
("equity_with_loan", ("EquityWithLoanValue",)),
("gross_position_value", ("GrossPositionValue",)),
)

# Currency is read from a monetary tag; descriptive tags carry an empty one.
_CURRENCY_TAGS: Tuple[str, ...] = ("NetLiquidation", "TotalCashValue", "BuyingPower")


def _tag_value(summary: Mapping[str, Any], tag: str) -> Optional[float]:
row = summary.get(tag)
raw = row.get("value") if isinstance(row, Mapping) else row
if raw is None or (isinstance(raw, str) and not raw.strip()):
return None
try:
return float(raw)
except (TypeError, ValueError):
return None


def _tag_currency(summary: Mapping[str, Any], tag: str) -> str:
row = summary.get(tag)
if not isinstance(row, Mapping):
return ""
return str(row.get("currency") or "").strip()


def position_entry_price(position: Mapping[str, Any]) -> float:
"""Per-unit entry price for a position row from ``IBKRClient.get_positions``.

``avgCost`` is reported per contract, i.e. already multiplied, so a futures
position read through it is inflated by the contract multiplier. Callers
that compare against quotes, stops or fills want ``avgPrice``; ``avgCost``
remains the fallback for rows produced before it existed.
"""
if not isinstance(position, Mapping):
return 0.0
for field in ("avgPrice", "avgCost"):
raw = position.get(field)
if raw in (None, ""):
continue
try:
return float(raw)
except (TypeError, ValueError):
continue
return 0.0


def flatten_account_summary(summary: Any) -> Dict[str, Any]:
"""Return the flat numeric fields the broker-account UI reads.

Tags that IBKR did not report are omitted rather than zeroed, so the UI
shows "--" for genuinely missing values instead of a misleading 0.
"""
if not isinstance(summary, Mapping):
return {}

flat: Dict[str, Any] = {}
for field, tags in _FLAT_FIELDS:
for tag in tags:
value = _tag_value(summary, tag)
if value is not None:
flat[field] = value
break

if not flat:
# Currency belongs to an amount; on its own it tells the caller nothing.
return flat

for tag in _CURRENCY_TAGS:
currency = _tag_currency(summary, tag)
if currency:
flat["currency"] = currency
flat["account_currency"] = currency
break

return flat


__all__ = ["flatten_account_summary", "position_entry_price"]
46 changes: 42 additions & 4 deletions backend_api_python/app/services/ibkr_trading/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ def _ensure_ib_insync():
return ib_insync


def _contract_multiplier(contract) -> float:
"""Contract multiplier, defaulting to 1 for stocks and malformed values."""
try:
multiplier = float(getattr(contract, "multiplier", None) or 1)
except (TypeError, ValueError):
return 1.0
return multiplier if multiplier > 0 else 1.0


def _positive_or_none(value) -> Optional[float]:
"""IB reports an unset price as 0.0; treat that as absent."""
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None


@dataclass
class IBKRConfig:
"""IBKR connection configuration."""
Expand Down Expand Up @@ -576,16 +594,26 @@ def get_positions(self) -> List[Dict[str, Any]]:
for pos in positions:
contract = pos.contract
exchange = contract.exchange or contract.primaryExchange or "SMART"

multiplier = _contract_multiplier(contract)
avg_cost = float(pos.avgCost)

result.append({
"symbol": format_display_symbol(contract.symbol, exchange),
"ib_symbol": contract.symbol,
# Futures share a symbol across expiries; localSymbol (MGCV6)
# is what identifies the contract actually held.
"localSymbol": contract.localSymbol or "",
"lastTradeDate": contract.lastTradeDateOrContractMonth or "",
"secType": contract.secType,
"exchange": exchange,
"currency": contract.currency,
"multiplier": multiplier,
"quantity": float(pos.position),
"avgCost": float(pos.avgCost),
"marketValue": float(pos.position) * float(pos.avgCost),
# IB reports avgCost per contract, i.e. already multiplied.
"avgCost": avg_cost,
# Per unit, so it is comparable to quotes and stop prices.
"avgPrice": avg_cost / multiplier,
"marketValue": float(pos.position) * avg_cost,
})

return result
Expand All @@ -612,15 +640,25 @@ def get_open_orders(self) -> List[Dict[str, Any]]:
order = trade.order
contract = trade.contract
status = trade.orderStatus

limit_price = _positive_or_none(getattr(order, "lmtPrice", None))
# Stop and stop-limit orders carry their trigger in auxPrice;
# lmtPrice is 0 for a plain STP, which reads as "no price".
stop_price = _positive_or_none(getattr(order, "auxPrice", None))

result.append({
# ``id`` is what order tables key rows and cancel actions on.
"id": order.orderId,
"orderId": order.orderId,
"permId": getattr(order, "permId", 0),
"symbol": contract.symbol,
"localSymbol": contract.localSymbol or "",
"action": order.action,
"quantity": float(order.totalQuantity),
"orderType": order.orderType,
"limitPrice": getattr(order, 'lmtPrice', None),
"stopPrice": stop_price,
# The price that actually governs this order.
"price": limit_price if limit_price is not None else stop_price,
"status": status.status,
"filled": float(status.filled or 0),
"remaining": float(status.remaining or 0),
Expand Down
Loading