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:
@@ -28,7 +28,3 @@ 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
|
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_value = 50.0 # skip trades below max(min_trade_value, min_trade_fraction * NAV);
|
||||||
min_trade_fraction = 0.001 # full exits always trade
|
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"]
|
|
||||||
|
|||||||
@@ -148,16 +148,18 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
|||||||
|
|
||||||
async with Broker(cfg.ib) as broker:
|
async with Broker(cfg.ib) as broker:
|
||||||
state = await broker.account_state()
|
state = await broker.account_state()
|
||||||
managed = [h for h in state.holdings if h.sec_type == "STK" and h.currency == "USD" and h.symbol not in ex.unmanaged]
|
# The strategy owns the whole account: every position it does not select is sold.
|
||||||
unmanaged_value = sum(h.market_value_usd for h in state.holdings if h not in managed)
|
# Orders go out as SMART/USD stocks, so any other kind of position stops the run.
|
||||||
investable = state.nav_usd * (1 - ex.cash_buffer) - unmanaged_value
|
if foreign := [h.symbol for h in state.holdings if (h.sec_type, h.currency) != ("STK", "USD")]:
|
||||||
weights = select_with_buffer(score, {h.symbol for h in managed}, st.top_n, st.buffer)
|
raise SystemExit(f"positions that are not USD stocks: {foreign}; close them before rebalancing")
|
||||||
|
investable = state.nav_usd * (1 - ex.cash_buffer)
|
||||||
|
weights = select_with_buffer(score, {h.symbol for h in state.holdings}, st.top_n, st.buffer)
|
||||||
# Sized at today's open, the latest price in the bars; filled at the close. A holding
|
# 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.
|
# outside the index has no bars and takes the account's price; its exit size does not use it.
|
||||||
orders = plan_orders(
|
orders = plan_orders(
|
||||||
weights,
|
weights,
|
||||||
{h.symbol: h.quantity for h in managed},
|
{h.symbol: h.quantity for h in state.holdings},
|
||||||
{h.symbol: h.market_price for h in managed} | opens.iloc[-1].dropna().to_dict(),
|
{h.symbol: h.market_price for h in state.holdings} | opens.iloc[-1].dropna().to_dict(),
|
||||||
investable,
|
investable,
|
||||||
max(ex.min_trade_value, ex.min_trade_fraction * state.nav_usd),
|
max(ex.min_trade_value, ex.min_trade_fraction * state.nav_usd),
|
||||||
ex.resize_band,
|
ex.resize_band,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class ExecutionConfig:
|
|||||||
resize_band: float
|
resize_band: float
|
||||||
min_trade_value: float
|
min_trade_value: float
|
||||||
min_trade_fraction: float
|
min_trade_fraction: float
|
||||||
unmanaged: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ def test_resize_band_keeps_near_target_holdings():
|
|||||||
assert [(o.symbol, o.delta) for o in orders] == [("MSFT", 6)]
|
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 = load_config(Path(__file__).resolve().parents[1] / "config.toml")
|
||||||
cfg = dataclasses.replace(cfg, root=tmp_path, strategy=dataclasses.replace(cfg.strategy, top_n=2),
|
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)
|
idx = pd.bdate_range(end=dt.date(2026, 10, 13), periods=300)
|
||||||
panel = pd.DataFrame(100.0, index=idx, columns=["AAA", "BBB", "CCC"])
|
panel = pd.DataFrame(100.0, index=idx, columns=["AAA", "BBB", "CCC"])
|
||||||
monkeypatch.setattr(cli, "load_universe", lambda _: list(panel.columns))
|
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, "sessions_until_rebalance", lambda *_: 0)
|
||||||
monkeypatch.setattr(cli, "signal_overnight_recovery",
|
monkeypatch.setattr(cli, "signal_overnight_recovery",
|
||||||
lambda o, c: pd.DataFrame({"AAA": 0.9, "BBB": 0.8, "CCC": 0.1}, index=o.index))
|
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)
|
broker = Broker(cfg.ib)
|
||||||
monkeypatch.setattr(Broker, "__aenter__", AsyncMock(return_value=broker))
|
monkeypatch.setattr(Broker, "__aenter__", AsyncMock(return_value=broker))
|
||||||
monkeypatch.setattr(Broker, "__aexit__", AsyncMock(return_value=None))
|
monkeypatch.setattr(Broker, "__aexit__", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(cli, "Broker", lambda _: broker)
|
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))
|
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.
|
def test_rebalance_manages_every_position_in_the_account(monkeypatch, tmp_path):
|
||||||
assert list(zip(plan.symbol, plan.delta, plan.price)) == [("OLD", -10, 30.0), ("BBB", 44, 100.0)]
|
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)])
|
||||||
|
|||||||
Reference in New Issue
Block a user