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.18"
version = "0.7.19"
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.18",
version="0.7.19",
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: 1 addition & 1 deletion src/quant_platform_kit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""QuantPlatformKit public package surface."""

__version__ = "0.7.18"
__version__ = "0.7.19"

from .common.models import (
ExecutionReport,
Expand Down
14 changes: 13 additions & 1 deletion src/quant_platform_kit/common/runtime_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,25 @@ def build_portfolio_snapshot_from_account_state(
)

available_cash = float(account_state["available_cash"])
snapshot_metadata = dict(metadata or {})
if normalized_symbols:
snapshot_metadata.setdefault("strategy_symbols", normalized_symbols)
raw_cash_by_currency = account_state.get("cash_by_currency")
if isinstance(raw_cash_by_currency, Mapping):
cash_by_currency = {
str(currency).strip().upper(): float(amount)
for currency, amount in raw_cash_by_currency.items()
if str(currency).strip()
}
if cash_by_currency:
snapshot_metadata.setdefault("cash_by_currency", cash_by_currency)
return PortfolioSnapshot(
as_of=as_of or datetime.now(timezone.utc),
total_equity=float(account_state["total_strategy_equity"]),
buying_power=available_cash,
cash_balance=available_cash,
positions=tuple(positions),
metadata=dict(metadata or {}),
metadata=snapshot_metadata,
)


Expand Down
13 changes: 13 additions & 0 deletions src/quant_platform_kit/ibkr/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ def probe_tcp_endpoint(
close()


def _disconnect_quietly(ib: Any) -> None:
disconnect = getattr(ib, "disconnect", None)
if callable(disconnect):
try:
disconnect()
except Exception:
pass


def connect_ib(
host: str,
port: int,
Expand Down Expand Up @@ -70,11 +79,15 @@ def connect_ib(
try:
ib.connect(host, port, clientId=client_id, timeout=timeout)
except TimeoutError as exc:
_disconnect_quietly(ib)
raise TimeoutError(
"IBKR API handshake timed out after TCP preflight succeeded "
f"for {host}:{port} clientId={client_id}. "
"Check that IB Gateway/TWS is fully logged in, API access is enabled, "
"the paper/live port matches the session, no login/API prompt is blocking, "
"and the client ID is not already stuck in another session."
) from exc
except Exception:
_disconnect_quietly(ib)
raise
return ib
11 changes: 9 additions & 2 deletions src/quant_platform_kit/longbridge/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ def fetch_strategy_account_state(
strategy_assets: Iterable[str],
) -> dict[str, Any]:
available_cash = 0.0
cash_by_currency: dict[str, float] = {}
account_balance = t_ctx.account_balance()
for account in account_balance:
for cash_info in getattr(account, "cash_infos", []):
if getattr(cash_info, "currency", None) == "USD":
available_cash += float(getattr(cash_info, "available_cash", 0.0))
currency = str(getattr(cash_info, "currency", "") or "").strip().upper()
if not currency:
continue
cash_amount = float(getattr(cash_info, "available_cash", 0.0))
cash_by_currency[currency] = cash_by_currency.get(currency, 0.0) + cash_amount
if currency == "USD":
available_cash += cash_amount

assets = [str(symbol).strip().upper() for symbol in strategy_assets if str(symbol).strip()]
market_values = {symbol: 0.0 for symbol in assets}
Expand Down Expand Up @@ -48,6 +54,7 @@ def fetch_strategy_account_state(

return {
"available_cash": available_cash,
"cash_by_currency": cash_by_currency,
"market_values": market_values,
"quantities": quantities,
"sellable_quantities": sellable_quantities,
Expand Down
7 changes: 7 additions & 0 deletions tests/test_ibkr_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ def fake_socket_create_connection(address, timeout):
self.assertEqual(observed["args"], ("127.0.0.1", 4001, 9, 20))

def test_connect_ib_wraps_api_handshake_timeout(self) -> None:
observed: dict[str, object] = {}

class FakeConnection:
def close(self):
pass
Expand All @@ -62,6 +64,9 @@ class FakeIB:
def connect(self, host, port, clientId, timeout):
raise TimeoutError()

def disconnect(self):
observed["disconnected"] = True

with self.assertRaisesRegex(TimeoutError, "API handshake timed out"):
connect_ib(
"10.0.0.8",
Expand All @@ -71,6 +76,8 @@ def connect(self, host, port, clientId, timeout):
ib_factory=FakeIB,
)

self.assertTrue(observed["disconnected"])

def test_probe_tcp_endpoint_wraps_timeout(self) -> None:
def fake_socket_create_connection(_address, _timeout):
raise TimeoutError()
Expand Down
6 changes: 5 additions & 1 deletion tests/test_longbridge_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ def __init__(self, currency, available_cash):

class FakeBalanceAccount:
def __init__(self):
self.cash_infos = [FakeCashInfo("USD", 1000.0)]
self.cash_infos = [
FakeCashInfo("USD", 1000.0),
FakeCashInfo("SGD", 350.0),
]


class FakePosition:
Expand Down Expand Up @@ -56,6 +59,7 @@ def test_fetch_strategy_account_state(self) -> None:
)

self.assertEqual(state["available_cash"], 1000.0)
self.assertEqual(state["cash_by_currency"], {"USD": 1000.0, "SGD": 350.0})
self.assertEqual(state["market_values"]["SOXL"], 150.0)
self.assertEqual(state["quantities"]["QQQI"], 2)
self.assertEqual(state["sellable_quantities"]["QQQI"], 1)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_strategy_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def test_build_portfolio_snapshot_from_account_state_keeps_strategy_symbol_order
snapshot = build_portfolio_snapshot_from_account_state(
{
"available_cash": 1500.0,
"cash_by_currency": {"usd": 1500.0, "sgd": 350.0},
"market_values": {"QQQI": 300.0, "TQQQ": 1200.0, "QQQ": 8000.0},
"quantities": {"QQQI": 10, "TQQQ": 3, "QQQ": 99},
"total_strategy_equity": 3000.0,
Expand All @@ -351,6 +352,8 @@ def test_build_portfolio_snapshot_from_account_state_keeps_strategy_symbol_order
self.assertEqual(snapshot.cash_balance, 1500.0)
self.assertEqual([position.symbol for position in snapshot.positions], ["TQQQ", "QQQI"])
self.assertEqual(snapshot.metadata["account_hash"], "acct-001")
self.assertEqual(snapshot.metadata["strategy_symbols"], ("TQQQ", "QQQI", "BOXX"))
self.assertEqual(snapshot.metadata["cash_by_currency"], {"USD": 1500.0, "SGD": 350.0})

def test_build_strategy_evaluation_inputs_only_keeps_available_inputs(self) -> None:
snapshot = object()
Expand Down