Skip to content

Commit 8bc8fb6

Browse files
authored
fix(regime): harden history fetching (#679)
Retry empty regime history responses, require fresh completed-session data before using API or cached bars, and fail closed when the cache cannot validate the lookback window. Add regression coverage for empty/stale API history, cache recovery, unreadable cache failures, and ignoring extra partial-session bars.
1 parent 1b80144 commit 8bc8fb6

4 files changed

Lines changed: 611 additions & 70 deletions

File tree

tests/test_db.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,46 @@ def test_record_historical_bars_upserts_and_parses_dates(tmp_path) -> None:
157157
assert volume == 20
158158

159159

160+
def test_get_historical_bars_filters_by_symbol_timeframe_and_time(tmp_path) -> None:
161+
db_path = tmp_path / "state.db"
162+
data_store = DataStore(
163+
f"sqlite:///{db_path}",
164+
str(tmp_path / "thetagang.toml"),
165+
dry_run=False,
166+
config_text="test",
167+
)
168+
169+
data_store.record_historical_bars(
170+
"AAA",
171+
"1 day",
172+
[
173+
SimpleNamespace(date="20240104", close=1.0),
174+
SimpleNamespace(date="20240105", close=2.0),
175+
],
176+
)
177+
data_store.record_historical_bars(
178+
"AAA",
179+
"1 hour",
180+
[SimpleNamespace(date="20240105 12:00:00", close=99.0)],
181+
)
182+
data_store.record_historical_bars(
183+
"BBB",
184+
"1 day",
185+
[SimpleNamespace(date="20240105", close=100.0)],
186+
)
187+
188+
bars = data_store.get_historical_bars(
189+
"AAA",
190+
"1 day",
191+
datetime(2024, 1, 5, 0, 0, 0),
192+
datetime(2024, 1, 5, 23, 59, 59),
193+
)
194+
195+
assert len(bars) == 1
196+
assert bars[0].date == datetime(2024, 1, 5)
197+
assert bars[0].close == 2.0
198+
199+
160200
def test_record_executions_parses_string_times(tmp_path) -> None:
161201
db_path = tmp_path / "state.db"
162202
data_store = DataStore(

tests/test_regime_rebalance.py

Lines changed: 272 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import math
2-
from datetime import datetime, timedelta
2+
from datetime import date, datetime, timedelta
33
from types import SimpleNamespace
44
from typing import cast
55

@@ -16,6 +16,13 @@
1616
normalize_config,
1717
)
1818
from 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

159166
def _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+
175186
def _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+
219234
def _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+
227299
def _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
351589
async 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
21272386
async def test_regime_rebalance_zero_weights_raises(portfolio_manager, mocker):
21282387
account_summary = {"NetLiquidation": SimpleNamespace(value="400")}

0 commit comments

Comments
 (0)