| import logging |
|
|
| import pandas as pd |
| import pytest |
| from unittest.mock import patch |
|
|
| from agentic_ai_system.yahoo_data_stream import YahooDataStream |
|
|
|
|
| @pytest.fixture |
| def yahoo_config(): |
| return { |
| 'data_source': {'type': 'yahoo'}, |
| 'yahoo': {'poll_interval_seconds': 1, 'auto_adjust': False}, |
| 'trading': { |
| 'symbol': 'AAPL', |
| 'timeframe': '1d', |
| }, |
| 'realtime_data': {'buffer_size': 10}, |
| } |
|
|
|
|
| def _sample_yahoo_frame(): |
| idx = pd.date_range('2024-06-03', periods=3, freq='D', tz='America/New_York') |
| return pd.DataFrame( |
| { |
| 'Open': [190.0, 191.0, 192.0], |
| 'High': [191.5, 192.5, 193.5], |
| 'Low': [189.0, 190.0, 191.0], |
| 'Close': [191.0, 192.0, 193.0], |
| 'Volume': [1_000_000, 1_100_000, 1_200_000], |
| }, |
| index=idx, |
| ) |
|
|
|
|
| def _split_frame(): |
| """An unadjusted 10:1 split, as Yahoo returns it with auto_adjust=False. |
| |
| Modelled on NVDA, 10 June 2024: the raw Close drops from ~1200 to ~120 and |
| a backtest reads it as a -90% day. |
| """ |
| idx = pd.date_range('2024-06-06', periods=4, freq='D', tz='America/New_York') |
| close = [1200.0, 1208.0, 120.5, 121.0] |
| return pd.DataFrame( |
| { |
| 'Open': close, |
| 'High': [c * 1.01 for c in close], |
| 'Low': [c * 0.99 for c in close], |
| 'Close': close, |
| 'Volume': [1_000_000] * 4, |
| }, |
| index=idx, |
| ) |
|
|
|
|
| def _dated_frame(timestamps, close=100.0): |
| return pd.DataFrame( |
| { |
| 'timestamp': [pd.Timestamp(t) for t in timestamps], |
| 'open': close, |
| 'high': close, |
| 'low': close, |
| 'close': close, |
| 'volume': 1_000.0, |
| } |
| ) |
|
|
|
|
| class TestYahooDataStream: |
| def test_initialization_from_symbol(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| assert stream.symbols == ['AAPL'] |
| assert stream.interval == '1d' |
|
|
| def test_normalize_ohlcv(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| df = stream._normalize_ohlcv(_sample_yahoo_frame()) |
| assert list(df.columns) == ['timestamp', 'open', 'high', 'low', 'close', 'volume'] |
| assert len(df) == 3 |
| assert df['close'].iloc[-1] == 193.0 |
|
|
| def test_clamp_intraday_lookback(self, yahoo_config): |
| yahoo_config['trading']['timeframe'] = '1m' |
| stream = YahooDataStream(yahoo_config) |
| start, end = stream._clamp_window('2020-01-01', '2026-01-01', '1m') |
| assert start > '2020-01-01' |
| assert end >= start |
|
|
| def test_get_historical_data(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| with patch.object(stream, '_download', return_value=_sample_yahoo_frame()): |
| df = stream.get_historical_data('AAPL', '2024-01-01', '2024-12-31') |
| assert len(df) == 3 |
| assert 'open' in df.columns |
|
|
|
|
| class TestPriceAdjustment: |
| """Unadjusted prices turn every split into a phantom crash.""" |
|
|
| def test_adjustment_is_on_by_default(self): |
| stream = YahooDataStream({'trading': {'symbol': 'AAPL', 'timeframe': '1d'}}) |
| assert stream.auto_adjust is True |
|
|
| def test_auto_adjust_is_passed_through_to_yfinance(self): |
| stream = YahooDataStream({'trading': {'symbol': 'AAPL', 'timeframe': '1d'}}) |
| with patch('yfinance.download', return_value=_sample_yahoo_frame()) as download: |
| stream._download('AAPL', period='5d', interval='1d') |
| assert download.call_args.kwargs['auto_adjust'] is True |
|
|
| def test_opting_out_of_adjustment_warns(self, caplog): |
| with caplog.at_level(logging.WARNING): |
| YahooDataStream({ |
| 'trading': {'symbol': 'AAPL', 'timeframe': '1d'}, |
| 'yahoo': {'auto_adjust': False}, |
| }) |
| assert any('not split' in r.message.lower() for r in caplog.records) |
|
|
| def test_split_sized_move_is_flagged(self, yahoo_config, caplog): |
| stream = YahooDataStream(yahoo_config) |
| df = stream._normalize_ohlcv(_split_frame()) |
| with caplog.at_level(logging.WARNING): |
| found = stream._warn_if_unadjusted('NVDA', df) |
| assert found == 1 |
| assert any('auto_adjust' in r.message for r in caplog.records) |
|
|
| def test_ordinary_moves_are_not_flagged(self, yahoo_config, caplog): |
| stream = YahooDataStream(yahoo_config) |
| df = stream._normalize_ohlcv(_sample_yahoo_frame()) |
| with caplog.at_level(logging.WARNING): |
| assert stream._warn_if_unadjusted('AAPL', df) == 0 |
|
|
| def test_intraday_bars_are_not_split_checked(self, yahoo_config): |
| """A 40% move in one minute is a halt or a fat finger, not a split.""" |
| yahoo_config['trading']['timeframe'] = '1m' |
| stream = YahooDataStream(yahoo_config) |
| df = stream._normalize_ohlcv(_split_frame()) |
| assert stream._warn_if_unadjusted('NVDA', df) == 0 |
|
|
|
|
| class TestIncompleteBars: |
| """The bar Yahoo is still building must not be reported as final.""" |
|
|
| def test_forming_bar_is_dropped(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize() |
| df = _dated_frame([now - pd.Timedelta(days=2), now - pd.Timedelta(days=1), now]) |
| kept = stream._drop_incomplete(df) |
| assert len(kept) == 2 |
| assert kept['timestamp'].max() < now |
|
|
| def test_finished_bars_all_survive(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize() |
| df = _dated_frame([now - pd.Timedelta(days=5), now - pd.Timedelta(days=4)]) |
| assert len(stream._drop_incomplete(df)) == 2 |
|
|
| def test_opting_in_keeps_the_forming_bar(self, yahoo_config): |
| yahoo_config['yahoo']['emit_incomplete_bars'] = True |
| stream = YahooDataStream(yahoo_config) |
| now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize() |
| df = _dated_frame([now - pd.Timedelta(days=1), now]) |
| assert len(stream._drop_incomplete(df)) == 2 |
|
|
| def test_partial_bar_is_never_emitted_then_stranded(self, yahoo_config): |
| """The bug this guards: emitting the forming bar advanced the watermark, |
| so the finished version of that same bar never reached a callback.""" |
| stream = YahooDataStream(yahoo_config) |
| received = [] |
| stream.add_data_callback(lambda kind, bar: received.append(bar)) |
|
|
| now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize() |
| yesterday, today = now - pd.Timedelta(days=1), now |
|
|
| stream._ingest_new_bars('AAPL', _dated_frame([yesterday, today], close=100.0)) |
| assert len(received) == 1 |
|
|
| |
| with patch.object(stream, '_drop_incomplete', side_effect=lambda d: d): |
| stream._ingest_new_bars('AAPL', _dated_frame([yesterday, today], close=105.0)) |
| assert len(received) == 2 |
| assert received[-1]['close'] == 105.0 |
|
|
|
|
| class TestPollBackoff: |
| """Yahoo rate-limits hard, and a fixed interval keeps you throttled.""" |
|
|
| def test_success_polls_at_the_configured_interval(self, yahoo_config): |
| yahoo_config['yahoo']['poll_interval_seconds'] = 60 |
| stream = YahooDataStream(yahoo_config) |
| stream._consecutive_failures = 0 |
| assert 48 <= stream._next_delay() <= 72 |
|
|
| def test_delay_grows_with_consecutive_failures(self, yahoo_config): |
| yahoo_config['yahoo']['poll_interval_seconds'] = 60 |
| stream = YahooDataStream(yahoo_config) |
| delays = [] |
| for failures in (1, 2, 3): |
| stream._consecutive_failures = failures |
| delays.append(stream._next_delay()) |
| assert delays[0] < delays[1] < delays[2] |
|
|
| def test_backoff_is_capped(self, yahoo_config): |
| yahoo_config['yahoo']['poll_interval_seconds'] = 60 |
| yahoo_config['yahoo']['max_backoff_seconds'] = 300 |
| stream = YahooDataStream(yahoo_config) |
| stream._consecutive_failures = 20 |
| assert stream._next_delay() <= 300 * 1.2 |
|
|
| def test_jitter_desynchronises_retries(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| stream._consecutive_failures = 3 |
| assert len({stream._next_delay() for _ in range(20)}) > 1 |
|
|
| def test_poll_reports_failure_when_every_symbol_fails(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| with patch.object(stream, '_download', side_effect=RuntimeError('429 Too Many Requests')): |
| assert stream._poll_once() is False |
|
|
| def test_poll_reports_success_when_a_symbol_returns_bars(self, yahoo_config): |
| stream = YahooDataStream(yahoo_config) |
| with patch.object(stream, '_download', return_value=_sample_yahoo_frame()): |
| assert stream._poll_once() is True |
|
|
| def test_empty_response_counts_as_failure(self, yahoo_config): |
| """A rate-limited yfinance returns an empty frame rather than raising.""" |
| stream = YahooDataStream(yahoo_config) |
| with patch.object(stream, '_download', return_value=pd.DataFrame()): |
| assert stream._poll_once() is False |
|
|