Skip to content

Commit eae6f32

Browse files
committed
Add IBKR account-aware routing
1 parent 8769362 commit eae6f32

7 files changed

Lines changed: 130 additions & 39 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.21"
7+
version = "0.7.22"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
setup(
55
name="quant-platform-kit",
6-
version="0.7.21",
6+
version="0.7.22",
77
description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.",
88
package_dir={"": "src"},
99
packages=find_packages(where="src"),

src/quant_platform_kit/common/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Position:
4242
market_value: float
4343
average_cost: float | None = None
4444
currency: str = "USD"
45+
account_id: str | None = None
4546

4647

4748
@dataclass(frozen=True)
@@ -62,6 +63,7 @@ class OrderIntent:
6263
order_type: str = "market"
6364
limit_price: float | None = None
6465
time_in_force: str | None = None
66+
account_id: str | None = None
6567
metadata: dict[str, Any] = field(default_factory=dict)
6668

6769

src/quant_platform_kit/ibkr/execution.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from quant_platform_kit.common.models import ExecutionReport, OrderIntent
66

77

8+
def _normalize_account_id(value: str | None) -> str | None:
9+
text = str(value or "").strip()
10+
return text or None
11+
12+
813
def _build_stock_contract(
914
symbol: str,
1015
*,
@@ -23,6 +28,7 @@ def submit_order_intent(
2328
ib: Any,
2429
order_intent: OrderIntent,
2530
*,
31+
account_id: str | None = None,
2632
wait_seconds: float = 1.0,
2733
stock_factory: Callable[..., Any] | None = None,
2834
market_order_factory: Callable[..., Any] | None = None,
@@ -55,6 +61,16 @@ def submit_order_intent(
5561
else:
5662
raise ValueError(f"Unsupported IBKR order type: {order_intent.order_type!r}")
5763

64+
intent_account_id = _normalize_account_id(order_intent.account_id)
65+
explicit_account_id = _normalize_account_id(account_id)
66+
if intent_account_id and explicit_account_id and intent_account_id != explicit_account_id:
67+
raise ValueError(
68+
"OrderIntent.account_id conflicts with submit_order_intent(account_id=...)."
69+
)
70+
resolved_account_id = intent_account_id or explicit_account_id
71+
if resolved_account_id:
72+
order.account = resolved_account_id
73+
5874
trade = ib.placeOrder(contract, order)
5975
if wait_seconds:
6076
import time as time_module
@@ -73,5 +89,6 @@ def submit_order_intent(
7389
raw_payload={
7490
"order_type": order_type,
7591
"time_in_force": getattr(order, "tif", None),
92+
"account_id": resolved_account_id,
7693
},
7794
)
Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
11
from __future__ import annotations
22

33
from datetime import datetime
4-
from typing import Any
4+
from typing import Any, Iterable
55

66
from quant_platform_kit.common.models import PortfolioSnapshot, Position
77

88

9-
def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> PortfolioSnapshot:
9+
def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
10+
if account_ids is None:
11+
return ()
12+
if isinstance(account_ids, str):
13+
candidates = [account_ids]
14+
else:
15+
candidates = list(account_ids)
16+
normalized = []
17+
for candidate in candidates:
18+
text = str(candidate or "").strip()
19+
if text:
20+
normalized.append(text)
21+
return tuple(dict.fromkeys(normalized))
22+
23+
24+
def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool:
25+
if not selected_account_ids:
26+
return True
27+
return str(account_id or "").strip() in selected_account_ids
28+
29+
30+
def fetch_portfolio_snapshot(
31+
ib: Any,
32+
*,
33+
account_ids: Iterable[str] | str | None = None,
34+
wait_seconds: float = 1.0,
35+
) -> PortfolioSnapshot:
36+
selected_account_ids = _normalize_account_ids(account_ids)
1037
ib.reqPositions()
1138
if wait_seconds:
1239
import time as time_module
@@ -15,6 +42,9 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio
1542

1643
positions = []
1744
for raw_position in ib.positions():
45+
account_id = str(getattr(raw_position, "account", "") or "").strip() or None
46+
if not _matches_account(account_id, selected_account_ids):
47+
continue
1848
if raw_position.position == 0:
1949
continue
2050
quantity = float(raw_position.position)
@@ -25,22 +55,28 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio
2555
quantity=quantity,
2656
market_value=quantity * average_cost,
2757
average_cost=average_cost,
58+
account_id=account_id,
2859
)
2960
)
3061

3162
total_equity = 0.0
3263
buying_power = None
3364
for account_value in ib.accountValues():
65+
account_id = str(getattr(account_value, "account", "") or "").strip() or None
66+
if not _matches_account(account_id, selected_account_ids):
67+
continue
3468
if account_value.currency != "USD":
3569
continue
3670
if account_value.tag == "NetLiquidation":
37-
total_equity = float(account_value.value)
71+
total_equity += float(account_value.value)
3872
elif account_value.tag == "AvailableFunds":
39-
buying_power = float(account_value.value)
73+
value = float(account_value.value)
74+
buying_power = value if buying_power is None else buying_power + value
4075

4176
return PortfolioSnapshot(
4277
as_of=datetime.utcnow(),
4378
total_equity=total_equity,
4479
buying_power=buying_power,
4580
positions=tuple(positions),
81+
metadata={"account_ids": selected_account_ids},
4682
)

tests/test_ibkr_execution.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,32 @@ def test_submit_limit_order_sets_time_in_force(self) -> None:
8787
self.assertEqual(report.raw_payload["time_in_force"], "DAY")
8888
self.assertEqual(ib.orders[0][1].tif, "DAY")
8989

90+
def test_submit_order_intent_sets_account_when_provided(self) -> None:
91+
ib = FakeIB()
92+
report = submit_order_intent(
93+
ib,
94+
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
95+
wait_seconds=0,
96+
stock_factory=FakeContract,
97+
market_order_factory=FakeMarketOrder,
98+
)
99+
100+
self.assertEqual(ib.orders[0][1].account, "U18308207")
101+
self.assertEqual(report.raw_payload["account_id"], "U18308207")
102+
103+
def test_submit_order_intent_rejects_conflicting_account_id(self) -> None:
104+
ib = FakeIB()
105+
106+
with self.assertRaises(ValueError):
107+
submit_order_intent(
108+
ib,
109+
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
110+
account_id="U15998061",
111+
wait_seconds=0,
112+
stock_factory=FakeContract,
113+
market_order_factory=FakeMarketOrder,
114+
)
115+
90116

91117
if __name__ == "__main__":
92118
unittest.main()

tests/test_ibkr_portfolio.py

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,66 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from types import SimpleNamespace
44
import unittest
55

66
from quant_platform_kit.ibkr.portfolio import fetch_portfolio_snapshot
77

88

9-
@dataclass
10-
class FakeContract:
11-
symbol: str
12-
13-
14-
@dataclass
15-
class FakePosition:
16-
contract: FakeContract
17-
position: int
18-
avgCost: float
19-
20-
21-
@dataclass
22-
class FakeAccountValue:
23-
tag: str
24-
currency: str
25-
value: str
26-
27-
289
class FakeIB:
10+
def __init__(self):
11+
self.req_positions_called = False
12+
2913
def reqPositions(self):
30-
self.positions_requested = True
14+
self.req_positions_called = True
3115

3216
def positions(self):
3317
return [
34-
FakePosition(contract=FakeContract("SPY"), position=10, avgCost=99.0),
35-
FakePosition(contract=FakeContract("AGG"), position=0, avgCost=100.0),
18+
SimpleNamespace(
19+
account="U18308207",
20+
contract=SimpleNamespace(symbol="TQQQ"),
21+
position=3,
22+
avgCost=100.0,
23+
),
24+
SimpleNamespace(
25+
account="U15998061",
26+
contract=SimpleNamespace(symbol="AAPL"),
27+
position=5,
28+
avgCost=200.0,
29+
),
3630
]
3731

3832
def accountValues(self):
3933
return [
40-
FakeAccountValue(tag="NetLiquidation", currency="USD", value="100000"),
41-
FakeAccountValue(tag="AvailableFunds", currency="USD", value="25000"),
34+
SimpleNamespace(account="U18308207", tag="NetLiquidation", currency="USD", value="1000"),
35+
SimpleNamespace(account="U18308207", tag="AvailableFunds", currency="USD", value="250"),
36+
SimpleNamespace(account="U15998061", tag="NetLiquidation", currency="USD", value="2000"),
37+
SimpleNamespace(account="U15998061", tag="AvailableFunds", currency="USD", value="500"),
4238
]
4339

4440

4541
class IbkrPortfolioTests(unittest.TestCase):
46-
def test_fetch_portfolio_snapshot_returns_equity_and_positions(self) -> None:
47-
snapshot = fetch_portfolio_snapshot(FakeIB(), wait_seconds=0)
48-
49-
self.assertEqual(snapshot.total_equity, 100000.0)
50-
self.assertEqual(snapshot.buying_power, 25000.0)
51-
self.assertEqual(len(snapshot.positions), 1)
52-
self.assertEqual(snapshot.positions[0].symbol, "SPY")
53-
self.assertEqual(snapshot.positions[0].market_value, 990.0)
42+
def test_fetch_portfolio_snapshot_filters_by_account_id(self) -> None:
43+
ib = FakeIB()
44+
45+
snapshot = fetch_portfolio_snapshot(ib, account_ids=("U18308207",), wait_seconds=0)
46+
47+
self.assertTrue(ib.req_positions_called)
48+
self.assertEqual(snapshot.total_equity, 1000.0)
49+
self.assertEqual(snapshot.buying_power, 250.0)
50+
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ",))
51+
self.assertEqual(snapshot.positions[0].account_id, "U18308207")
52+
self.assertEqual(snapshot.metadata["account_ids"], ("U18308207",))
53+
54+
def test_fetch_portfolio_snapshot_sums_selected_accounts(self) -> None:
55+
snapshot = fetch_portfolio_snapshot(
56+
FakeIB(),
57+
account_ids=("U18308207", "U15998061"),
58+
wait_seconds=0,
59+
)
60+
61+
self.assertEqual(snapshot.total_equity, 3000.0)
62+
self.assertEqual(snapshot.buying_power, 750.0)
63+
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ", "AAPL"))
5464

5565

5666
if __name__ == "__main__":

0 commit comments

Comments
 (0)