From 77a6362ff23d2c579484190b2b42de6d6715f4bd Mon Sep 17 00:00:00 2001 From: Jeff West Date: Mon, 15 Sep 2025 18:05:39 -0500 Subject: [PATCH] feat: Add comprehensive Market Snapshots API support - Created snapshots.py module with get_snapshot() and get_snapshots() methods - Implemented SnapshotModel and BarModel dataclasses for snapshot data - Added support for latest trade, quote, minute bar, daily bar, and previous daily bar - Integrated snapshots into Stock module - Added comprehensive unit tests (15 test cases) - Added integration tests (10 test cases) - Supports multiple data feeds (iex, sip, otc) - Handles single and multiple symbol requests efficiently - Updated DEVELOPMENT_PLAN.md to mark feature as complete Completes Phase 1 (Critical Features) of v3.0.0 development --- DEVELOPMENT_PLAN.md | 23 +- src/py_alpaca_api/models/snapshot_model.py | 87 ++++ src/py_alpaca_api/stock/__init__.py | 2 + src/py_alpaca_api/stock/snapshots.py | 141 ++++++ .../test_snapshots_integration.py | 217 +++++++++ tests/test_stock/test_snapshots.py | 424 ++++++++++++++++++ 6 files changed, 884 insertions(+), 10 deletions(-) create mode 100644 src/py_alpaca_api/models/snapshot_model.py create mode 100644 src/py_alpaca_api/stock/snapshots.py create mode 100644 tests/test_integration/test_snapshots_integration.py create mode 100644 tests/test_stock/test_snapshots.py diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index e7afaf2..6c0f011 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -93,19 +93,22 @@ main - Handles large datasets efficiently - Proper error handling for invalid symbols -#### 1.3 Market Snapshots ⬜ +#### 1.3 Market Snapshots ✅ **Branch**: `feature/market-snapshots` **Priority**: 🔴 Critical **Estimated Time**: 2 days +**Actual Time**: < 1 day +**Completed**: 2025-01-15 **Tasks**: -- [ ] Create `stock/snapshots.py` module -- [ ] Implement `get_snapshots()` for multiple symbols -- [ ] Implement `get_snapshot()` for single symbol -- [ ] Create `SnapshotModel` dataclass -- [ ] Add latest trade, quote, bar, daily bar, prev daily bar -- [ ] Add comprehensive tests (10+ test cases) -- [ ] Update documentation +- [x] Create `stock/snapshots.py` module +- [x] Implement `get_snapshots()` for multiple symbols +- [x] Implement `get_snapshot()` for single symbol +- [x] Create `SnapshotModel` dataclass +- [x] Create `BarModel` dataclass +- [x] Add latest trade, quote, bar, daily bar, prev daily bar +- [x] Add comprehensive tests (15 unit tests, 10 integration tests) +- [x] Update documentation **Acceptance Criteria**: - Returns complete market snapshot data @@ -285,11 +288,11 @@ main ## 📈 Progress Tracking -### Overall Progress: 🟦 20% Complete +### Overall Progress: 🟦 30% Complete | Phase | Status | Progress | Estimated Completion | |-------|--------|----------|---------------------| -| Phase 1: Critical Features | 🟦 In Progress | 67% | Week 3 | +| Phase 1: Critical Features | ✅ Complete | 100% | Week 1 | | Phase 2: Important Enhancements | ⬜ Not Started | 0% | Week 5 | | Phase 3: Performance & Quality | ⬜ Not Started | 0% | Week 7 | | Phase 4: Advanced Features | ⬜ Not Started | 0% | Week 10 | diff --git a/src/py_alpaca_api/models/snapshot_model.py b/src/py_alpaca_api/models/snapshot_model.py new file mode 100644 index 0000000..fb33253 --- /dev/null +++ b/src/py_alpaca_api/models/snapshot_model.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass + +import pendulum + +from py_alpaca_api.models.quote_model import QuoteModel, quote_class_from_dict +from py_alpaca_api.models.trade_model import TradeModel, trade_class_from_dict + + +@dataclass +class BarModel: + timestamp: str # Store as string for consistency with other models + open: float + high: float + low: float + close: float + volume: int + trade_count: int | None = None + vwap: float | None = None + + +@dataclass +class SnapshotModel: + symbol: str + latest_trade: TradeModel | None = None + latest_quote: QuoteModel | None = None + minute_bar: BarModel | None = None + daily_bar: BarModel | None = None + prev_daily_bar: BarModel | None = None + + +def bar_class_from_dict(data: dict) -> BarModel: + # Parse timestamp + timestamp_str = data.get("t", "") + if timestamp_str: + timestamp = pendulum.parse(timestamp_str, tz="America/New_York") + if isinstance(timestamp, pendulum.DateTime): + timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") + else: + timestamp_str = str(timestamp) + + return BarModel( + timestamp=timestamp_str, + open=float(data.get("o", 0.0)), + high=float(data.get("h", 0.0)), + low=float(data.get("l", 0.0)), + close=float(data.get("c", 0.0)), + volume=int(data.get("v", 0)), + trade_count=int(data["n"]) if "n" in data and data["n"] is not None else None, + vwap=float(data["vw"]) if "vw" in data and data["vw"] is not None else None, + ) + + +def snapshot_class_from_dict(data: dict) -> SnapshotModel: + snapshot_data = {"symbol": data.get("symbol", "")} + + if data.get("latestTrade"): + trade_data = data["latestTrade"] + snapshot_data["latest_trade"] = trade_class_from_dict( + trade_data, data.get("symbol", "") + ) + + if data.get("latestQuote"): + quote_data = data["latestQuote"] + # Map API field names to model field names + quote_dict = { + "symbol": data.get("symbol", ""), + "timestamp": quote_data.get("t", ""), + "ask": quote_data.get("ap", 0.0), + "ask_size": quote_data.get("as", 0), + "bid": quote_data.get("bp", 0.0), + "bid_size": quote_data.get("bs", 0), + } + snapshot_data["latest_quote"] = quote_class_from_dict(quote_dict) + + if data.get("minuteBar"): + bar_data = data["minuteBar"] + snapshot_data["minute_bar"] = bar_class_from_dict(bar_data) + + if data.get("dailyBar"): + bar_data = data["dailyBar"] + snapshot_data["daily_bar"] = bar_class_from_dict(bar_data) + + if data.get("prevDailyBar"): + bar_data = data["prevDailyBar"] + snapshot_data["prev_daily_bar"] = bar_class_from_dict(bar_data) + + return SnapshotModel(**snapshot_data) diff --git a/src/py_alpaca_api/stock/__init__.py b/src/py_alpaca_api/stock/__init__.py index f1ef717..3188f7a 100644 --- a/src/py_alpaca_api/stock/__init__.py +++ b/src/py_alpaca_api/stock/__init__.py @@ -3,6 +3,7 @@ from py_alpaca_api.stock.latest_quote import LatestQuote from py_alpaca_api.stock.predictor import Predictor from py_alpaca_api.stock.screener import Screener +from py_alpaca_api.stock.snapshots import Snapshots from py_alpaca_api.stock.trades import Trades from py_alpaca_api.trading.market import Market @@ -40,4 +41,5 @@ def _initialize_components( ) self.predictor = Predictor(history=self.history, screener=self.screener) self.latest_quote = LatestQuote(headers=headers) + self.snapshots = Snapshots(headers=headers) self.trades = Trades(headers=headers) diff --git a/src/py_alpaca_api/stock/snapshots.py b/src/py_alpaca_api/stock/snapshots.py new file mode 100644 index 0000000..23584b9 --- /dev/null +++ b/src/py_alpaca_api/stock/snapshots.py @@ -0,0 +1,141 @@ +import json + +from py_alpaca_api.exceptions import APIRequestError, ValidationError +from py_alpaca_api.http.requests import Requests +from py_alpaca_api.models.snapshot_model import SnapshotModel, snapshot_class_from_dict + + +class Snapshots: + def __init__(self, headers: dict[str, str]) -> None: + """Initialize the Snapshots class. + + Args: + headers: Dictionary containing authentication headers. + """ + self.headers = headers + self.base_url = "https://data.alpaca.markets/v2/stocks" + + def get_snapshot( + self, + symbol: str, + feed: str = "iex", + ) -> SnapshotModel: + """Get a snapshot of a single stock symbol. + + The snapshot includes the latest trade, latest quote, minute bar, + daily bar, and previous daily bar data. + + Args: + symbol: The stock symbol to get snapshot for. + feed: The data feed to use ("iex", "sip", or "otc"). Defaults to "iex". + + Returns: + A SnapshotModel containing the snapshot data. + + Raises: + ValidationError: If symbol is invalid or feed is invalid. + APIRequestError: If the API request fails. + """ + if not symbol or not isinstance(symbol, str): + raise ValidationError("Symbol is required and must be a string.") + + valid_feeds = ["iex", "sip", "otc"] + if feed not in valid_feeds: + raise ValidationError( + f"Invalid feed. Must be one of: {', '.join(valid_feeds)}" + ) + + symbol = symbol.upper().strip() + + url = f"{self.base_url}/{symbol}/snapshot" + + params: dict[str, str | bool | float | int] = {"feed": feed} + + try: + response = json.loads( + Requests() + .request(method="GET", url=url, headers=self.headers, params=params) + .text + ) + except Exception as e: + raise APIRequestError( + message=f"Failed to get snapshot for {symbol}: {e!s}" + ) from e + + if not response: + raise APIRequestError(message=f"No snapshot data returned for {symbol}") + + response["symbol"] = symbol + return snapshot_class_from_dict(response) + + def get_snapshots( + self, + symbols: list[str] | str, + feed: str = "iex", + ) -> list[SnapshotModel] | dict[str, SnapshotModel]: + """Get snapshots for multiple stock symbols. + + The snapshot includes the latest trade, latest quote, minute bar, + daily bar, and previous daily bar data for each symbol. + + Args: + symbols: A list of stock symbols or comma-separated string of symbols. + feed: The data feed to use ("iex", "sip", or "otc"). Defaults to "iex". + + Returns: + A dictionary mapping symbols to their SnapshotModel objects, or a list + of SnapshotModel objects if only one symbol is provided. + + Raises: + ValidationError: If symbols are invalid or feed is invalid. + APIRequestError: If the API request fails. + """ + if not symbols: + raise ValidationError("Symbols are required.") + + valid_feeds = ["iex", "sip", "otc"] + if feed not in valid_feeds: + raise ValidationError( + f"Invalid feed. Must be one of: {', '.join(valid_feeds)}" + ) + + if isinstance(symbols, str): + symbols_str = symbols.upper().strip() + symbols_list = [s.strip() for s in symbols_str.split(",")] + else: + symbols_list = [s.upper().strip() for s in symbols] + symbols_str = ",".join(symbols_list) + + if not symbols_str: + raise ValidationError("At least one symbol is required.") + + url = f"{self.base_url}/snapshots" + + params: dict[str, str | bool | float | int] = { + "symbols": symbols_str, + "feed": feed, + } + + try: + response = json.loads( + Requests() + .request(method="GET", url=url, headers=self.headers, params=params) + .text + ) + except Exception as e: + raise APIRequestError(message=f"Failed to get snapshots: {e!s}") from e + + if not response: + raise APIRequestError(message="No snapshot data returned") + + # The API returns symbols as top-level keys directly + snapshots = {} + for symbol, data in response.items(): + if isinstance(data, dict): # Ensure it's snapshot data + data["symbol"] = symbol + snapshots[symbol] = snapshot_class_from_dict(data) + + if len(symbols_list) == 1: + return list(snapshots.values()) + + return snapshots diff --git a/tests/test_integration/test_snapshots_integration.py b/tests/test_integration/test_snapshots_integration.py new file mode 100644 index 0000000..3a4790a --- /dev/null +++ b/tests/test_integration/test_snapshots_integration.py @@ -0,0 +1,217 @@ +import os + +import pytest + +from py_alpaca_api import PyAlpacaAPI +from py_alpaca_api.models.snapshot_model import BarModel, SnapshotModel + + +@pytest.mark.skipif( + not os.environ.get("ALPACA_API_KEY") or not os.environ.get("ALPACA_SECRET_KEY"), + reason="API credentials not set", +) +class TestSnapshotsIntegration: + @pytest.fixture + def alpaca(self): + return PyAlpacaAPI( + api_key=os.environ.get("ALPACA_API_KEY"), + api_secret=os.environ.get("ALPACA_SECRET_KEY"), + api_paper=True, + ) + + def test_get_snapshot_single_symbol(self, alpaca): + result = alpaca.stock.snapshots.get_snapshot("AAPL") + + assert isinstance(result, SnapshotModel) + assert result.symbol == "AAPL" + + # At least one of these should be present during market hours + # Some may be None during pre/post market + assert any( + [ + result.latest_trade is not None, + result.latest_quote is not None, + result.minute_bar is not None, + result.daily_bar is not None, + result.prev_daily_bar is not None, + ] + ) + + # If latest_trade exists, validate its structure + if result.latest_trade: + assert result.latest_trade.price > 0 + assert result.latest_trade.size >= 0 + assert result.latest_trade.symbol == "AAPL" + + # If latest_quote exists, validate its structure + if result.latest_quote: + assert result.latest_quote.ask >= 0 + assert result.latest_quote.bid >= 0 + assert result.latest_quote.symbol == "AAPL" + + # If minute_bar exists, validate its structure + if result.minute_bar: + assert isinstance(result.minute_bar, BarModel) + assert result.minute_bar.open > 0 + assert result.minute_bar.high >= result.minute_bar.low + assert result.minute_bar.volume >= 0 + + # If daily_bar exists, validate its structure + if result.daily_bar: + assert isinstance(result.daily_bar, BarModel) + assert result.daily_bar.open > 0 + assert result.daily_bar.high >= result.daily_bar.low + assert result.daily_bar.volume >= 0 + + # If prev_daily_bar exists, validate its structure + if result.prev_daily_bar: + assert isinstance(result.prev_daily_bar, BarModel) + assert result.prev_daily_bar.open > 0 + assert result.prev_daily_bar.high >= result.prev_daily_bar.low + assert result.prev_daily_bar.volume >= 0 + + def test_get_snapshot_with_different_feeds(self, alpaca): + # Test with IEX feed (default) + result_iex = alpaca.stock.snapshots.get_snapshot("AAPL", feed="iex") + assert isinstance(result_iex, SnapshotModel) + assert result_iex.symbol == "AAPL" + + # Test with SIP feed if available (might require subscription) + try: + result_sip = alpaca.stock.snapshots.get_snapshot("AAPL", feed="sip") + assert isinstance(result_sip, SnapshotModel) + assert result_sip.symbol == "AAPL" + except Exception: + # SIP feed might not be available for all accounts + pass + + def test_get_snapshots_multiple_symbols(self, alpaca): + symbols = ["AAPL", "MSFT", "GOOGL"] + result = alpaca.stock.snapshots.get_snapshots(symbols) + + assert isinstance(result, dict) + assert len(result) == 3 + + for symbol in symbols: + assert symbol in result + assert isinstance(result[symbol], SnapshotModel) + assert result[symbol].symbol == symbol + + # Validate at least some data is present + snapshot = result[symbol] + assert any( + [ + snapshot.latest_trade is not None, + snapshot.latest_quote is not None, + snapshot.minute_bar is not None, + snapshot.daily_bar is not None, + snapshot.prev_daily_bar is not None, + ] + ) + + def test_get_snapshots_single_symbol_returns_list(self, alpaca): + result = alpaca.stock.snapshots.get_snapshots("AAPL") + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], SnapshotModel) + assert result[0].symbol == "AAPL" + + def test_get_snapshots_comma_separated_string(self, alpaca): + result = alpaca.stock.snapshots.get_snapshots("AAPL,MSFT,TSLA") + + assert isinstance(result, dict) + assert len(result) == 3 + assert all(symbol in result for symbol in ["AAPL", "MSFT", "TSLA"]) + + def test_get_snapshots_large_batch(self, alpaca): + # Test with a larger batch of symbols + symbols = [ + "AAPL", + "MSFT", + "GOOGL", + "AMZN", + "META", + "TSLA", + "NVDA", + "JPM", + "V", + "JNJ", + ] + result = alpaca.stock.snapshots.get_snapshots(symbols) + + assert isinstance(result, dict) + assert len(result) == 10 + + for symbol in symbols: + assert symbol in result + assert isinstance(result[symbol], SnapshotModel) + assert result[symbol].symbol == symbol + + def test_get_snapshot_with_otc_symbol(self, alpaca): + # Test with an OTC symbol if available + try: + result = alpaca.stock.snapshots.get_snapshot("TCEHY", feed="otc") + assert isinstance(result, SnapshotModel) + assert result.symbol == "TCEHY" + except Exception: + # OTC feed might not be available or symbol might not exist + pass + + def test_snapshot_data_consistency(self, alpaca): + # Get snapshot and verify data consistency + result = alpaca.stock.snapshots.get_snapshot("SPY") + + assert isinstance(result, SnapshotModel) + assert result.symbol == "SPY" + + # If we have both minute and daily bars, check consistency + if result.minute_bar and result.daily_bar: + # Minute bar should be within the daily bar's range + assert result.minute_bar.high <= result.daily_bar.high + assert result.minute_bar.low >= result.daily_bar.low + + # If we have latest trade and minute bar, check consistency + if result.latest_trade and result.minute_bar: + # Latest trade should be within reasonable range of minute bar + # (allowing for some time difference) + assert result.latest_trade.price > 0 + assert result.minute_bar.close > 0 + + def test_get_snapshots_handles_invalid_symbol_gracefully(self, alpaca): + # Mix valid and potentially invalid symbols + symbols = ["AAPL", "INVALID123", "MSFT"] + + try: + result = alpaca.stock.snapshots.get_snapshots(symbols) + # If it doesn't error, check we at least got valid symbols + assert "AAPL" in result or "MSFT" in result + except Exception: + # API might reject the entire request with invalid symbols + # This is acceptable behavior + pass + + def test_snapshot_during_market_hours(self, alpaca): + # This test is most meaningful during market hours + # Get snapshot for a highly liquid symbol + result = alpaca.stock.snapshots.get_snapshot("SPY") + + assert isinstance(result, SnapshotModel) + assert result.symbol == "SPY" + + # During market hours, SPY should have most data available + # Note: This might fail outside market hours + try: + market_info = alpaca.trading.market.get_market_info() + market_open = ( + market_info.is_open if hasattr(market_info, "is_open") else False + ) + except Exception: + market_open = False + + if market_open: + # During market hours, we expect more data to be available + assert result.latest_trade is not None + assert result.latest_quote is not None + # Minute bar might not be immediately available at market open + # Daily bar updates throughout the day diff --git a/tests/test_stock/test_snapshots.py b/tests/test_stock/test_snapshots.py new file mode 100644 index 0000000..1532b4a --- /dev/null +++ b/tests/test_stock/test_snapshots.py @@ -0,0 +1,424 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from py_alpaca_api.exceptions import APIRequestError, ValidationError +from py_alpaca_api.models.snapshot_model import ( + BarModel, + SnapshotModel, + bar_class_from_dict, + snapshot_class_from_dict, +) +from py_alpaca_api.stock.snapshots import Snapshots + + +class TestSnapshots: + @pytest.fixture + def snapshots(self): + headers = { + "APCA-API-KEY-ID": "test_key", + "APCA-API-SECRET-KEY": "test_secret", + } + return Snapshots(headers=headers) + + @pytest.fixture + def mock_snapshot_response(self): + return { + "latestTrade": { + "t": "2025-01-14T10:30:00Z", + "p": 150.25, + "s": 100, + "c": ["@", "F"], + "x": "Q", + "z": "C", + }, + "latestQuote": { + "t": "2025-01-14T10:30:01Z", + "ap": 150.30, + "as": 100, + "bp": 150.20, + "bs": 200, + "ax": "Q", + "bx": "N", + "c": ["R"], + "z": "C", + }, + "minuteBar": { + "t": "2025-01-14T10:30:00Z", + "o": 150.10, + "h": 150.35, + "l": 150.05, + "c": 150.25, + "v": 10000, + "n": 250, + "vw": 150.20, + }, + "dailyBar": { + "t": "2025-01-14T00:00:00Z", + "o": 149.50, + "h": 151.00, + "l": 149.00, + "c": 150.25, + "v": 1000000, + "n": 25000, + "vw": 150.00, + }, + "prevDailyBar": { + "t": "2025-01-13T00:00:00Z", + "o": 148.50, + "h": 150.00, + "l": 148.00, + "c": 149.50, + "v": 1200000, + "n": 28000, + "vw": 149.25, + }, + } + + @pytest.fixture + def mock_snapshots_response(self): + # API returns symbols as top-level keys + return { + "AAPL": { + "latestTrade": { + "t": "2025-01-14T10:30:00Z", + "p": 150.25, + "s": 100, + "c": ["@"], + "x": "Q", + "z": "C", + }, + "latestQuote": { + "t": "2025-01-14T10:30:01Z", + "ap": 150.30, + "as": 100, + "bp": 150.20, + "bs": 200, + "ax": "Q", + "bx": "N", + "c": ["R"], + "z": "C", + }, + "minuteBar": { + "t": "2025-01-14T10:30:00Z", + "o": 150.10, + "h": 150.35, + "l": 150.05, + "c": 150.25, + "v": 10000, + "n": 250, + "vw": 150.20, + }, + "dailyBar": None, + "prevDailyBar": None, + }, + "MSFT": { + "latestTrade": { + "t": "2025-01-14T10:30:00Z", + "p": 380.50, + "s": 50, + "c": [], + "x": "N", + "z": "C", + }, + "latestQuote": { + "t": "2025-01-14T10:30:01Z", + "ap": 380.60, + "as": 50, + "bp": 380.40, + "bs": 100, + "ax": "N", + "bx": "P", + "c": [], + "z": "C", + }, + "minuteBar": { + "t": "2025-01-14T10:30:00Z", + "o": 380.25, + "h": 380.75, + "l": 380.00, + "c": 380.50, + "v": 5000, + "n": 150, + "vw": 380.40, + }, + "dailyBar": { + "t": "2025-01-14T00:00:00Z", + "o": 379.00, + "h": 381.00, + "l": 378.50, + "c": 380.50, + "v": 500000, + "n": 12000, + "vw": 380.00, + }, + "prevDailyBar": { + "t": "2025-01-13T00:00:00Z", + "o": 378.00, + "h": 380.00, + "l": 377.50, + "c": 379.00, + "v": 600000, + "n": 14000, + "vw": 378.75, + }, + }, + } + + def test_get_snapshot_valid_symbol(self, snapshots, mock_snapshot_response): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.text = json.dumps(mock_snapshot_response) + mock_requests.return_value.request.return_value = mock_response + + result = snapshots.get_snapshot("AAPL") + + assert isinstance(result, SnapshotModel) + assert result.symbol == "AAPL" + assert result.latest_trade is not None + assert result.latest_trade.price == 150.25 + assert result.latest_quote is not None + assert result.latest_quote.ask == 150.30 + assert result.minute_bar is not None + assert result.minute_bar.close == 150.25 + assert result.daily_bar is not None + assert result.prev_daily_bar is not None + + def test_get_snapshot_with_feed(self, snapshots, mock_snapshot_response): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.text = json.dumps(mock_snapshot_response) + mock_requests.return_value.request.return_value = mock_response + + snapshots.get_snapshot("AAPL", feed="sip") + + mock_requests.return_value.request.assert_called_once() + call_args = mock_requests.return_value.request.call_args + assert call_args.kwargs["params"]["feed"] == "sip" + + def test_get_snapshot_invalid_symbol(self, snapshots): + with pytest.raises(ValidationError, match="Symbol is required"): + snapshots.get_snapshot("") + + with pytest.raises(ValidationError, match="Symbol is required"): + snapshots.get_snapshot(None) + + def test_get_snapshot_invalid_feed(self, snapshots): + with pytest.raises(ValidationError, match="Invalid feed"): + snapshots.get_snapshot("AAPL", feed="invalid") + + def test_get_snapshot_api_error(self, snapshots): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_requests.return_value.request.side_effect = Exception("API Error") + + with pytest.raises(APIRequestError) as exc_info: + snapshots.get_snapshot("AAPL") + assert "Failed to get snapshot" in str(exc_info.value) + + def test_get_snapshots_multiple_symbols(self, snapshots, mock_snapshots_response): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.text = json.dumps(mock_snapshots_response) + mock_requests.return_value.request.return_value = mock_response + + result = snapshots.get_snapshots(["AAPL", "MSFT"]) + + assert isinstance(result, dict) + assert len(result) == 2 + assert "AAPL" in result + assert "MSFT" in result + assert isinstance(result["AAPL"], SnapshotModel) + assert isinstance(result["MSFT"], SnapshotModel) + assert result["AAPL"].symbol == "AAPL" + assert result["MSFT"].symbol == "MSFT" + + def test_get_snapshots_single_symbol_returns_list( + self, snapshots, mock_snapshots_response + ): + mock_single_response = {"AAPL": mock_snapshots_response["AAPL"]} + + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.text = json.dumps(mock_single_response) + mock_requests.return_value.request.return_value = mock_response + + result = snapshots.get_snapshots("AAPL") + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], SnapshotModel) + assert result[0].symbol == "AAPL" + + def test_get_snapshots_comma_separated_string( + self, snapshots, mock_snapshots_response + ): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.text = json.dumps(mock_snapshots_response) + mock_requests.return_value.request.return_value = mock_response + + result = snapshots.get_snapshots("AAPL,MSFT") + + assert isinstance(result, dict) + assert len(result) == 2 + assert "AAPL" in result + assert "MSFT" in result + + def test_get_snapshots_invalid_symbols(self, snapshots): + with pytest.raises(ValidationError, match="Symbols are required"): + snapshots.get_snapshots([]) + + with pytest.raises(ValidationError, match="Symbols are required"): + snapshots.get_snapshots(None) + + def test_get_snapshots_invalid_feed(self, snapshots): + with pytest.raises(ValidationError, match="Invalid feed"): + snapshots.get_snapshots(["AAPL"], feed="invalid") + + def test_get_snapshots_api_error(self, snapshots): + with patch("py_alpaca_api.stock.snapshots.Requests") as mock_requests: + mock_requests.return_value.request.side_effect = Exception("API Error") + + with pytest.raises(APIRequestError) as exc_info: + snapshots.get_snapshots(["AAPL", "MSFT"]) + assert "Failed to get snapshots" in str(exc_info.value) + + +class TestSnapshotModels: + def test_bar_class_from_dict(self): + # Use API field names as they come from the API + bar_data = { + "t": "2025-01-14T10:30:00Z", + "o": 150.10, + "h": 150.35, + "l": 150.05, + "c": 150.25, + "v": 10000, + "n": 250, + "vw": 150.20, + } + + bar = bar_class_from_dict(bar_data) + + assert isinstance(bar, BarModel) + assert bar.open == 150.10 + assert bar.high == 150.35 + assert bar.low == 150.05 + assert bar.close == 150.25 + assert bar.volume == 10000 + assert bar.trade_count == 250 + assert bar.vwap == 150.20 + + def test_bar_class_from_dict_minimal(self): + # Use API field names, minimal data + bar_data = { + "t": "2025-01-14T10:30:00Z", + "o": 150.10, + "h": 150.35, + "l": 150.05, + "c": 150.25, + "v": 10000, + } + + bar = bar_class_from_dict(bar_data) + + assert isinstance(bar, BarModel) + assert bar.trade_count is None + assert bar.vwap is None + + def test_snapshot_class_from_dict_full(self): + snapshot_data = { + "symbol": "AAPL", + "latestTrade": { + "t": "2025-01-14T10:30:00Z", + "p": 150.25, + "s": 100, + "c": ["@"], + "x": "Q", + "z": "C", + }, + "latestQuote": { + "t": "2025-01-14T10:30:01Z", + "ap": 150.30, + "as": 100, + "bp": 150.20, + "bs": 200, + "ax": "Q", + "bx": "N", + "c": ["R"], + "z": "C", + }, + "minuteBar": { + "t": "2025-01-14T10:30:00Z", + "o": 150.10, + "h": 150.35, + "l": 150.05, + "c": 150.25, + "v": 10000, + "n": 250, + "vw": 150.20, + }, + "dailyBar": { + "t": "2025-01-14T00:00:00Z", + "o": 149.50, + "h": 151.00, + "l": 149.00, + "c": 150.25, + "v": 1000000, + "n": 25000, + "vw": 150.00, + }, + "prevDailyBar": { + "t": "2025-01-13T00:00:00Z", + "o": 148.50, + "h": 150.00, + "l": 148.00, + "c": 149.50, + "v": 1200000, + "n": 28000, + "vw": 149.25, + }, + } + + snapshot = snapshot_class_from_dict(snapshot_data) + + assert isinstance(snapshot, SnapshotModel) + assert snapshot.symbol == "AAPL" + assert snapshot.latest_trade is not None + assert snapshot.latest_trade.price == 150.25 + assert snapshot.latest_quote is not None + assert snapshot.latest_quote.ask == 150.30 + assert snapshot.minute_bar is not None + assert snapshot.minute_bar.close == 150.25 + assert snapshot.daily_bar is not None + assert snapshot.daily_bar.close == 150.25 + assert snapshot.prev_daily_bar is not None + assert snapshot.prev_daily_bar.close == 149.50 + + def test_snapshot_class_from_dict_partial(self): + snapshot_data = { + "symbol": "AAPL", + "latestTrade": { + "t": "2025-01-14T10:30:00Z", + "p": 150.25, + "s": 100, + "c": [], + "x": "Q", + "z": "C", + }, + "latestQuote": None, + "minuteBar": None, + "dailyBar": None, + "prevDailyBar": None, + } + + snapshot = snapshot_class_from_dict(snapshot_data) + + assert isinstance(snapshot, SnapshotModel) + assert snapshot.symbol == "AAPL" + assert snapshot.latest_trade is not None + assert snapshot.latest_quote is None + assert snapshot.minute_bar is None + assert snapshot.daily_bar is None + assert snapshot.prev_daily_bar is None