Skip to content

Commit 2082293

Browse files
committed
fix(regime): decouple ratio gate from proxy history
Add a regression test for an uninvested non-anchor symbol in the ratio basket, fetch aligned closes explicitly for the ratio gate, and widen create_limit_order kwargs typing so the ty pre-commit hook passes.
1 parent a0d49de commit 2082293

4 files changed

Lines changed: 71 additions & 11 deletions

File tree

tests/test_regime_rebalance.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,22 @@ async def _get_history(*_args, **_kwargs):
171171
return bars
172172

173173

174-
def _mock_regime_tickers(portfolio_manager, mocker, aaa_price=100.0, bbb_price=100.0):
174+
def _mock_regime_tickers(
175+
portfolio_manager,
176+
mocker,
177+
aaa_price=100.0,
178+
bbb_price=100.0,
179+
extra_prices: dict[str, float] | None = None,
180+
):
175181
aaa_ticker = mocker.Mock()
176182
aaa_ticker.marketPrice.return_value = aaa_price
177183
bbb_ticker = mocker.Mock()
178184
bbb_ticker.marketPrice.return_value = bbb_price
179185
tickers = {"AAA": aaa_ticker, "BBB": bbb_ticker}
186+
for symbol, price in (extra_prices or {}).items():
187+
ticker = mocker.Mock()
188+
ticker.marketPrice.return_value = price
189+
tickers[symbol] = ticker
180190

181191
async def _get_ticker(symbol, _primary_exchange):
182192
return tickers[symbol]
@@ -433,6 +443,50 @@ async def test_regime_rebalance_hard_band_ignores_ratio_gate(portfolio_manager,
433443
assert orders == [("AAA", "NYSE", -1), ("BBB", "NYSE", 1)]
434444

435445

446+
@pytest.mark.asyncio
447+
async def test_regime_rebalance_ratio_gate_handles_uninvested_rest_symbol(
448+
portfolio_manager, mocker
449+
):
450+
portfolio_manager.config.portfolio.symbols["AAA"].weight = 0.4
451+
portfolio_manager.config.portfolio.symbols["BBB"].weight = 0.4
452+
portfolio_manager.config.portfolio.symbols["CCC"] = SimpleNamespace(
453+
weight=0.2, primary_exchange="NYSE"
454+
)
455+
portfolio_manager.config.strategies.regime_rebalance.symbols = [
456+
"AAA",
457+
"BBB",
458+
"CCC",
459+
]
460+
portfolio_manager.config.strategies.regime_rebalance.choppiness_min = 0.0
461+
portfolio_manager.config.strategies.regime_rebalance.efficiency_max = 1.0
462+
portfolio_manager.config.strategies.regime_rebalance.ratio_gate = SimpleNamespace(
463+
enabled=True,
464+
anchor="BBB",
465+
drift_max=1.25,
466+
var_min=0.0,
467+
)
468+
469+
account_summary = {"NetLiquidation": SimpleNamespace(value="500")}
470+
portfolio_positions = {
471+
"AAA": [SimpleNamespace(contract=Stock("AAA", "SMART", "USD"), position=3)],
472+
"BBB": [SimpleNamespace(contract=Stock("BBB", "SMART", "USD"), position=1)],
473+
}
474+
475+
_mock_regime_tickers(
476+
portfolio_manager,
477+
mocker,
478+
extra_prices={"CCC": 100.0},
479+
)
480+
_mock_regime_history(portfolio_manager, mocker, [100.0, 100.0, 100.0, 100.0])
481+
portfolio_manager.ibkr.request_executions = mocker.AsyncMock(return_value=[])
482+
483+
_, orders = await portfolio_manager.check_regime_rebalance_positions(
484+
account_summary, portfolio_positions
485+
)
486+
487+
assert isinstance(orders, list)
488+
489+
436490
@pytest.mark.asyncio
437491
async def test_regime_rebalance_cooldown_blocks_trades(
438492
portfolio_manager, mocker, monkeypatch

thetagang/portfolio_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,7 @@ async def _get_regime_proxy_series(
933933
lookback_days: int,
934934
cooldown_days: int,
935935
weights_override: Optional[Dict[str, float]] = None,
936-
) -> Tuple[List[date], List[float], Dict[str, List[float]]]:
936+
) -> Tuple[List[date], List[float]]:
937937
return await self.regime_engine._get_regime_proxy_series(
938938
symbols, lookback_days, cooldown_days, weights_override
939939
)

thetagang/strategies/regime_engine.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,11 @@ async def _get_regime_proxy_series(
8585
lookback_days: int,
8686
cooldown_days: int,
8787
weights_override: Optional[Dict[str, float]] = None,
88-
) -> Tuple[List[date], List[float], Dict[str, List[float]]]:
88+
) -> Tuple[List[date], List[float]]:
8989
symbol_configs = resolve_symbol_configs(
9090
self.config, context="regime proxy series"
9191
)
92-
if weights_override:
93-
symbols = list(weights_override.keys())
92+
proxy_symbols = list(weights_override.keys()) if weights_override else symbols
9493
sorted_dates, aligned_closes = await self._get_regime_aligned_closes(
9594
symbols,
9695
lookback_days,
@@ -100,7 +99,9 @@ async def _get_regime_proxy_series(
10099
if weights_override:
101100
weights = weights_override
102101
else:
103-
weights = {symbol: symbol_configs[symbol].weight for symbol in symbols}
102+
weights = {
103+
symbol: symbol_configs[symbol].weight for symbol in proxy_symbols
104+
}
104105
total_weight = sum(weights.values())
105106
if total_weight <= 0:
106107
log.error("Regime-aware rebalancing weights sum to zero, skipping.")
@@ -114,13 +115,13 @@ async def _get_regime_proxy_series(
114115
normalized_series = [1.0]
115116
for idx in range(1, len(sorted_dates)):
116117
daily_factor = 0.0
117-
for symbol in symbols:
118+
for symbol in proxy_symbols:
118119
prev_close = aligned_closes[symbol][idx - 1]
119120
curr_close = aligned_closes[symbol][idx]
120121
daily_factor += normalized_weights[symbol] * (curr_close / prev_close)
121122
normalized_series.append(normalized_series[-1] * daily_factor)
122123

123-
return (sorted_dates, normalized_series, aligned_closes)
124+
return (sorted_dates, normalized_series)
124125

125126
async def _get_regime_aligned_closes(
126127
self,
@@ -428,7 +429,7 @@ async def get_ticker_task(symbol: str) -> Tuple[str, Ticker]:
428429
symbol: symbol_configs[symbol].weight for symbol in symbols
429430
}
430431

431-
dates, values, aligned_closes = await self._get_regime_proxy_series(
432+
dates, values = await self._get_regime_proxy_series(
432433
symbols,
433434
regime_rebalance.lookback_days,
434435
regime_rebalance.cooldown_days,
@@ -463,6 +464,11 @@ async def get_ticker_task(symbol: str) -> Tuple[str, Ticker]:
463464
ratio_anchor: Optional[str] = None
464465
ratio_rest: List[str] = []
465466
if ratio_gate is not None:
467+
_, aligned_closes = await self._get_regime_aligned_closes(
468+
symbols,
469+
regime_rebalance.lookback_days,
470+
regime_rebalance.cooldown_days,
471+
)
466472
ratio_anchor = getattr(ratio_gate, "anchor", "")
467473
ratio_rest = [s for s in symbols if s != ratio_anchor]
468474
if not ratio_anchor or ratio_anchor not in symbols or not ratio_rest:

thetagang/trading_operations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import math
4-
from typing import Callable, List, Optional, Tuple
4+
from typing import Any, Callable, List, Optional, Tuple
55

66
from ib_async import TagValue, Ticker, util
77
from ib_async.contract import Contract, Option
@@ -68,7 +68,7 @@ def create_limit_order(
6868
transmit: bool = True,
6969
order_id: int | None = None,
7070
) -> LimitOrder:
71-
kwargs = {
71+
kwargs: dict[str, Any] = {
7272
"tif": tif,
7373
"account": self.account_number,
7474
"transmit": transmit,

0 commit comments

Comments
 (0)