Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
87 changes: 87 additions & 0 deletions src/py_alpaca_api/models/snapshot_model.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/py_alpaca_api/stock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
141 changes: 141 additions & 0 deletions src/py_alpaca_api/stock/snapshots.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading