ParallelLLC/algorithmic_trading
2732
1import logging2 3import pandas as pd4import pytest5from unittest.mock import patch6 7from agentic_ai_system.yahoo_data_stream import YahooDataStream8 9 10@pytest.fixture11def yahoo_config():12 return {13 'data_source': {'type': 'yahoo'},14 'yahoo': {'poll_interval_seconds': 1, 'auto_adjust': False},15 'trading': {16 'symbol': 'AAPL',17 'timeframe': '1d',18 },19 'realtime_data': {'buffer_size': 10},20 }21 22 23def _sample_yahoo_frame():24 idx = pd.date_range('2024-06-03', periods=3, freq='D', tz='America/New_York')25 return pd.DataFrame(26 {27 'Open': [190.0, 191.0, 192.0],28 'High': [191.5, 192.5, 193.5],29 'Low': [189.0, 190.0, 191.0],30 'Close': [191.0, 192.0, 193.0],31 'Volume': [1_000_000, 1_100_000, 1_200_000],32 },33 index=idx,34 )35 36 37def _split_frame():38 """An unadjusted 10:1 split, as Yahoo returns it with auto_adjust=False.39 40 Modelled on NVDA, 10 June 2024: the raw Close drops from ~1200 to ~120 and41 a backtest reads it as a -90% day.42 """43 idx = pd.date_range('2024-06-06', periods=4, freq='D', tz='America/New_York')44 close = [1200.0, 1208.0, 120.5, 121.0]45 return pd.DataFrame(46 {47 'Open': close,48 'High': [c * 1.01 for c in close],49 'Low': [c * 0.99 for c in close],50 'Close': close,51 'Volume': [1_000_000] * 4,52 },53 index=idx,54 )55 56 57def _dated_frame(timestamps, close=100.0):58 return pd.DataFrame(59 {60 'timestamp': [pd.Timestamp(t) for t in timestamps],61 'open': close,62 'high': close,63 'low': close,64 'close': close,65 'volume': 1_000.0,66 }67 )68 69 70class TestYahooDataStream:71 def test_initialization_from_symbol(self, yahoo_config):72 stream = YahooDataStream(yahoo_config)73 assert stream.symbols == ['AAPL']74 assert stream.interval == '1d'75 76 def test_normalize_ohlcv(self, yahoo_config):77 stream = YahooDataStream(yahoo_config)78 df = stream._normalize_ohlcv(_sample_yahoo_frame())79 assert list(df.columns) == ['timestamp', 'open', 'high', 'low', 'close', 'volume']80 assert len(df) == 381 assert df['close'].iloc[-1] == 193.082 83 def test_clamp_intraday_lookback(self, yahoo_config):84 yahoo_config['trading']['timeframe'] = '1m'85 stream = YahooDataStream(yahoo_config)86 start, end = stream._clamp_window('2020-01-01', '2026-01-01', '1m')87 assert start > '2020-01-01'88 assert end >= start89 90 def test_get_historical_data(self, yahoo_config):91 stream = YahooDataStream(yahoo_config)92 with patch.object(stream, '_download', return_value=_sample_yahoo_frame()):93 df = stream.get_historical_data('AAPL', '2024-01-01', '2024-12-31')94 assert len(df) == 395 assert 'open' in df.columns96 97 98class TestPriceAdjustment:99 """Unadjusted prices turn every split into a phantom crash."""100 101 def test_adjustment_is_on_by_default(self):102 stream = YahooDataStream({'trading': {'symbol': 'AAPL', 'timeframe': '1d'}})103 assert stream.auto_adjust is True104 105 def test_auto_adjust_is_passed_through_to_yfinance(self):106 stream = YahooDataStream({'trading': {'symbol': 'AAPL', 'timeframe': '1d'}})107 with patch('yfinance.download', return_value=_sample_yahoo_frame()) as download:108 stream._download('AAPL', period='5d', interval='1d')109 assert download.call_args.kwargs['auto_adjust'] is True110 111 def test_opting_out_of_adjustment_warns(self, caplog):112 with caplog.at_level(logging.WARNING):113 YahooDataStream({114 'trading': {'symbol': 'AAPL', 'timeframe': '1d'},115 'yahoo': {'auto_adjust': False},116 })117 assert any('not split' in r.message.lower() for r in caplog.records)118 119 def test_split_sized_move_is_flagged(self, yahoo_config, caplog):120 stream = YahooDataStream(yahoo_config)121 df = stream._normalize_ohlcv(_split_frame())122 with caplog.at_level(logging.WARNING):123 found = stream._warn_if_unadjusted('NVDA', df)124 assert found == 1125 assert any('auto_adjust' in r.message for r in caplog.records)126 127 def test_ordinary_moves_are_not_flagged(self, yahoo_config, caplog):128 stream = YahooDataStream(yahoo_config)129 df = stream._normalize_ohlcv(_sample_yahoo_frame())130 with caplog.at_level(logging.WARNING):131 assert stream._warn_if_unadjusted('AAPL', df) == 0132 133 def test_intraday_bars_are_not_split_checked(self, yahoo_config):134 """A 40% move in one minute is a halt or a fat finger, not a split."""135 yahoo_config['trading']['timeframe'] = '1m'136 stream = YahooDataStream(yahoo_config)137 df = stream._normalize_ohlcv(_split_frame())138 assert stream._warn_if_unadjusted('NVDA', df) == 0139 140 141class TestIncompleteBars:142 """The bar Yahoo is still building must not be reported as final."""143 144 def test_forming_bar_is_dropped(self, yahoo_config):145 stream = YahooDataStream(yahoo_config)146 now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize()147 df = _dated_frame([now - pd.Timedelta(days=2), now - pd.Timedelta(days=1), now])148 kept = stream._drop_incomplete(df)149 assert len(kept) == 2150 assert kept['timestamp'].max() < now151 152 def test_finished_bars_all_survive(self, yahoo_config):153 stream = YahooDataStream(yahoo_config)154 now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize()155 df = _dated_frame([now - pd.Timedelta(days=5), now - pd.Timedelta(days=4)])156 assert len(stream._drop_incomplete(df)) == 2157 158 def test_opting_in_keeps_the_forming_bar(self, yahoo_config):159 yahoo_config['yahoo']['emit_incomplete_bars'] = True160 stream = YahooDataStream(yahoo_config)161 now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize()162 df = _dated_frame([now - pd.Timedelta(days=1), now])163 assert len(stream._drop_incomplete(df)) == 2164 165 def test_partial_bar_is_never_emitted_then_stranded(self, yahoo_config):166 """The bug this guards: emitting the forming bar advanced the watermark,167 so the finished version of that same bar never reached a callback."""168 stream = YahooDataStream(yahoo_config)169 received = []170 stream.add_data_callback(lambda kind, bar: received.append(bar))171 172 now = pd.Timestamp.now(tz='UTC').tz_convert(None).normalize()173 yesterday, today = now - pd.Timedelta(days=1), now174 175 stream._ingest_new_bars('AAPL', _dated_frame([yesterday, today], close=100.0))176 assert len(received) == 1 # only yesterday's completed bar177 178 # Next day: what was the forming bar is now final and must arrive.179 with patch.object(stream, '_drop_incomplete', side_effect=lambda d: d):180 stream._ingest_new_bars('AAPL', _dated_frame([yesterday, today], close=105.0))181 assert len(received) == 2182 assert received[-1]['close'] == 105.0183 184 185class TestPollBackoff:186 """Yahoo rate-limits hard, and a fixed interval keeps you throttled."""187 188 def test_success_polls_at_the_configured_interval(self, yahoo_config):189 yahoo_config['yahoo']['poll_interval_seconds'] = 60190 stream = YahooDataStream(yahoo_config)191 stream._consecutive_failures = 0192 assert 48 <= stream._next_delay() <= 72 # 60s +/- jitter193 194 def test_delay_grows_with_consecutive_failures(self, yahoo_config):195 yahoo_config['yahoo']['poll_interval_seconds'] = 60196 stream = YahooDataStream(yahoo_config)197 delays = []198 for failures in (1, 2, 3):199 stream._consecutive_failures = failures200 delays.append(stream._next_delay())201 assert delays[0] < delays[1] < delays[2]202 203 def test_backoff_is_capped(self, yahoo_config):204 yahoo_config['yahoo']['poll_interval_seconds'] = 60205 yahoo_config['yahoo']['max_backoff_seconds'] = 300206 stream = YahooDataStream(yahoo_config)207 stream._consecutive_failures = 20208 assert stream._next_delay() <= 300 * 1.2209 210 def test_jitter_desynchronises_retries(self, yahoo_config):211 stream = YahooDataStream(yahoo_config)212 stream._consecutive_failures = 3213 assert len({stream._next_delay() for _ in range(20)}) > 1214 215 def test_poll_reports_failure_when_every_symbol_fails(self, yahoo_config):216 stream = YahooDataStream(yahoo_config)217 with patch.object(stream, '_download', side_effect=RuntimeError('429 Too Many Requests')):218 assert stream._poll_once() is False219 220 def test_poll_reports_success_when_a_symbol_returns_bars(self, yahoo_config):221 stream = YahooDataStream(yahoo_config)222 with patch.object(stream, '_download', return_value=_sample_yahoo_frame()):223 assert stream._poll_once() is True224 225 def test_empty_response_counts_as_failure(self, yahoo_config):226 """A rate-limited yfinance returns an empty frame rather than raising."""227 stream = YahooDataStream(yahoo_config)228 with patch.object(stream, '_download', return_value=pd.DataFrame()):229 assert stream._poll_once() is False230 