diff --git a/pyproject.toml b/pyproject.toml index 1a3476e..bce970d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/setup.py b/setup.py index 9786d35..34c3e73 100644 --- a/setup.py +++ b/setup.py @@ -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"), diff --git a/src/quant_platform_kit/__init__.py b/src/quant_platform_kit/__init__.py index bb3d11d..b046f6e 100644 --- a/src/quant_platform_kit/__init__.py +++ b/src/quant_platform_kit/__init__.py @@ -1,6 +1,6 @@ """QuantPlatformKit public package surface.""" -__version__ = "0.7.18" +__version__ = "0.7.19" from .common.models import ( ExecutionReport, diff --git a/src/quant_platform_kit/common/runtime_inputs.py b/src/quant_platform_kit/common/runtime_inputs.py index 73704d5..3054e05 100644 --- a/src/quant_platform_kit/common/runtime_inputs.py +++ b/src/quant_platform_kit/common/runtime_inputs.py @@ -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, ) diff --git a/src/quant_platform_kit/ibkr/connection.py b/src/quant_platform_kit/ibkr/connection.py index 191ca0e..a8798f6 100644 --- a/src/quant_platform_kit/ibkr/connection.py +++ b/src/quant_platform_kit/ibkr/connection.py @@ -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, @@ -70,6 +79,7 @@ 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}. " @@ -77,4 +87,7 @@ def connect_ib( "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 diff --git a/src/quant_platform_kit/longbridge/portfolio.py b/src/quant_platform_kit/longbridge/portfolio.py index a517aad..8a78436 100644 --- a/src/quant_platform_kit/longbridge/portfolio.py +++ b/src/quant_platform_kit/longbridge/portfolio.py @@ -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} @@ -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, diff --git a/tests/test_ibkr_connection.py b/tests/test_ibkr_connection.py index 3e34531..0cfe425 100644 --- a/tests/test_ibkr_connection.py +++ b/tests/test_ibkr_connection.py @@ -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 @@ -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", @@ -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() diff --git a/tests/test_longbridge_portfolio.py b/tests/test_longbridge_portfolio.py index 57571b4..d706e62 100644 --- a/tests/test_longbridge_portfolio.py +++ b/tests/test_longbridge_portfolio.py @@ -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: @@ -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) diff --git a/tests/test_strategy_contracts.py b/tests/test_strategy_contracts.py index cc0305b..2054d5f 100644 --- a/tests/test_strategy_contracts.py +++ b/tests/test_strategy_contracts.py @@ -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, @@ -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()