Every position is the strategy's: names it does not select, including ones outside the index such as AAOI, are sold at the next rebalance, and sizing uses the full NAV less the cash buffer. A position that is not a USD stock cannot be traded as a SMART/USD order, so rebalance refuses to run while one exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.5 KiB
Python
86 lines
3.5 KiB
Python
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 dry_run(monkeypatch, tmp_path, holdings):
|
|
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))
|
|
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))
|
|
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_800, holdings)))
|
|
asyncio.run(cli.rebalance(cfg, submit=False))
|
|
return pd.read_csv(next((tmp_path / "runs").glob("*/plan.csv")))
|
|
|
|
|
|
def test_rebalance_manages_every_position_in_the_account(monkeypatch, tmp_path):
|
|
plan = dry_run(monkeypatch, tmp_path, [Holding("AAA", "STK", "USD", 49, 100.0, 4_900.0),
|
|
Holding("OLD", "STK", "USD", 10, 30.0, 300.0)]) # not in the index: no bars
|
|
# investable = 10,000 * 0.98 = 9,800: 4,900 per name, so AAA is on target.
|
|
assert list(zip(plan.symbol, plan.delta, plan.price)) == [("OLD", -10, 30.0), ("BBB", 49, 100.0)]
|
|
|
|
|
|
def test_rebalance_refuses_positions_it_cannot_trade(monkeypatch, tmp_path):
|
|
with pytest.raises(SystemExit, match="not USD stocks: \\['ES'\\]"):
|
|
dry_run(monkeypatch, tmp_path, [Holding("ES", "FUT", "USD", 1, 6_000.0, 300_000.0)])
|