11import math
2- from datetime import datetime , timedelta
2+ from datetime import date , datetime , timedelta
33from types import SimpleNamespace
44from typing import cast
55
1616 normalize_config ,
1717)
1818from thetagang .portfolio_manager import PortfolioManager
19+ from thetagang .strategies .regime_engine import (
20+ REGIME_HISTORY_MAX_ATTEMPTS ,
21+ REGIME_HISTORY_TIMEFRAME ,
22+ )
23+
24+ REGIME_HISTORY_START = datetime (2024 , 1 , 2 )
25+ REGIME_SYMBOLS = ("AAA" , "BBB" )
1926
2027
2128@pytest .fixture
@@ -157,11 +164,8 @@ def now(cls, tz=None):
157164
158165
159166def _mock_regime_history (portfolio_manager , mocker , closes ):
160- start_date = datetime (2024 , 1 , 2 )
161- bars = [
162- SimpleNamespace (date = start_date + timedelta (days = offset ), close = close )
163- for offset , close in enumerate (closes )
164- ]
167+ bars = _regime_bars (closes )
168+ _mock_required_regime_history_dates (portfolio_manager , mocker )
165169
166170 async def _get_history (* _args , ** _kwargs ):
167171 return bars
@@ -172,15 +176,18 @@ async def _get_history(*_args, **_kwargs):
172176 return bars
173177
174178
179+ def _regime_bars (closes , start_date : datetime = REGIME_HISTORY_START ):
180+ return [
181+ SimpleNamespace (date = start_date + timedelta (days = offset ), close = close )
182+ for offset , close in enumerate (closes )
183+ ]
184+
185+
175186def _mock_regime_histories (portfolio_manager , mocker , closes_by_symbol ):
176- start_date = datetime (2024 , 1 , 2 )
177187 bars_by_symbol = {
178- symbol : [
179- SimpleNamespace (date = start_date + timedelta (days = offset ), close = close )
180- for offset , close in enumerate (closes )
181- ]
182- for symbol , closes in closes_by_symbol .items ()
188+ symbol : _regime_bars (closes ) for symbol , closes in closes_by_symbol .items ()
183189 }
190+ _mock_required_regime_history_dates (portfolio_manager , mocker )
184191
185192 async def _get_history (contract , * _args , ** _kwargs ):
186193 return bars_by_symbol [contract .symbol ]
@@ -216,6 +223,14 @@ async def _get_ticker(symbol, _primary_exchange):
216223 )
217224
218225
226+ def _mock_regime_broker (portfolio_manager , mocker , ** history_mock_kwargs ) -> None :
227+ _mock_regime_tickers (portfolio_manager , mocker )
228+ portfolio_manager .ibkr .request_historical_data = mocker .AsyncMock (
229+ ** history_mock_kwargs
230+ )
231+ portfolio_manager .ibkr .request_executions = mocker .AsyncMock (return_value = [])
232+
233+
219234def _stock_position (symbol : str , position : int , market_value : float | None = None ):
220235 return SimpleNamespace (
221236 contract = Stock (symbol , "SMART" , "USD" ),
@@ -224,6 +239,63 @@ def _stock_position(symbol: str, position: int, market_value: float | None = Non
224239 )
225240
226241
242+ def _regime_account_summary (value : str = "400" ):
243+ return {"NetLiquidation" : SimpleNamespace (value = value )}
244+
245+
246+ def _regime_stock_positions (aaa : int = 3 , bbb : int = 1 ):
247+ return {
248+ "AAA" : [_stock_position ("AAA" , aaa )],
249+ "BBB" : [_stock_position ("BBB" , bbb )],
250+ }
251+
252+
253+ def _expected_regime_history_fetches (symbols = REGIME_SYMBOLS ) -> int :
254+ return len (symbols ) * REGIME_HISTORY_MAX_ATTEMPTS
255+
256+
257+ def _disable_regime_history_retry_delay (monkeypatch ) -> None :
258+ monkeypatch .setattr (
259+ "thetagang.strategies.regime_engine.REGIME_HISTORY_RETRY_DELAY_SECONDS" ,
260+ 0.0 ,
261+ )
262+
263+
264+ def _required_regime_history_dates (required_points : int ) -> list [date ]:
265+ return [
266+ (REGIME_HISTORY_START + timedelta (days = offset )).date ()
267+ for offset in range (required_points )
268+ ]
269+
270+
271+ def _set_required_regime_history_dates (
272+ portfolio_manager , monkeypatch , required_points : int
273+ ) -> list [date ]:
274+ required_dates = _required_regime_history_dates (required_points )
275+ monkeypatch .setattr (
276+ portfolio_manager .regime_engine ,
277+ "_get_required_history_dates" ,
278+ lambda _required_points : required_dates ,
279+ )
280+ return required_dates
281+
282+
283+ def _mock_required_regime_history_dates (portfolio_manager , mocker ) -> None :
284+ mocker .patch .object (
285+ portfolio_manager .regime_engine ,
286+ "_get_required_history_dates" ,
287+ side_effect = _required_regime_history_dates ,
288+ )
289+
290+
291+ def _seed_regime_history_cache (portfolio_manager , closes , symbols = REGIME_SYMBOLS ):
292+ cache_bars = _regime_bars (closes )
293+ for symbol in symbols :
294+ portfolio_manager .data_store .record_historical_bars (
295+ symbol , REGIME_HISTORY_TIMEFRAME , cache_bars
296+ )
297+
298+
227299def _option_position (
228300 symbol : str ,
229301 position : int ,
@@ -347,6 +419,172 @@ async def test_regime_rebalance_generates_orders(portfolio_manager, mocker):
347419 assert orders == [("AAA" , "NYSE" , - 1 ), ("BBB" , "NYSE" , 1 )]
348420
349421
422+ @pytest .mark .asyncio
423+ async def test_regime_rebalance_retries_empty_history (
424+ portfolio_manager , mocker , monkeypatch
425+ ):
426+ _disable_regime_history_retry_delay (monkeypatch )
427+ _mock_required_regime_history_dates (portfolio_manager , mocker )
428+ account_summary = _regime_account_summary ()
429+ portfolio_positions = _regime_stock_positions ()
430+ bars = _regime_bars ([100.0 , 110.0 , 100.0 , 110.0 ])
431+ attempts_by_symbol = {"AAA" : 0 , "BBB" : 0 }
432+
433+ async def _get_history (contract , * _args , ** _kwargs ):
434+ attempts_by_symbol [contract .symbol ] += 1
435+ if attempts_by_symbol [contract .symbol ] == 1 :
436+ return []
437+ return bars
438+
439+ _mock_regime_broker (portfolio_manager , mocker , side_effect = _get_history )
440+
441+ _ , orders = await portfolio_manager .check_regime_rebalance_positions (
442+ account_summary , portfolio_positions
443+ )
444+
445+ assert attempts_by_symbol == {"AAA" : 2 , "BBB" : 2 }
446+ assert orders == [("AAA" , "NYSE" , - 1 ), ("BBB" , "NYSE" , 1 )]
447+
448+
449+ @pytest .mark .asyncio
450+ async def test_regime_rebalance_uses_fresh_cached_history_when_api_empty (
451+ portfolio_manager_with_db , mocker , monkeypatch
452+ ):
453+ _disable_regime_history_retry_delay (monkeypatch )
454+ _set_required_regime_history_dates (
455+ portfolio_manager_with_db , monkeypatch , required_points = 4
456+ )
457+ _seed_regime_history_cache (portfolio_manager_with_db , [100.0 , 110.0 , 100.0 , 110.0 ])
458+ account_summary = _regime_account_summary ()
459+ portfolio_positions = _regime_stock_positions ()
460+
461+ _mock_regime_broker (portfolio_manager_with_db , mocker , return_value = [])
462+
463+ _ , orders = await portfolio_manager_with_db .check_regime_rebalance_positions (
464+ account_summary , portfolio_positions
465+ )
466+
467+ assert (
468+ portfolio_manager_with_db .ibkr .request_historical_data .call_count
469+ == _expected_regime_history_fetches ()
470+ )
471+ assert orders == [("AAA" , "NYSE" , - 1 ), ("BBB" , "NYSE" , 1 )]
472+
473+
474+ @pytest .mark .asyncio
475+ async def test_regime_rebalance_uses_fresh_cache_when_api_history_is_stale (
476+ portfolio_manager_with_db , mocker , monkeypatch
477+ ):
478+ _disable_regime_history_retry_delay (monkeypatch )
479+ _set_required_regime_history_dates (
480+ portfolio_manager_with_db , monkeypatch , required_points = 4
481+ )
482+ _seed_regime_history_cache (portfolio_manager_with_db , [100.0 , 110.0 , 100.0 , 110.0 ])
483+ stale_bars = _regime_bars (
484+ [100.0 , 99.0 , 98.0 , 97.0 ],
485+ start_date = datetime (2023 , 12 , 20 ),
486+ )
487+ account_summary = _regime_account_summary ()
488+ portfolio_positions = _regime_stock_positions ()
489+
490+ _mock_regime_broker (portfolio_manager_with_db , mocker , return_value = stale_bars )
491+
492+ _ , orders = await portfolio_manager_with_db .check_regime_rebalance_positions (
493+ account_summary , portfolio_positions
494+ )
495+
496+ assert portfolio_manager_with_db .ibkr .request_historical_data .call_count == len (
497+ REGIME_SYMBOLS
498+ )
499+ assert orders == [("AAA" , "NYSE" , - 1 ), ("BBB" , "NYSE" , 1 )]
500+
501+
502+ @pytest .mark .asyncio
503+ async def test_regime_rebalance_ignores_history_after_required_sessions (
504+ portfolio_manager , mocker , monkeypatch
505+ ):
506+ _disable_regime_history_retry_delay (monkeypatch )
507+ required_dates = _set_required_regime_history_dates (
508+ portfolio_manager , monkeypatch , required_points = 4
509+ )
510+ bars_with_extra_partial_session = _regime_bars ([100.0 , 110.0 , 100.0 , 110.0 , 1.0 ])
511+
512+ _mock_regime_broker (
513+ portfolio_manager , mocker , return_value = bars_with_extra_partial_session
514+ )
515+
516+ (
517+ dates ,
518+ aligned_closes ,
519+ ) = await portfolio_manager .regime_engine ._get_regime_aligned_closes (
520+ list (REGIME_SYMBOLS ),
521+ lookback_days = 3 ,
522+ cooldown_days = 0 ,
523+ )
524+
525+ assert dates == required_dates
526+ assert aligned_closes == {
527+ "AAA" : [100.0 , 110.0 , 100.0 , 110.0 ],
528+ "BBB" : [100.0 , 110.0 , 100.0 , 110.0 ],
529+ }
530+ assert portfolio_manager .ibkr .request_historical_data .call_count == len (
531+ REGIME_SYMBOLS
532+ )
533+
534+
535+ @pytest .mark .asyncio
536+ async def test_regime_rebalance_rejects_incomplete_cached_history (
537+ portfolio_manager_with_db , mocker , monkeypatch
538+ ):
539+ _disable_regime_history_retry_delay (monkeypatch )
540+ _set_required_regime_history_dates (
541+ portfolio_manager_with_db , monkeypatch , required_points = 4
542+ )
543+ _seed_regime_history_cache (portfolio_manager_with_db , [100.0 , 110.0 , 100.0 ])
544+ account_summary = _regime_account_summary ()
545+ portfolio_positions = _regime_stock_positions ()
546+
547+ _mock_regime_broker (portfolio_manager_with_db , mocker , return_value = [])
548+
549+ with pytest .raises (ValueError , match = "fresh historical data" ):
550+ await portfolio_manager_with_db .check_regime_rebalance_positions (
551+ account_summary , portfolio_positions
552+ )
553+
554+ assert (
555+ portfolio_manager_with_db .ibkr .request_historical_data .call_count
556+ == _expected_regime_history_fetches ()
557+ )
558+
559+
560+ @pytest .mark .asyncio
561+ async def test_regime_rebalance_rejects_unreadable_cached_history (
562+ portfolio_manager_with_db , mocker , monkeypatch
563+ ):
564+ _disable_regime_history_retry_delay (monkeypatch )
565+ _set_required_regime_history_dates (
566+ portfolio_manager_with_db , monkeypatch , required_points = 4
567+ )
568+ account_summary = _regime_account_summary ()
569+ portfolio_positions = _regime_stock_positions ()
570+
571+ _mock_regime_broker (portfolio_manager_with_db , mocker , return_value = [])
572+ portfolio_manager_with_db .data_store .get_historical_bars = mocker .Mock (
573+ side_effect = RuntimeError ("database unavailable" )
574+ )
575+
576+ with pytest .raises (ValueError , match = "readable history cache" ):
577+ await portfolio_manager_with_db .check_regime_rebalance_positions (
578+ account_summary , portfolio_positions
579+ )
580+
581+ assert (
582+ portfolio_manager_with_db .ibkr .request_historical_data .call_count
583+ == _expected_regime_history_fetches ()
584+ )
585+ assert portfolio_manager_with_db .data_store .get_historical_bars .call_count == 1
586+
587+
350588@pytest .mark .asyncio
351589async def test_regime_rebalance_volatility_weight_scales_down_without_renormalizing (
352590 portfolio_manager , mocker
@@ -1392,7 +1630,7 @@ async def test_regime_rebalance_insufficient_history(portfolio_manager, mocker):
13921630 _mock_regime_history (portfolio_manager , mocker , [100.0 , 110.0 , 100.0 ])
13931631 portfolio_manager .ibkr .request_executions = mocker .AsyncMock (return_value = [])
13941632
1395- with pytest .raises (ValueError , match = "full lookback history " ):
1633+ with pytest .raises (ValueError , match = "fresh historical data " ):
13961634 await portfolio_manager .check_regime_rebalance_positions (
13971635 account_summary , portfolio_positions
13981636 )
@@ -2123,6 +2361,27 @@ async def _get_history(contract, *_args, **_kwargs):
21232361 )
21242362
21252363
2364+ @pytest .mark .asyncio
2365+ async def test_regime_rebalance_empty_history_stays_hard_failure (
2366+ portfolio_manager , mocker , monkeypatch
2367+ ):
2368+ _disable_regime_history_retry_delay (monkeypatch )
2369+ account_summary = _regime_account_summary ()
2370+ portfolio_positions = _regime_stock_positions ()
2371+
2372+ _mock_regime_broker (portfolio_manager , mocker , return_value = [])
2373+
2374+ with pytest .raises (ValueError , match = "aligned history" ):
2375+ await portfolio_manager .check_regime_rebalance_positions (
2376+ account_summary , portfolio_positions
2377+ )
2378+
2379+ assert (
2380+ portfolio_manager .ibkr .request_historical_data .call_count
2381+ == _expected_regime_history_fetches ()
2382+ )
2383+
2384+
21262385@pytest .mark .asyncio
21272386async def test_regime_rebalance_zero_weights_raises (portfolio_manager , mocker ):
21282387 account_summary = {"NetLiquidation" : SimpleNamespace (value = "400" )}
0 commit comments