File size: 17,253 Bytes
8a5d4f8
9d67baa
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
 
 
 
8a5d4f8
9d67baa
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
 
 
 
 
 
8a5d4f8
9d67baa
 
8a5d4f8
 
 
9d67baa
 
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
 
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d67baa
 
8a5d4f8
9d67baa
8a5d4f8
 
9d67baa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a5d4f8
9d67baa
 
 
8a5d4f8
 
 
 
 
 
 
 
9d67baa
8a5d4f8
 
9d67baa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a5d4f8
 
9d67baa
8a5d4f8
 
9d67baa
8a5d4f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
import logging
import random
import threading
import time
from typing import Any, Callable, Dict, List, Optional

import pandas as pd

logger = logging.getLogger(__name__)

_INTERVAL_MAP = {
    '1min': '1m',
    '1m': '1m',
    '5min': '5m',
    '5m': '5m',
    '15min': '15m',
    '15m': '15m',
    '30min': '30m',
    '30m': '30m',
    '1H': '1h',
    '1h': '1h',
    '60min': '1h',
    '1D': '1d',
    '1d': '1d',
    '1day': '1d',
}

# Yahoo lookback limits by interval. Requesting more returns empty or errors.
_MAX_LOOKBACK = {
    '1m': pd.Timedelta(days=7),
    '2m': pd.Timedelta(days=60),
    '5m': pd.Timedelta(days=60),
    '15m': pd.Timedelta(days=60),
    '30m': pd.Timedelta(days=60),
    '60m': pd.Timedelta(days=730),
    '90m': pd.Timedelta(days=60),
    '1h': pd.Timedelta(days=730),
    '1d': None,
    '5d': None,
    '1wk': None,
    '1mo': None,
    '3mo': None,
}

# How long each bar covers. Used to tell a finished bar from the one still
# forming right now -- see _drop_incomplete.
_INTERVAL_DURATION = {
    '1m': pd.Timedelta(minutes=1),
    '2m': pd.Timedelta(minutes=2),
    '5m': pd.Timedelta(minutes=5),
    '15m': pd.Timedelta(minutes=15),
    '30m': pd.Timedelta(minutes=30),
    '60m': pd.Timedelta(hours=1),
    '90m': pd.Timedelta(minutes=90),
    '1h': pd.Timedelta(hours=1),
    '1d': pd.Timedelta(days=1),
    '5d': pd.Timedelta(days=5),
    '1wk': pd.Timedelta(weeks=1),
}

# A daily-or-slower bar that moves more than this is almost always an
# unadjusted split rather than a real move (NVDA's 2024 10:1 shows up as -90%).
_SPLIT_SUSPECT_MOVE = 0.35


class YahooDataStream:
    """
    Market data from Yahoo Finance via yfinance.

    Yahoo has no public equities WebSocket. This polls OHLCV bars.
    Quotes are typically delayed (~15 minutes for US equities).
    Unofficial API: rate limits and schema changes are expected failure modes.
    """

    def __init__(self, config: Dict[str, Any]):
        self.config = config
        trading = config.get('trading', {})
        if trading.get('symbols'):
            self.symbols = list(trading['symbols'])
        elif trading.get('symbol'):
            self.symbols = [trading['symbol']]
        else:
            self.symbols = ['AAPL']
        yahoo_cfg = config.get('yahoo', {})
        self.poll_interval = int(yahoo_cfg.get('poll_interval_seconds', 60))
        # Adjusted by default. With auto_adjust off, Yahoo returns raw Close and
        # every split reads as a crash: NVDA's June 2024 10:1 becomes a -90% bar.
        self.auto_adjust = bool(yahoo_cfg.get('auto_adjust', True))
        self.emit_incomplete_bars = bool(yahoo_cfg.get('emit_incomplete_bars', False))
        self.max_backoff = int(yahoo_cfg.get('max_backoff_seconds', 900))
        self.interval = self._map_interval(config.get('trading', {}).get('timeframe', '1d'))
        self._consecutive_failures = 0
        self.data_callbacks: List[Callable] = []
        self.is_connected = False
        self.data_buffer: Dict[str, Dict[str, Any]] = {}
        self._stop_event = threading.Event()
        self._poll_thread: Optional[threading.Thread] = None
        self._last_bar_ts: Dict[str, pd.Timestamp] = {}
        self._buffer_size = int(config.get('realtime_data', {}).get('buffer_size', 100))

        for symbol in self.symbols:
            self.data_buffer[symbol] = {
                'trades': [],
                'quotes': [],
                'bars': [],
                'latest_bar': None,
            }

        if not self.auto_adjust:
            logger.warning(
                "yahoo.auto_adjust is false: prices are NOT split- or dividend-adjusted. "
                "Every split will appear as a large single-bar loss and any backtest "
                "spanning one will be wrong."
            )

        logger.info(
            "Initialized YahooDataStream symbols=%s interval=%s poll_interval=%ss "
            "auto_adjust=%s emit_incomplete_bars=%s",
            self.symbols,
            self.interval,
            self.poll_interval,
            self.auto_adjust,
            self.emit_incomplete_bars,
        )

    @staticmethod
    def _map_interval(timeframe: str) -> str:
        mapped = _INTERVAL_MAP.get(str(timeframe), None)
        if mapped is None:
            logger.warning("Unknown timeframe %s, defaulting to 1d", timeframe)
            return '1d'
        return mapped

    def connect(self) -> None:
        """Start polling Yahoo for new bars."""
        if self.is_connected:
            logger.info("Yahoo data stream already connected")
            return

        self._stop_event.clear()
        # Seed the backoff from the first attempt: if we are already being
        # throttled, the loop should start backed off rather than hammering.
        self._consecutive_failures = 0 if self._poll_once() else 1
        self._poll_thread = threading.Thread(target=self._poll_loop, name='yahoo-poll', daemon=True)
        self._poll_thread.start()
        self.is_connected = True
        logger.info("Yahoo data stream polling started")

    def disconnect(self) -> None:
        self._stop_event.set()
        self.is_connected = False
        if self._poll_thread and self._poll_thread.is_alive():
            self._poll_thread.join(timeout=min(5, self.poll_interval + 1))
        logger.info("Disconnected from Yahoo data stream")

    def is_streaming(self) -> bool:
        return self.is_connected and self._poll_thread is not None and self._poll_thread.is_alive()

    def add_data_callback(self, callback: Callable) -> None:
        self.data_callbacks.append(callback)

    def get_latest_data(self, symbol: str) -> Dict[str, Any]:
        if symbol not in self.data_buffer:
            return {}
        buffer = self.data_buffer[symbol]
        return {
            'latest_trade': buffer['trades'][-1] if buffer['trades'] else None,
            'latest_quote': buffer['quotes'][-1] if buffer['quotes'] else None,
            'latest_bar': buffer['latest_bar'],
            'recent_trades': buffer['trades'][-10:] if buffer['trades'] else [],
            'recent_quotes': buffer['quotes'][-10:] if buffer['quotes'] else [],
        }

    def get_historical_data(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
        start, end = self._clamp_window(start_date, end_date, self.interval)
        try:
            raw = self._download(symbol, start=start, end=end, interval=self.interval)
            df = self._drop_incomplete(self._normalize_ohlcv(raw))
            self._warn_if_unadjusted(symbol, df)
            if df.empty:
                logger.warning("No Yahoo historical data for %s between %s and %s", symbol, start, end)
            else:
                logger.info("Loaded %s Yahoo bars for %s (%s to %s)", len(df), symbol, start, end)
            return df
        except Exception as e:
            logger.error("Error fetching Yahoo historical data for %s: %s", symbol, e, exc_info=True)
            return pd.DataFrame()

    def test_connection(self) -> bool:
        try:
            symbol = self.symbols[0] if self.symbols else 'AAPL'
            df = self._download(symbol, period='5d', interval='1d')
            if df is None or df.empty:
                logger.warning("Yahoo connection test returned no data for %s", symbol)
                return False
            logger.info("Yahoo connection test succeeded for %s (%s rows)", symbol, len(df))
            return True
        except Exception as e:
            logger.error("Yahoo connection test failed: %s", e)
            return False

    def get_connection_status(self) -> Dict[str, Any]:
        return {
            'is_connected': self.is_connected,
            'provider': 'yahoo',
            'interval': self.interval,
            'poll_interval_seconds': self.poll_interval,
            'symbols': self.symbols,
            'data_buffers': {
                symbol: len(buffer['bars']) for symbol, buffer in self.data_buffer.items()
            },
        }

    def generate_simulated_data(self, symbol: str) -> Dict[str, Any]:
        latest_data = self.get_latest_data(symbol)
        base_price = 150.0
        if latest_data.get('latest_bar'):
            base_price = latest_data['latest_bar']['close']
        elif latest_data.get('latest_trade'):
            base_price = latest_data['latest_trade']['price']

        price_change = random.uniform(-0.01, 0.01) * base_price
        new_price = base_price + price_change
        simulated_bar = {
            'symbol': symbol,
            'open': base_price,
            'high': max(base_price, new_price),
            'low': min(base_price, new_price),
            'close': new_price,
            'volume': random.randint(100, 1000),
            'timestamp': int(time.time() * 1_000_000),
        }
        self._store_bar(symbol, simulated_bar, emit=False)
        return simulated_bar

    def _poll_loop(self) -> None:
        delay = self.poll_interval
        while not self._stop_event.wait(delay):
            try:
                succeeded = self._poll_once()
            except Exception as e:
                logger.error("Yahoo poll loop error: %s", e, exc_info=True)
                succeeded = False
            self._consecutive_failures = 0 if succeeded else self._consecutive_failures + 1
            delay = self._next_delay()

    def _next_delay(self) -> float:
        """Poll interval, backed off exponentially while Yahoo is refusing us.

        Yahoo rate-limits aggressively and an unofficial API gives no
        Retry-After, so a fixed interval just keeps you throttled. Jitter stops
        several symbols (or several deployments) resynchronising after an outage.
        """
        if self._consecutive_failures == 0:
            base = float(self.poll_interval)
        else:
            base = min(
                self.poll_interval * (2 ** self._consecutive_failures),
                float(self.max_backoff),
            )
            logger.warning(
                "Yahoo poll failed %s time(s) in a row; next attempt in ~%.0fs",
                self._consecutive_failures,
                base,
            )
        return max(1.0, base * random.uniform(0.8, 1.2))

    def _poll_once(self) -> bool:
        """Fetch and ingest one round of bars. Returns True if any symbol succeeded."""
        any_success = False
        for symbol in self.symbols:
            try:
                raw = self._download(symbol, period='5d', interval=self.interval)
                df = self._normalize_ohlcv(raw)
                if df.empty:
                    logger.warning("Yahoo poll returned no bars for %s", symbol)
                    continue
                self._ingest_new_bars(symbol, df)
                any_success = True
            except Exception as e:
                logger.error("Yahoo poll failed for %s: %s", symbol, e)
        return any_success

    def _warn_if_unadjusted(self, symbol: str, df: pd.DataFrame) -> int:
        """Flag single-bar moves that look like unadjusted corporate actions.

        This is a backstop rather than the fix -- the fix is auto_adjust. But a
        split slipping through silently corrupts every downstream number, so it
        is worth naming the dates rather than letting a strategy trade them.
        Returns the number of suspicious bars found.
        """
        duration = _INTERVAL_DURATION.get(self.interval)
        if df.empty or len(df) < 2 or duration is None or duration < pd.Timedelta(days=1):
            return 0
        moves = df['close'].pct_change()
        suspects = df.loc[moves.abs() > _SPLIT_SUSPECT_MOVE, 'timestamp']
        if len(suspects):
            dates = ', '.join(str(pd.Timestamp(t).date()) for t in suspects.head(5))
            logger.warning(
                "%s has %s bar(s) moving more than %.0f%% (%s). On a liquid name that is "
                "usually an unadjusted split, not a real move — check yahoo.auto_adjust.",
                symbol,
                len(suspects),
                _SPLIT_SUSPECT_MOVE * 100,
                dates,
            )
        return int(len(suspects))

    def _drop_incomplete(self, df: pd.DataFrame) -> pd.DataFrame:
        """Remove the bar that is still forming.

        Yahoo returns the in-progress period as an ordinary row. Emitting it
        would hand the strategy a close that has not happened yet, and because
        the watermark advances past it, the finished version never arrives.
        """
        if self.emit_incomplete_bars or df.empty:
            return df
        duration = _INTERVAL_DURATION.get(self.interval)
        if duration is None:
            return df
        now = pd.Timestamp.now(tz='UTC').tz_convert(None)
        complete = df[df['timestamp'] + duration <= now]
        dropped = len(df) - len(complete)
        if dropped:
            logger.debug("Dropped %s in-progress %s bar(s)", dropped, self.interval)
        return complete

    def _ingest_new_bars(self, symbol: str, df: pd.DataFrame) -> None:
        rows = self._drop_incomplete(df)
        last_ts = self._last_bar_ts.get(symbol)
        if last_ts is not None:
            rows = rows[rows['timestamp'] > last_ts]
        if rows.empty:
            return

        for _, row in rows.iterrows():
            ts = pd.Timestamp(row['timestamp'])
            bar = {
                'symbol': symbol,
                'open': float(row['open']),
                'high': float(row['high']),
                'low': float(row['low']),
                'close': float(row['close']),
                'volume': float(row['volume']),
                'timestamp': int(ts.timestamp() * 1_000_000),
            }
            self._store_bar(symbol, bar, emit=True)
            self._last_bar_ts[symbol] = ts

    def _store_bar(self, symbol: str, bar: Dict[str, Any], emit: bool) -> None:
        buffer = self.data_buffer[symbol]
        buffer['bars'].append(bar)
        buffer['latest_bar'] = bar
        if len(buffer['bars']) > self._buffer_size:
            buffer['bars'] = buffer['bars'][-self._buffer_size:]
        if emit:
            self._notify_callbacks('bar', bar)

    def _notify_callbacks(self, data_type: str, data: Dict[str, Any]) -> None:
        for callback in self.data_callbacks:
            try:
                callback(data_type, data)
            except Exception as e:
                logger.error("Error in data callback: %s", e)

    def _clamp_window(self, start_date: str, end_date: str, interval: str) -> tuple:
        start = pd.to_datetime(start_date, utc=True).tz_convert(None)
        end = pd.to_datetime(end_date, utc=True).tz_convert(None)
        max_lookback = _MAX_LOOKBACK.get(interval)
        if max_lookback is not None:
            earliest = pd.Timestamp.now(tz='UTC').tz_convert(None) - max_lookback
            if start < earliest:
                logger.warning(
                    "Yahoo %s bars only cover ~%s; clamping start from %s to %s",
                    interval,
                    max_lookback,
                    start.date(),
                    earliest.date(),
                )
                start = earliest
        if end < start:
            end = start + pd.Timedelta(days=1)
        return start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d')

    def _download(
        self,
        symbol: str,
        start: Optional[str] = None,
        end: Optional[str] = None,
        period: Optional[str] = None,
        interval: str = '1d',
    ) -> pd.DataFrame:
        import yfinance as yf

        kwargs: Dict[str, Any] = {
            'tickers': symbol,
            'interval': interval,
            'auto_adjust': self.auto_adjust,
            'progress': False,
            'threads': False,
        }
        if period:
            kwargs['period'] = period
        else:
            kwargs['start'] = start
            kwargs['end'] = end
        return yf.download(**kwargs)

    @staticmethod
    def _normalize_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
        if df is None or df.empty:
            return pd.DataFrame(columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])

        out = df.copy()
        if isinstance(out.columns, pd.MultiIndex):
            out.columns = [str(col[0]).strip().lower() for col in out.columns]
        else:
            out.columns = [str(c).strip().lower() for c in out.columns]
        rename = {}
        if 'datetime' in out.columns:
            rename['datetime'] = 'timestamp'
        out = out.rename(columns=rename)

        if 'timestamp' not in out.columns:
            out = out.reset_index()
            time_col = out.columns[0]
            out = out.rename(columns={time_col: 'timestamp'})

        out['timestamp'] = pd.to_datetime(out['timestamp'], utc=True).dt.tz_localize(None)

        required = ['timestamp', 'open', 'high', 'low', 'close', 'volume']
        missing = [c for c in required if c not in out.columns]
        if missing:
            logger.error("Yahoo response missing columns: %s", missing)
            return pd.DataFrame(columns=required)

        out = out[required].dropna()
        out = out.sort_values('timestamp').drop_duplicates(subset=['timestamp'])
        return out.reset_index(drop=True)