diff --git a/config.toml b/config.toml index a0649c7..aefbbf8 100644 --- a/config.toml +++ b/config.toml @@ -28,3 +28,7 @@ cash_buffer = 0.02 # fraction of NAV kept in cash resize_band = 0.10 # leave a held name alone while within 10% of its target value min_trade_value = 50.0 # skip trades below max(min_trade_value, min_trade_fraction * NAV); min_trade_fraction = 0.001 # full exits always trade +# IBKR symbols rebalance never trades; their value is set aside before sizing. Every +# other USD stock/ETF position is the strategy's and is sold once it is not selected, +# including a name that has left the index. +unmanaged = ["AAOI"] diff --git a/src/ibkr_quant/cli.py b/src/ibkr_quant/cli.py index cbeb1b3..d9cd2ea 100644 --- a/src/ibkr_quant/cli.py +++ b/src/ibkr_quant/cli.py @@ -148,15 +148,16 @@ async def rebalance(cfg: Config, submit: bool) -> None: async with Broker(cfg.ib) as broker: state = await broker.account_state() - managed = [h for h in state.holdings if h.sec_type == "STK" and h.currency == "USD" and h.symbol in universe] + managed = [h for h in state.holdings if h.sec_type == "STK" and h.currency == "USD" and h.symbol not in ex.unmanaged] unmanaged_value = sum(h.market_value_usd for h in state.holdings if h not in managed) investable = state.nav_usd * (1 - ex.cash_buffer) - unmanaged_value weights = select_with_buffer(score, {h.symbol for h in managed}, st.top_n, st.buffer) - # Sized at today's open, the latest price in the bars; filled at the close. + # Sized at today's open, the latest price in the bars; filled at the close. A holding + # that left the index has no bars and takes the account's price; its exit size does not use it. orders = plan_orders( weights, {h.symbol: h.quantity for h in managed}, - opens.iloc[-1].dropna().to_dict(), + {h.symbol: h.market_price for h in managed} | opens.iloc[-1].dropna().to_dict(), investable, max(ex.min_trade_value, ex.min_trade_fraction * state.nav_usd), ex.resize_band, diff --git a/src/ibkr_quant/config.py b/src/ibkr_quant/config.py index 1128a3e..d9a5d09 100644 --- a/src/ibkr_quant/config.py +++ b/src/ibkr_quant/config.py @@ -38,6 +38,7 @@ class ExecutionConfig: resize_band: float min_trade_value: float min_trade_fraction: float + unmanaged: list[str] @dataclass(frozen=True) diff --git a/tests/test_rebalance.py b/tests/test_rebalance.py index 2bb68a0..77043ed 100644 --- a/tests/test_rebalance.py +++ b/tests/test_rebalance.py @@ -1,5 +1,15 @@ +import asyncio +import dataclasses +import datetime as dt +from pathlib import Path +from unittest.mock import AsyncMock + +import pandas as pd import pytest +from ibkr_quant import cli +from ibkr_quant.broker import AccountState, Broker, Holding +from ibkr_quant.config import load_config from ibkr_quant.rebalance import plan_orders @@ -41,3 +51,30 @@ def test_resize_band_keeps_near_target_holdings(): ) # AAPL is 8% under target and stays; MSFT is 40% under and tops up. assert [(o.symbol, o.delta) for o in orders] == [("MSFT", 6)] + + +def test_rebalance_sells_a_holding_that_left_the_index_and_skips_unmanaged(monkeypatch, tmp_path): + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + cfg = dataclasses.replace(cfg, root=tmp_path, strategy=dataclasses.replace(cfg.strategy, top_n=2), + execution=dataclasses.replace(cfg.execution, unmanaged=["XYZ"])) + idx = pd.bdate_range(end=dt.date(2026, 10, 13), periods=300) + panel = pd.DataFrame(100.0, index=idx, columns=["AAA", "BBB", "CCC"]) + monkeypatch.setattr(cli, "load_universe", lambda _: list(panel.columns)) + monkeypatch.setattr(cli, "load_bars", lambda *_: (panel, panel)) + monkeypatch.setattr(cli, "sessions_until_rebalance", lambda *_: 0) + monkeypatch.setattr(cli, "signal_overnight_recovery", + lambda o, c: pd.DataFrame({"AAA": 0.9, "BBB": 0.8, "CCC": 0.1}, index=o.index)) + holdings = [Holding("AAA", "STK", "USD", 44, 100.0, 4_400.0), + Holding("OLD", "STK", "USD", 10, 30.0, 300.0), # left the index: no bars + Holding("XYZ", "STK", "USD", 10, 100.0, 1_000.0)] # unmanaged + broker = Broker(cfg.ib) + monkeypatch.setattr(Broker, "__aenter__", AsyncMock(return_value=broker)) + monkeypatch.setattr(Broker, "__aexit__", AsyncMock(return_value=None)) + monkeypatch.setattr(cli, "Broker", lambda _: broker) + monkeypatch.setattr(broker, "account_state", AsyncMock(return_value=AccountState("DU123", 10_000, 4_300, holdings))) + + asyncio.run(cli.rebalance(cfg, submit=False)) + + plan = pd.read_csv(next((tmp_path / "runs").glob("*/plan.csv"))) + # investable = 10,000 * 0.98 - 1,000 (XYZ) = 8,800: 4,400 per name, so AAA is on target. + assert list(zip(plan.symbol, plan.delta, plan.price)) == [("OLD", -10, 30.0), ("BBB", 44, 100.0)]