use serde::{Deserialize, Serialize};
use crate::adapters::polygon::build_client;
use crate::error::{FinanceError, Result};
use crate::models::quote::snapshot::{AssetClass, MarketSnapshot};
const MAX_SYMBOLS: usize = 250;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UnifiedSnapshotResponseDTO {
pub results: Option<Vec<UnifiedSnapshotDTO>>,
pub next_url: Option<String>,
pub request_id: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UnifiedSnapshotDTO {
pub ticker: Option<String>,
pub name: Option<String>,
#[serde(rename = "type")]
pub asset_type: Option<String>,
pub market_status: Option<String>,
pub last_trade: Option<UnifiedTradeDTO>,
pub last_quote: Option<UnifiedQuoteDTO>,
pub session: Option<UnifiedSessionDTO>,
pub error: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UnifiedTradeDTO {
pub price: Option<f64>,
pub size: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UnifiedQuoteDTO {
pub bid: Option<f64>,
pub ask: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UnifiedSessionDTO {
pub open: Option<f64>,
pub high: Option<f64>,
pub low: Option<f64>,
pub close: Option<f64>,
pub previous_close: Option<f64>,
pub volume: Option<f64>,
pub change: Option<f64>,
pub change_percent: Option<f64>,
}
pub async fn unified_snapshot(tickers: &[&str]) -> Result<UnifiedSnapshotResponseDTO> {
let joined = tickers.join(",");
let limit = tickers.len().to_string();
build_client()?
.get_as(
"/v3/snapshot",
&[("ticker.any_of", &joined), ("limit", &limit)],
"unified_snapshot",
"unified snapshot response",
)
.await
}
fn parse_asset_class(raw: Option<&str>) -> Option<AssetClass> {
match raw? {
"stocks" => Some(AssetClass::Stocks),
"options" => Some(AssetClass::Options),
"fx" => Some(AssetClass::Fx),
"crypto" => Some(AssetClass::Crypto),
"indices" => Some(AssetClass::Indices),
_ => None,
}
}
pub(crate) fn to_market_snapshot(dto: UnifiedSnapshotDTO) -> MarketSnapshot {
let session = dto.session;
MarketSnapshot {
symbol: dto.ticker,
name: dto.name,
asset_class: parse_asset_class(dto.asset_type.as_deref()),
market_status: dto.market_status,
last_price: dto.last_trade.and_then(|t| t.price),
bid: dto.last_quote.as_ref().and_then(|q| q.bid),
ask: dto.last_quote.and_then(|q| q.ask),
open: session.as_ref().and_then(|s| s.open),
high: session.as_ref().and_then(|s| s.high),
low: session.as_ref().and_then(|s| s.low),
close: session.as_ref().and_then(|s| s.close),
previous_close: session.as_ref().and_then(|s| s.previous_close),
volume: session.as_ref().and_then(|s| s.volume),
change: session.as_ref().and_then(|s| s.change),
change_percent: session.and_then(|s| s.change_percent),
error: dto.error,
message: dto.message,
}
}
pub async fn fetch_unified_snapshot_response(symbols: &[&str]) -> Result<Vec<MarketSnapshot>> {
if symbols.is_empty() {
return Ok(Vec::new());
}
if symbols.len() > MAX_SYMBOLS {
return Err(FinanceError::InvalidParameter {
param: "symbols".to_string(),
reason: format!(
"Polygon's unified snapshot accepts at most {MAX_SYMBOLS} symbols per request, got {}",
symbols.len()
),
});
}
let resp = unified_snapshot(symbols).await?;
Ok(resp
.results
.unwrap_or_default()
.into_iter()
.map(to_market_snapshot)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_a_stock_row_across_trade_quote_and_session() {
let dto: UnifiedSnapshotDTO = serde_json::from_value(serde_json::json!({
"ticker": "AAPL",
"name": "Apple Inc.",
"type": "stocks",
"market_status": "closed",
"last_trade": { "price": 227.5, "size": 2.0 },
"last_quote": { "bid": 227.4, "ask": 227.6 },
"session": {
"open": 225.0,
"high": 228.0,
"low": 224.5,
"close": 227.5,
"previous_close": 226.0,
"volume": 51_000_000.0,
"change": 1.5,
"change_percent": 0.66
}
}))
.unwrap();
let out = to_market_snapshot(dto);
assert_eq!(out.symbol.as_deref(), Some("AAPL"));
assert_eq!(out.asset_class, Some(AssetClass::Stocks));
assert_eq!(out.last_price, Some(227.5));
assert_eq!(out.bid, Some(227.4));
assert_eq!(out.ask, Some(227.6));
assert_eq!(out.previous_close, Some(226.0));
assert_eq!(out.change_percent, Some(0.66));
assert!(out.error.is_none());
}
#[test]
fn preserves_per_ticker_errors() {
let dto: UnifiedSnapshotDTO = serde_json::from_value(serde_json::json!({
"ticker": "TSLAAPL",
"error": "NOT_FOUND",
"message": "Ticker not found."
}))
.unwrap();
let out = to_market_snapshot(dto);
assert_eq!(out.symbol.as_deref(), Some("TSLAAPL"));
assert_eq!(out.error.as_deref(), Some("NOT_FOUND"));
assert_eq!(out.message.as_deref(), Some("Ticker not found."));
assert!(out.last_price.is_none());
}
#[test]
fn parses_every_asset_class_and_rejects_unknown() {
assert_eq!(parse_asset_class(Some("stocks")), Some(AssetClass::Stocks));
assert_eq!(
parse_asset_class(Some("options")),
Some(AssetClass::Options)
);
assert_eq!(parse_asset_class(Some("fx")), Some(AssetClass::Fx));
assert_eq!(parse_asset_class(Some("crypto")), Some(AssetClass::Crypto));
assert_eq!(
parse_asset_class(Some("indices")),
Some(AssetClass::Indices)
);
assert_eq!(parse_asset_class(Some("bonds")), None);
assert_eq!(parse_asset_class(None), None);
}
#[tokio::test]
async fn rejects_batches_over_polygons_cap_before_the_request() {
let symbols: Vec<String> = (0..MAX_SYMBOLS + 1).map(|i| format!("T{i}")).collect();
let refs: Vec<&str> = symbols.iter().map(String::as_str).collect();
let err = fetch_unified_snapshot_response(&refs).await.unwrap_err();
assert!(
matches!(err, FinanceError::InvalidParameter { ref param, .. } if param == "symbols"),
"got {err:?}"
);
}
#[tokio::test]
async fn empty_request_short_circuits_without_a_call() {
assert!(
fetch_unified_snapshot_response(&[])
.await
.unwrap()
.is_empty()
);
}
}