Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "quant-platform-kit"
version = "0.7.21"
version = "0.7.22"
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

setup(
name="quant-platform-kit",
version="0.7.21",
version="0.7.22",
description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.",
package_dir={"": "src"},
packages=find_packages(where="src"),
Expand Down
2 changes: 2 additions & 0 deletions src/quant_platform_kit/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Position:
market_value: float
average_cost: float | None = None
currency: str = "USD"
account_id: str | None = None


@dataclass(frozen=True)
Expand All @@ -62,6 +63,7 @@ class OrderIntent:
order_type: str = "market"
limit_price: float | None = None
time_in_force: str | None = None
account_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
Comment on lines +66 to 67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve OrderIntent positional argument order

Adding account_id before metadata changes the generated dataclass __init__ signature, so existing positional calls like OrderIntent(..., None, None, {"source": "x"}) now bind that dict to account_id and silently drop metadata. In IBKR flows this can propagate into submit_order_intent and set order.account to a stringified dict, causing misrouted or rejected orders for callers that relied on the previous positional API.

Useful? React with 👍 / 👎.



Expand Down
17 changes: 17 additions & 0 deletions src/quant_platform_kit/ibkr/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from quant_platform_kit.common.models import ExecutionReport, OrderIntent


def _normalize_account_id(value: str | None) -> str | None:
text = str(value or "").strip()
return text or None


def _build_stock_contract(
symbol: str,
*,
Expand All @@ -23,6 +28,7 @@ def submit_order_intent(
ib: Any,
order_intent: OrderIntent,
*,
account_id: str | None = None,
wait_seconds: float = 1.0,
stock_factory: Callable[..., Any] | None = None,
market_order_factory: Callable[..., Any] | None = None,
Expand Down Expand Up @@ -55,6 +61,16 @@ def submit_order_intent(
else:
raise ValueError(f"Unsupported IBKR order type: {order_intent.order_type!r}")

intent_account_id = _normalize_account_id(order_intent.account_id)
explicit_account_id = _normalize_account_id(account_id)
if intent_account_id and explicit_account_id and intent_account_id != explicit_account_id:
raise ValueError(
"OrderIntent.account_id conflicts with submit_order_intent(account_id=...)."
)
resolved_account_id = intent_account_id or explicit_account_id
if resolved_account_id:
order.account = resolved_account_id

trade = ib.placeOrder(contract, order)
if wait_seconds:
import time as time_module
Expand All @@ -73,5 +89,6 @@ def submit_order_intent(
raw_payload={
"order_type": order_type,
"time_in_force": getattr(order, "tif", None),
"account_id": resolved_account_id,
},
)
44 changes: 40 additions & 4 deletions src/quant_platform_kit/ibkr/portfolio.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
from __future__ import annotations

from datetime import datetime
from typing import Any
from typing import Any, Iterable

from quant_platform_kit.common.models import PortfolioSnapshot, Position


def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> PortfolioSnapshot:
def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
if account_ids is None:
return ()
if isinstance(account_ids, str):
candidates = [account_ids]
else:
candidates = list(account_ids)
normalized = []
for candidate in candidates:
text = str(candidate or "").strip()
if text:
normalized.append(text)
return tuple(dict.fromkeys(normalized))


def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool:
if not selected_account_ids:
return True
return str(account_id or "").strip() in selected_account_ids


def fetch_portfolio_snapshot(
ib: Any,
*,
account_ids: Iterable[str] | str | None = None,
wait_seconds: float = 1.0,
) -> PortfolioSnapshot:
selected_account_ids = _normalize_account_ids(account_ids)
ib.reqPositions()
if wait_seconds:
import time as time_module
Expand All @@ -15,6 +42,9 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio

positions = []
for raw_position in ib.positions():
account_id = str(getattr(raw_position, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if raw_position.position == 0:
continue
quantity = float(raw_position.position)
Expand All @@ -25,22 +55,28 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio
quantity=quantity,
market_value=quantity * average_cost,
average_cost=average_cost,
account_id=account_id,
)
)

total_equity = 0.0
buying_power = None
for account_value in ib.accountValues():
account_id = str(getattr(account_value, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if account_value.currency != "USD":
continue
if account_value.tag == "NetLiquidation":
total_equity = float(account_value.value)
total_equity += float(account_value.value)
elif account_value.tag == "AvailableFunds":
buying_power = float(account_value.value)
value = float(account_value.value)
buying_power = value if buying_power is None else buying_power + value

return PortfolioSnapshot(
as_of=datetime.utcnow(),
total_equity=total_equity,
buying_power=buying_power,
positions=tuple(positions),
metadata={"account_ids": selected_account_ids},
)
26 changes: 26 additions & 0 deletions tests/test_ibkr_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ def test_submit_limit_order_sets_time_in_force(self) -> None:
self.assertEqual(report.raw_payload["time_in_force"], "DAY")
self.assertEqual(ib.orders[0][1].tif, "DAY")

def test_submit_order_intent_sets_account_when_provided(self) -> None:
ib = FakeIB()
report = submit_order_intent(
ib,
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
wait_seconds=0,
stock_factory=FakeContract,
market_order_factory=FakeMarketOrder,
)

self.assertEqual(ib.orders[0][1].account, "U18308207")
self.assertEqual(report.raw_payload["account_id"], "U18308207")

def test_submit_order_intent_rejects_conflicting_account_id(self) -> None:
ib = FakeIB()

with self.assertRaises(ValueError):
submit_order_intent(
ib,
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
account_id="U15998061",
wait_seconds=0,
stock_factory=FakeContract,
market_order_factory=FakeMarketOrder,
)


if __name__ == "__main__":
unittest.main()
76 changes: 43 additions & 33 deletions tests/test_ibkr_portfolio.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,66 @@
from __future__ import annotations

from dataclasses import dataclass
from types import SimpleNamespace
import unittest

from quant_platform_kit.ibkr.portfolio import fetch_portfolio_snapshot


@dataclass
class FakeContract:
symbol: str


@dataclass
class FakePosition:
contract: FakeContract
position: int
avgCost: float


@dataclass
class FakeAccountValue:
tag: str
currency: str
value: str


class FakeIB:
def __init__(self):
self.req_positions_called = False

def reqPositions(self):
self.positions_requested = True
self.req_positions_called = True

def positions(self):
return [
FakePosition(contract=FakeContract("SPY"), position=10, avgCost=99.0),
FakePosition(contract=FakeContract("AGG"), position=0, avgCost=100.0),
SimpleNamespace(
account="U18308207",
contract=SimpleNamespace(symbol="TQQQ"),
position=3,
avgCost=100.0,
),
SimpleNamespace(
account="U15998061",
contract=SimpleNamespace(symbol="AAPL"),
position=5,
avgCost=200.0,
),
]

def accountValues(self):
return [
FakeAccountValue(tag="NetLiquidation", currency="USD", value="100000"),
FakeAccountValue(tag="AvailableFunds", currency="USD", value="25000"),
SimpleNamespace(account="U18308207", tag="NetLiquidation", currency="USD", value="1000"),
SimpleNamespace(account="U18308207", tag="AvailableFunds", currency="USD", value="250"),
SimpleNamespace(account="U15998061", tag="NetLiquidation", currency="USD", value="2000"),
SimpleNamespace(account="U15998061", tag="AvailableFunds", currency="USD", value="500"),
]


class IbkrPortfolioTests(unittest.TestCase):
def test_fetch_portfolio_snapshot_returns_equity_and_positions(self) -> None:
snapshot = fetch_portfolio_snapshot(FakeIB(), wait_seconds=0)

self.assertEqual(snapshot.total_equity, 100000.0)
self.assertEqual(snapshot.buying_power, 25000.0)
self.assertEqual(len(snapshot.positions), 1)
self.assertEqual(snapshot.positions[0].symbol, "SPY")
self.assertEqual(snapshot.positions[0].market_value, 990.0)
def test_fetch_portfolio_snapshot_filters_by_account_id(self) -> None:
ib = FakeIB()

snapshot = fetch_portfolio_snapshot(ib, account_ids=("U18308207",), wait_seconds=0)

self.assertTrue(ib.req_positions_called)
self.assertEqual(snapshot.total_equity, 1000.0)
self.assertEqual(snapshot.buying_power, 250.0)
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ",))
self.assertEqual(snapshot.positions[0].account_id, "U18308207")
self.assertEqual(snapshot.metadata["account_ids"], ("U18308207",))

def test_fetch_portfolio_snapshot_sums_selected_accounts(self) -> None:
snapshot = fetch_portfolio_snapshot(
FakeIB(),
account_ids=("U18308207", "U15998061"),
wait_seconds=0,
)

self.assertEqual(snapshot.total_equity, 3000.0)
self.assertEqual(snapshot.buying_power, 750.0)
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ", "AAPL"))


if __name__ == "__main__":
Expand Down