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 def test_closes_dropped_names_and_sells_before_buys(): orders = plan_orders( target_weights={"AAPL": 0.5, "MSFT": 0.5}, positions={"AAPL": 10, "NVDA": 7}, prices={"AAPL": 100.0, "MSFT": 300.0, "NVDA": 50.0}, investable=10_000, min_trade_value=200, ) assert [(o.symbol, o.delta) for o in orders] == [("NVDA", -7), ("AAPL", 40), ("MSFT", 16)] def test_skips_small_adjustments_but_not_full_exits(): orders = plan_orders( target_weights={"AAPL": 1.0}, positions={"AAPL": 99, "KO": 1}, prices={"AAPL": 100.0, "KO": 60.0}, investable=10_000, min_trade_value=200, ) assert [(o.symbol, o.delta) for o in orders] == [("KO", -1)] def test_refuses_to_plan_without_prices(): with pytest.raises(ValueError, match="NVDA"): plan_orders({"AAPL": 1.0}, {"NVDA": 5}, {"AAPL": 100.0}, 10_000, 0) def test_resize_band_keeps_near_target_holdings(): orders = plan_orders( target_weights={"AAPL": 0.5, "MSFT": 0.5}, positions={"AAPL": 46, "MSFT": 10}, prices={"AAPL": 100.0, "MSFT": 300.0}, investable=10_000, min_trade_value=50, resize_band=0.10, ) # 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)]