Let the strategy own the whole account; drop the unmanaged list

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>
This commit is contained in:
2026-10-12 05:28:49 -07:00
parent e2b06f1b1c
commit 8238651af6
4 changed files with 25 additions and 23 deletions

View File

@@ -53,10 +53,10 @@ def test_resize_band_keeps_near_target_holdings():
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):
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),
execution=dataclasses.replace(cfg.execution, unmanaged=["XYZ"]))
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))
@@ -64,17 +64,22 @@ def test_rebalance_sells_a_holding_that_left_the_index_and_skips_unmanaged(monke
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)))
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")))
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)]
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)])