use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc, Weekday};
use futures_util::StreamExt;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::{ExpirationDate, OptionStyle};
use tokio_util::sync::CancellationToken;
use alpaca_http::{
AlpacaError, AlpacaHttpClient, Credentials, Environment, OptionContractParams, OptionType,
};
use alpaca_websocket::messages::SubscribeMessage;
use alpaca_websocket::{AlpacaWebSocketClient, DataFeed, MarketDataEvent};
use super::{
AuthKind, ChainCapability, ChainPollCapability, GreeksCapability, MarketUpdateSink,
OptionStreamCapability, Provider, ProviderCapabilities, SendState, SubscriptionHandle,
SubscriptionRequest, UnderlyingRef,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSnapshot, ChainSource, ContractSpecFingerprint, ExerciseStyle,
ExpirySource, GreeksOrigin, GreeksRow, Instrument, InstrumentKey, MarketUpdate, ProviderId,
QuoteUpdate, SettlementStyle, StreamHealth,
};
use crate::config::{EnvSource, Secret, require_credentials};
use crate::error::{NormalizeKind, ProviderError, TransportDetail, TransportKind};
const ALPACA_ID: &str = "alpaca";
const CREDENTIAL_KEYS: [&str; 2] = ["api_key", "api_secret"];
const ENVIRONMENT_VAR: &str = "CHAINVIEW_ALPACA_ENVIRONMENT";
const REFRESH_HINT_SECS: u32 = 5;
const QUOTE_CURRENCY: &str = "USD";
const DEFAULT_SHARES_PER_CONTRACT: u32 = 100;
const MAX_SYMBOLS_PER_BATCH: usize = 100;
const MAX_DISCOVERY_PAGES: usize = 64;
const MAX_CONTRACTS: usize = 8_192;
const DISCOVERY_PAGE_CAP: &str = "alpaca discovery page cap";
const DISCOVERY_CONTRACT_CAP: &str = "alpaca discovery contract cap";
const WS_CONNECT_RETRIES: u32 = 3;
const SIZE_EXACT_ENVELOPE: u64 = 1 << 53;
const BACKOFF_BASE_MS: f64 = 250.0;
const BACKOFF_MAX_MS: f64 = 30_000.0;
const JITTER_MAGNITUDE: f64 = 0.2;
const BACKOFF_MAX_SHIFT: u32 = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum AlpacaEnvironment {
#[default]
Paper,
Live,
}
impl AlpacaEnvironment {
fn from_value(value: &str) -> Self {
if value.trim().eq_ignore_ascii_case("live") {
Self::Live
} else {
Self::Paper
}
}
fn to_upstream(self) -> Environment {
match self {
Self::Paper => Environment::Paper,
Self::Live => Environment::Live,
}
}
}
#[derive(Clone)]
pub(crate) struct AlpacaAdapter {
id: ProviderId,
api_key: Secret,
api_secret: Secret,
environment: AlpacaEnvironment,
#[cfg(test)]
ws_url: Option<String>,
}
impl AlpacaAdapter {
pub(crate) fn from_env(env: &dyn EnvSource) -> Result<Self, crate::error::ConfigError> {
let id = alpaca_provider_id();
let creds = require_credentials(env, &id, &CREDENTIAL_KEYS)?;
let api_key = creds
.get("API_KEY")
.cloned()
.ok_or_else(|| crate::error::ConfigError::MissingCredential(id.clone()))?;
let api_secret = creds
.get("API_SECRET")
.cloned()
.ok_or_else(|| crate::error::ConfigError::MissingCredential(id.clone()))?;
let environment = env
.get(ENVIRONMENT_VAR)
.map(|value| AlpacaEnvironment::from_value(&value))
.unwrap_or_default();
Ok(Self {
id,
api_key,
api_secret,
environment,
#[cfg(test)]
ws_url: None,
})
}
#[cfg(test)]
fn with_ws_url(mut self, url: String) -> Self {
self.ws_url = Some(url);
self
}
fn credentials(&self) -> Credentials {
Credentials::new(
self.api_key.expose().to_owned(),
self.api_secret.expose().to_owned(),
)
}
fn http_client(&self) -> Result<AlpacaHttpClient, ProviderError> {
AlpacaHttpClient::new(self.credentials(), self.environment.to_upstream())
.map_err(alpaca_error)
}
fn ws_client(&self) -> AlpacaWebSocketClient {
#[cfg(test)]
if let Some(url) = &self.ws_url {
return AlpacaWebSocketClient::with_url(
self.credentials(),
self.environment.to_upstream(),
url.clone(),
);
}
AlpacaWebSocketClient::with_feed(
self.credentials(),
self.environment.to_upstream(),
DataFeed::Iex,
)
}
}
#[async_trait]
impl Provider for AlpacaAdapter {
fn id(&self) -> ProviderId {
self.id.clone()
}
fn capabilities(&self) -> ProviderCapabilities {
alpaca_capabilities()
}
async fn discover(&self) -> Result<Vec<UnderlyingRef>, ProviderError> {
Err(ProviderError::Unsupported("underlying discovery"))
}
async fn fetch_chain(
&self,
underlying: &str,
expiration: &ExpirationDate,
) -> Result<ChainFetch, ProviderError> {
let source = LiveDataSource {
client: self.http_client()?,
};
let composed = compose_chain(&source, underlying, expiration, &self.id, now_utc()).await?;
Ok(composed.fetch)
}
async fn subscribe(
&self,
req: SubscriptionRequest,
sink: MarketUpdateSink,
) -> Result<SubscriptionHandle, ProviderError> {
let transport = LiveTransport::new(self.clone());
let id = self.id.clone();
let SubscriptionRequest {
underlying,
expiration_utc,
instruments: _instruments,
cancel,
} = req;
let loop_cancel = cancel.clone();
let handle = tokio::spawn(run_reconnect_loop(
transport,
id,
underlying,
expiration_utc,
sink,
loop_cancel,
));
Ok(SubscriptionHandle::spawned(cancel, handle))
}
}
fn alpaca_provider_id() -> ProviderId {
match ProviderId::new(ALPACA_ID) {
Ok(id) => id,
Err(_) => unreachable!("`alpaca` is a valid, reserved provider id literal"),
}
}
#[must_use]
pub(crate) fn alpaca_capabilities() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Native)
.depth(false)
.greeks(GreeksCapability::Provided)
.option_stream(OptionStreamCapability::None)
.underlying_stream(false)
.chain_poll(ChainPollCapability::Poll {
interval_hint_secs: REFRESH_HINT_SECS,
})
.trades_tape(false)
.auth(AuthKind::KeySecret)
.build()
}
fn expiry_to_utc(date_str: &str) -> Result<DateTime<Utc>, NormalizeKind> {
let date = parse_ymd(date_str)?;
let offset_hours = if is_us_eastern_dst(date) { 4 } else { 5 };
let local_close = date
.and_hms_opt(16, 0, 0)
.ok_or(NormalizeKind::UnparseableExpiry)?;
let utc_naive = local_close
.checked_add_signed(TimeDelta::hours(offset_hours))
.ok_or(NormalizeKind::UnparseableExpiry)?;
Ok(DateTime::<Utc>::from_naive_utc_and_offset(utc_naive, Utc))
}
fn parse_ymd(s: &str) -> Result<NaiveDate, NormalizeKind> {
let mut parts = s.split('-');
let year = parts
.next()
.and_then(|value| value.parse::<i32>().ok())
.ok_or(NormalizeKind::UnparseableExpiry)?;
let month = parts
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or(NormalizeKind::UnparseableExpiry)?;
let day = parts
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or(NormalizeKind::UnparseableExpiry)?;
if parts.next().is_some() {
return Err(NormalizeKind::UnparseableExpiry);
}
NaiveDate::from_ymd_opt(year, month, day).ok_or(NormalizeKind::UnparseableExpiry)
}
fn format_ymd(date: NaiveDate) -> String {
format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day())
}
fn is_us_eastern_dst(date: NaiveDate) -> bool {
let year = date.year();
match (
nth_weekday_of_month(year, 3, Weekday::Sun, 2),
nth_weekday_of_month(year, 11, Weekday::Sun, 1),
) {
(Some(start), Some(end)) => date >= start && date < end,
_ => false,
}
}
fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option<NaiveDate> {
let first = NaiveDate::from_ymd_opt(year, month, 1)?;
let first_dow = first.weekday().num_days_from_sunday();
let target_dow = weekday.num_days_from_sunday();
let offset = (target_dow + 7 - first_dow) % 7;
let day = 1u32
.checked_add(offset)?
.checked_add(n.checked_sub(1)?.checked_mul(7)?)?;
NaiveDate::from_ymd_opt(year, month, day)
}
fn positive_or_drop(value: f64) -> Option<Positive> {
Positive::new(value).ok()
}
fn iv_or_drop(value: f64) -> Option<Positive> {
if !value.is_finite() {
return None;
}
Positive::new(value).ok()
}
fn greek_or_drop(value: Option<f64>) -> Option<Decimal> {
let raw = value?;
if !raw.is_finite() {
return None;
}
Decimal::try_from(raw).ok()
}
#[allow(clippy::cast_precision_loss)]
fn size_to_positive(size: u64) -> Option<Positive> {
if size >= SIZE_EXACT_ENVELOPE {
return None;
}
Positive::new(size as f64).ok()
}
fn strike_positive(value: &str) -> Result<Positive, NormalizeKind> {
let decimal = value
.trim()
.parse::<Decimal>()
.map_err(|_| NormalizeKind::OutOfRange("strike"))?;
let strike = Positive::new_decimal(decimal).map_err(|_| NormalizeKind::OutOfRange("strike"))?;
if strike == Positive::ZERO {
return Err(NormalizeKind::OutOfRange("strike"));
}
Ok(strike)
}
fn multiplier_of(size: Option<&str>) -> u32 {
size.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value >= 1)
.unwrap_or(DEFAULT_SHARES_PER_CONTRACT)
}
fn open_interest_of(value: Option<&str>) -> Option<u64> {
value.and_then(|raw| raw.trim().parse::<u64>().ok())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct NormalizedQuote {
bid: Option<Positive>,
ask: Option<Positive>,
}
fn normalize_quote(bid: Option<f64>, ask: Option<f64>) -> Result<NormalizedQuote, NormalizeKind> {
let bid = bid.and_then(positive_or_drop);
let ask = ask.and_then(positive_or_drop);
if let (Some(bid_value), Some(ask_value)) = (bid, ask)
&& ask_value < bid_value
{
return Err(NormalizeKind::OutOfRange("ask"));
}
Ok(NormalizedQuote { bid, ask })
}
fn alpaca_fingerprint(
root_symbol: &str,
multiplier: u32,
exercise: ExerciseStyle,
) -> ContractSpecFingerprint {
ContractSpecFingerprint {
contract_multiplier: multiplier,
settlement: SettlementStyle::Physical,
exercise,
quote_currency: QUOTE_CURRENCY.to_owned(),
venue_product_code: root_symbol.to_owned(),
}
}
#[derive(Debug, Clone)]
struct RawContract {
symbol: String,
underlying: String,
expiration_date: String,
strike_price: String,
style: OptionStyle,
exercise: ExerciseStyle,
root_symbol: String,
size: Option<String>,
open_interest: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct RawSnapshot {
bid: Option<f64>,
ask: Option<f64>,
last: Option<f64>,
bid_size: Option<u64>,
ask_size: Option<u64>,
quote_time: Option<DateTime<Utc>>,
delta: Option<f64>,
gamma: Option<f64>,
theta: Option<f64>,
vega: Option<f64>,
rho: Option<f64>,
iv: Option<f64>,
}
fn style_of(option_type: OptionType) -> OptionStyle {
match option_type {
OptionType::Call => OptionStyle::Call,
OptionType::Put => OptionStyle::Put,
}
}
fn exercise_of(style: alpaca_http::OptionStyle) -> ExerciseStyle {
match style {
alpaca_http::OptionStyle::American => ExerciseStyle::American,
alpaca_http::OptionStyle::European => ExerciseStyle::European,
}
}
fn map_contract(contract: alpaca_http::OptionContract) -> RawContract {
RawContract {
symbol: contract.symbol,
underlying: contract.underlying_symbol.to_ascii_uppercase(),
expiration_date: contract.expiration_date,
strike_price: contract.strike_price,
style: style_of(contract.option_type),
exercise: exercise_of(contract.style),
root_symbol: contract.root_symbol,
size: contract.size,
open_interest: contract.open_interest,
}
}
fn map_snapshot(snapshot: alpaca_http::OptionSnapshot) -> RawSnapshot {
let (bid, ask, bid_size, ask_size, quote_time) = match snapshot.latest_quote {
Some(quote) => (
Some(quote.bid_price),
Some(quote.ask_price),
Some(quote.bid_size),
Some(quote.ask_size),
Some(quote.timestamp),
),
None => (None, None, None, None, None),
};
let last = snapshot.latest_trade.map(|trade| trade.price);
let (delta, gamma, theta, vega, rho) = match snapshot.greeks {
Some(greeks) => (
greeks.delta,
greeks.gamma,
greeks.theta,
greeks.vega,
greeks.rho,
),
None => (None, None, None, None, None),
};
RawSnapshot {
bid,
ask,
last,
bid_size,
ask_size,
quote_time,
delta,
gamma,
theta,
vega,
rho,
iv: snapshot.implied_volatility,
}
}
#[derive(Debug, Clone, Default)]
struct ContractPage {
contracts: Vec<RawContract>,
next_page_token: Option<String>,
}
#[async_trait]
trait ChainDataSource: Send + Sync {
async fn discover_page(
&self,
underlying: &str,
expiration_date: &str,
page_token: Option<String>,
) -> Result<ContractPage, ProviderError>;
async fn hydrate_batch(
&self,
symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError>;
}
struct LiveDataSource {
client: AlpacaHttpClient,
}
#[async_trait]
impl ChainDataSource for LiveDataSource {
async fn discover_page(
&self,
underlying: &str,
expiration_date: &str,
page_token: Option<String>,
) -> Result<ContractPage, ProviderError> {
let params = OptionContractParams {
underlying_symbol: Some(underlying.to_owned()),
expiration_date: Some(expiration_date.to_owned()),
page_token,
..OptionContractParams::default()
};
let response = self
.client
.get_option_contracts(¶ms)
.await
.map_err(alpaca_error)?;
let contracts = response
.option_contracts
.into_iter()
.map(map_contract)
.collect();
Ok(ContractPage {
contracts,
next_page_token: response.next_page_token,
})
}
async fn hydrate_batch(
&self,
symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
if symbols.is_empty() {
return Ok(HashMap::new());
}
let joined = symbols.join(",");
let response = self
.client
.get_option_snapshots(&joined)
.await
.map_err(alpaca_error)?;
Ok(response
.snapshots
.into_iter()
.map(|(symbol, snapshot)| (symbol, map_snapshot(snapshot)))
.collect())
}
}
#[derive(Debug, Clone)]
struct ComposedChain {
fetch: ChainFetch,
overlays: Vec<MarketUpdate>,
}
#[derive(Debug, Clone)]
struct NormalizedLeg {
key: InstrumentKey,
native_symbol: String,
spec: ContractSpecFingerprint,
style: OptionStyle,
bid: Option<Positive>,
ask: Option<Positive>,
last: Option<Positive>,
bid_size: Option<Positive>,
ask_size: Option<Positive>,
quote_time: Option<DateTime<Utc>>,
delta: Option<Decimal>,
gamma: Option<Decimal>,
theta: Option<Decimal>,
vega: Option<Decimal>,
rho: Option<Decimal>,
iv: Option<Positive>,
open_interest: Option<u64>,
}
fn normalize_leg(
contract: &RawContract,
expiration_utc: DateTime<Utc>,
snapshot: Option<&RawSnapshot>,
) -> Option<NormalizedLeg> {
let strike = strike_positive(&contract.strike_price).ok()?;
let multiplier = multiplier_of(contract.size.as_deref());
let spec = alpaca_fingerprint(&contract.root_symbol, multiplier, contract.exercise);
let key = InstrumentKey {
underlying: contract.underlying.clone(),
expiration_utc,
strike,
style: contract.style,
};
let snapshot = snapshot.cloned().unwrap_or_default();
let quote = normalize_quote(snapshot.bid, snapshot.ask).unwrap_or_default();
Some(NormalizedLeg {
key,
native_symbol: contract.symbol.clone(),
spec,
style: contract.style,
bid: quote.bid,
ask: quote.ask,
last: snapshot.last.and_then(positive_or_drop),
bid_size: snapshot.bid_size.and_then(size_to_positive),
ask_size: snapshot.ask_size.and_then(size_to_positive),
quote_time: snapshot.quote_time,
delta: greek_or_drop(snapshot.delta),
gamma: greek_or_drop(snapshot.gamma),
theta: greek_or_drop(snapshot.theta),
vega: greek_or_drop(snapshot.vega),
rho: greek_or_drop(snapshot.rho),
iv: snapshot.iv.and_then(iv_or_drop),
open_interest: open_interest_of(contract.open_interest.as_deref()),
})
}
fn snapshot_greeks_row(
instrument: &Instrument,
leg: &NormalizedLeg,
received: DateTime<Utc>,
) -> GreeksRow {
GreeksRow {
instrument: instrument.clone(),
iv: leg.iv,
delta: leg.delta,
gamma: leg.gamma,
theta: leg.theta,
vega: leg.vega,
rho: leg.rho,
origin: GreeksOrigin::Provider,
event_time: leg.quote_time,
received_time: received,
}
}
fn snapshot_quote(
instrument: &Instrument,
leg: &NormalizedLeg,
received: DateTime<Utc>,
) -> QuoteUpdate {
QuoteUpdate {
instrument: instrument.clone(),
bid: leg.bid,
ask: leg.ask,
last: leg.last,
bid_size: leg.bid_size,
ask_size: leg.ask_size,
event_time: leg.quote_time,
received_time: received,
}
}
fn has_quote(leg: &NormalizedLeg) -> bool {
leg.bid.is_some() || leg.ask.is_some() || leg.last.is_some()
}
fn has_greeks(leg: &NormalizedLeg) -> bool {
leg.iv.is_some()
|| leg.delta.is_some()
|| leg.gamma.is_some()
|| leg.theta.is_some()
|| leg.vega.is_some()
|| leg.rho.is_some()
}
#[derive(Debug, Default)]
struct StrikePair<'a> {
call: Option<&'a NormalizedLeg>,
put: Option<&'a NormalizedLeg>,
}
async fn compose_chain<S: ChainDataSource + ?Sized>(
source: &S,
underlying: &str,
expiration: &ExpirationDate,
provider: &ProviderId,
received: DateTime<Utc>,
) -> Result<ComposedChain, ProviderError> {
let symbol = underlying.to_ascii_uppercase();
let target = expiration
.get_date()
.map_err(|_| ProviderError::Normalize {
kind: NormalizeKind::UnparseableExpiry,
})?;
let expiration_date = format_ymd(target.date_naive());
let expiration_utc =
expiry_to_utc(&expiration_date).map_err(|kind| ProviderError::Normalize { kind })?;
let contracts = discover_contracts(source, &symbol, &expiration_date).await?;
if contracts.is_empty() {
return Err(ProviderError::NoChain {
underlying: symbol,
expiration: expiration_utc.to_rfc3339(),
});
}
let occ_symbols: Vec<String> = contracts.iter().map(|c| c.symbol.clone()).collect();
let snapshots = hydrate_symbols(source, &occ_symbols).await?;
assemble_composed(
&symbol,
expiration_utc,
&contracts,
&snapshots,
provider,
received,
)
}
async fn discover_contracts<S: ChainDataSource + ?Sized>(
source: &S,
underlying: &str,
expiration_date: &str,
) -> Result<Vec<RawContract>, ProviderError> {
let mut all = Vec::new();
let mut page_token: Option<String> = None;
for _ in 0..MAX_DISCOVERY_PAGES {
let page = source
.discover_page(underlying, expiration_date, page_token.clone())
.await?;
for contract in page.contracts {
if contract.expiration_date != expiration_date {
continue;
}
if all.len() >= MAX_CONTRACTS {
return Err(ProviderError::Normalize {
kind: NormalizeKind::LimitExceeded(DISCOVERY_CONTRACT_CAP),
});
}
all.push(contract);
}
match page.next_page_token {
Some(token) if !token.is_empty() => page_token = Some(token),
_ => return Ok(all),
}
}
Err(ProviderError::Normalize {
kind: NormalizeKind::LimitExceeded(DISCOVERY_PAGE_CAP),
})
}
async fn hydrate_symbols<S: ChainDataSource + ?Sized>(
source: &S,
symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
let mut merged: HashMap<String, RawSnapshot> = HashMap::with_capacity(symbols.len());
for batch in symbols.chunks(MAX_SYMBOLS_PER_BATCH) {
let snapshots = source.hydrate_batch(batch).await?;
for (symbol, snapshot) in snapshots {
let _ = merged.insert(symbol, snapshot);
}
}
Ok(merged)
}
fn assemble_composed(
underlying: &str,
expiration_utc: DateTime<Utc>,
contracts: &[RawContract],
snapshots: &HashMap<String, RawSnapshot>,
provider: &ProviderId,
received: DateTime<Utc>,
) -> Result<ComposedChain, ProviderError> {
let legs: Vec<NormalizedLeg> = contracts
.iter()
.filter_map(|contract| {
normalize_leg(contract, expiration_utc, snapshots.get(&contract.symbol))
})
.collect();
if legs.is_empty() {
return Err(ProviderError::NoChain {
underlying: underlying.to_owned(),
expiration: expiration_utc.to_rfc3339(),
});
}
let mut aliases = AliasCatalog::new();
let mut overlays: Vec<MarketUpdate> = Vec::new();
for leg in &legs {
let instrument = Instrument {
key: leg.key.clone(),
provider: provider.clone(),
native_symbol: leg.native_symbol.clone(),
stream_symbol: None,
spec: leg.spec.clone(),
};
if has_quote(leg) {
overlays.push(MarketUpdate::Quote(snapshot_quote(
&instrument,
leg,
received,
)));
}
if has_greeks(leg) {
overlays.push(MarketUpdate::Greeks(snapshot_greeks_row(
&instrument,
leg,
received,
)));
}
aliases.insert(instrument);
}
let mut by_strike: BTreeMap<Positive, StrikePair<'_>> = BTreeMap::new();
for leg in &legs {
let entry = by_strike.entry(leg.key.strike).or_default();
match leg.style {
OptionStyle::Call => entry.call = Some(leg),
OptionStyle::Put => entry.put = Some(leg),
}
}
let spot = median_strike(&by_strike);
let mut chain = OptionChain::new(underlying, spot, expiration_utc.to_rfc3339(), None, None);
for (strike, pair) in &by_strike {
let iv = pair
.call
.and_then(|leg| leg.iv)
.or_else(|| pair.put.and_then(|leg| leg.iv))
.unwrap_or(Positive::ZERO);
chain.add_option(
*strike,
pair.call.and_then(|leg| leg.bid),
pair.call.and_then(|leg| leg.ask),
pair.put.and_then(|leg| leg.bid),
pair.put.and_then(|leg| leg.ask),
iv,
pair.call.and_then(|leg| leg.delta),
pair.put.and_then(|leg| leg.delta),
pair.call
.and_then(|leg| leg.gamma)
.or_else(|| pair.put.and_then(|leg| leg.gamma)),
None,
pair.call
.and_then(|leg| leg.open_interest)
.or_else(|| pair.put.and_then(|leg| leg.open_interest)),
None,
);
}
let fetch = ChainFetch::new(
chain,
ExpirySource::new(underlying, expiration_utc, provider.clone()),
aliases,
);
Ok(ComposedChain { fetch, overlays })
}
fn median_strike(by_strike: &BTreeMap<Positive, StrikePair<'_>>) -> Positive {
let strikes: Vec<Positive> = by_strike.keys().copied().collect();
let mid = strikes.len() / 2;
strikes.get(mid).copied().unwrap_or(Positive::ONE)
}
fn alpaca_error(err: AlpacaError) -> ProviderError {
match err {
AlpacaError::Auth(_) => ProviderError::Auth,
AlpacaError::RateLimit {
retry_after_secs, ..
} => ProviderError::RateLimited(Some(Duration::from_secs(retry_after_secs))),
AlpacaError::Api { status, .. } => ProviderError::Transport(Box::new(
TransportDetail::new(TransportKind::Http, Some(status)),
)),
AlpacaError::Timeout(_) => transport(TransportKind::Closed),
AlpacaError::Network(_) | AlpacaError::WebSocket(_) => transport(TransportKind::Closed),
AlpacaError::Json(_) | AlpacaError::InvalidData(_) => transport(TransportKind::Decode),
AlpacaError::Http(_)
| AlpacaError::Config(_)
| AlpacaError::Validation(_)
| AlpacaError::ValidationErrors(_) => transport(TransportKind::Http),
}
}
fn transport(kind: TransportKind) -> ProviderError {
ProviderError::Transport(Box::new(TransportDetail::new(kind, None)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TransportGone;
#[derive(Debug, Clone)]
enum RawStreamEvent {
Lagged,
Reconnecting {
attempt: u32,
},
Reconnected,
Disconnected,
Ignored,
}
#[async_trait]
trait AlpacaTransport: Send {
async fn connect_and_subscribe(&mut self, underlying: &str) -> Result<(), TransportGone>;
async fn receive(&mut self) -> Result<RawStreamEvent, TransportGone>;
async fn poll(
&mut self,
underlying: &str,
expiration: &ExpirationDate,
received: DateTime<Utc>,
) -> Option<ComposedChain>;
}
struct LiveTransport {
adapter: AlpacaAdapter,
stream: Option<alpaca_websocket::MarketDataStream>,
}
impl LiveTransport {
fn new(adapter: AlpacaAdapter) -> Self {
Self {
adapter,
stream: None,
}
}
}
#[async_trait]
impl AlpacaTransport for LiveTransport {
async fn connect_and_subscribe(&mut self, underlying: &str) -> Result<(), TransportGone> {
let client = self.adapter.ws_client();
let _connect_probe = client
.connect_with_reconnect(WS_CONNECT_RETRIES)
.await
.map_err(|_| TransportGone)?;
let subscription = SubscribeMessage {
trades: Some(vec![underlying.to_owned()]),
quotes: Some(vec![underlying.to_owned()]),
bars: None,
trade_updates: None,
};
let stream = client
.subscribe_market_data(subscription)
.await
.map_err(|_| TransportGone)?;
self.stream = Some(stream);
Ok(())
}
async fn receive(&mut self) -> Result<RawStreamEvent, TransportGone> {
match self.stream.as_mut() {
Some(stream) => match stream.next().await {
Some(event) => Ok(map_stream_event(event)),
None => Err(TransportGone),
},
None => Err(TransportGone),
}
}
async fn poll(
&mut self,
underlying: &str,
expiration: &ExpirationDate,
received: DateTime<Utc>,
) -> Option<ComposedChain> {
let source = LiveDataSource {
client: self.adapter.http_client().ok()?,
};
compose_chain(&source, underlying, expiration, &self.adapter.id, received)
.await
.ok()
}
}
fn map_stream_event(event: MarketDataEvent) -> RawStreamEvent {
match event {
MarketDataEvent::Lagged { .. } => RawStreamEvent::Lagged,
MarketDataEvent::Reconnecting { attempt, .. } => RawStreamEvent::Reconnecting { attempt },
MarketDataEvent::Reconnected => RawStreamEvent::Reconnected,
MarketDataEvent::Disconnected { .. } => RawStreamEvent::Disconnected,
MarketDataEvent::Update(_) => RawStreamEvent::Ignored,
}
}
enum StreamExit {
Reconnect,
Shutdown,
}
async fn run_reconnect_loop<T: AlpacaTransport>(
mut transport: T,
id: ProviderId,
underlying: String,
expiration_utc: DateTime<Utc>,
mut sink: MarketUpdateSink,
cancel: CancellationToken,
) {
let mut attempt: u32 = 0;
loop {
if cancel.is_cancelled() || sink.is_closed() {
return;
}
let exit = tokio::select! {
biased;
() = cancel.cancelled() => return,
exit = connect_stream_once(&mut transport, &id, &underlying, expiration_utc, &mut sink, &cancel, &mut attempt) => exit,
};
if matches!(exit, StreamExit::Shutdown) || cancel.is_cancelled() {
return;
}
attempt = attempt.checked_add(1).unwrap_or(attempt);
let health = MarketUpdate::Health(id.clone(), StreamHealth::Reconnecting { attempt });
let health_sent = tokio::select! {
biased;
() = cancel.cancelled() => return,
outcome = sink.send(health) => outcome,
};
if health_sent == SendState::Closed {
return;
}
let delay = backoff_delay(attempt, sample_jitter());
tokio::select! {
biased;
() = cancel.cancelled() => return,
() = tokio::time::sleep(delay) => {}
}
}
}
async fn connect_stream_once<T: AlpacaTransport>(
transport: &mut T,
id: &ProviderId,
underlying: &str,
expiration_utc: DateTime<Utc>,
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
attempt: &mut u32,
) -> StreamExit {
let subscribed = tokio::select! {
biased;
() = cancel.cancelled() => return StreamExit::Shutdown,
result = transport.connect_and_subscribe(underlying) => result,
};
if subscribed.is_err() {
return StreamExit::Reconnect;
}
*attempt = 0;
if go_live_and_backfill(transport, id, underlying, expiration_utc, sink, cancel).await
== SendState::Closed
{
return StreamExit::Shutdown;
}
loop {
let event = tokio::select! {
biased;
() = cancel.cancelled() => return StreamExit::Shutdown,
event = transport.receive() => event,
};
let event = match event {
Ok(event) => event,
Err(_) => return StreamExit::Reconnect,
};
let step = match event {
RawStreamEvent::Lagged => {
backfill(transport, underlying, expiration_utc, sink, cancel).await
}
RawStreamEvent::Reconnecting {
attempt: upstream_attempt,
} => emit_reconnecting(id, upstream_attempt, sink, cancel).await,
RawStreamEvent::Reconnected => {
go_live_and_backfill(transport, id, underlying, expiration_utc, sink, cancel).await
}
RawStreamEvent::Disconnected => {
emit_reconnecting(id, (*attempt).saturating_add(1), sink, cancel).await
}
RawStreamEvent::Ignored => SendState::Open,
};
if step == SendState::Closed {
return StreamExit::Shutdown;
}
}
}
async fn go_live_and_backfill<T: AlpacaTransport>(
transport: &mut T,
id: &ProviderId,
underlying: &str,
expiration_utc: DateTime<Utc>,
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
) -> SendState {
let live = MarketUpdate::Health(id.clone(), StreamHealth::Live);
let sent = tokio::select! {
biased;
() = cancel.cancelled() => return SendState::Open,
outcome = sink.send(live) => outcome,
};
if sent == SendState::Closed {
return SendState::Closed;
}
backfill(transport, underlying, expiration_utc, sink, cancel).await
}
async fn emit_reconnecting(
id: &ProviderId,
attempt: u32,
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
) -> SendState {
let health = MarketUpdate::Health(id.clone(), StreamHealth::Reconnecting { attempt });
tokio::select! {
biased;
() = cancel.cancelled() => SendState::Open,
outcome = sink.send(health) => outcome,
}
}
async fn backfill<T: AlpacaTransport>(
transport: &mut T,
underlying: &str,
expiration_utc: DateTime<Utc>,
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
) -> SendState {
let expiration = ExpirationDate::DateTime(expiration_utc);
let composed = tokio::select! {
biased;
() = cancel.cancelled() => return SendState::Open,
result = transport.poll(underlying, &expiration, now_utc()) => result,
};
let Some(composed) = composed else {
return SendState::Open;
};
let snapshot = MarketUpdate::Chain(chain_snapshot(&composed.fetch, now_utc()));
let snapshot_sent = tokio::select! {
biased;
() = cancel.cancelled() => return SendState::Open,
outcome = sink.send(snapshot) => outcome,
};
if snapshot_sent == SendState::Closed {
return SendState::Closed;
}
for overlay in composed.overlays {
let sent = tokio::select! {
biased;
() = cancel.cancelled() => return SendState::Open,
outcome = sink.send(overlay) => outcome,
};
if sent == SendState::Closed {
return SendState::Closed;
}
}
SendState::Open
}
fn chain_snapshot(fetch: &ChainFetch, last_poll: DateTime<Utc>) -> ChainSnapshot {
ChainSnapshot {
chain_key: (
fetch.expiry_source.provider.clone(),
fetch.expiry_source.underlying.clone(),
fetch.expiry_source.expiration_utc,
),
chain: fetch.chain.clone(),
aliases: fetch.aliases.clone(),
source: ChainSource::Merged,
health: StreamHealth::Live,
last_full_poll: Some(last_poll),
}
}
#[must_use]
fn backoff_delay(attempt: u32, jitter: f64) -> Duration {
let exponent = attempt.min(BACKOFF_MAX_SHIFT);
let uncapped = BACKOFF_BASE_MS * 2.0_f64.powi(exponent as i32);
let capped = uncapped.min(BACKOFF_MAX_MS);
let jitter = jitter.clamp(-JITTER_MAGNITUDE, JITTER_MAGNITUDE);
let millis = capped * (1.0 + jitter);
Duration::from_secs_f64(millis / 1000.0)
}
fn sample_jitter() -> f64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.subsec_nanos());
let unit = f64::from(nanos) / 1_000_000_000.0;
(unit * 2.0 - 1.0) * JITTER_MAGNITUDE
}
fn now_utc() -> DateTime<Utc> {
let since = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
DateTime::<Utc>::from_timestamp(secs, since.subsec_nanos()).unwrap_or(DateTime::<Utc>::MIN_UTC)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use chrono::Timelike;
use proptest::prelude::*;
use tokio::sync::mpsc;
use super::*;
use crate::chain::GreeksOrigin;
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
#[track_caller]
fn date(s: &str) -> NaiveDate {
match parse_ymd(s) {
Ok(d) => d,
Err(e) => panic!("invalid test date `{s}`: {e}"),
}
}
#[track_caller]
fn utc_rfc3339(s: &str) -> DateTime<Utc> {
match DateTime::parse_from_rfc3339(s) {
Ok(dt) => dt.with_timezone(&Utc),
Err(e) => panic!("invalid rfc3339 `{s}`: {e}"),
}
}
struct MapEnv(HashMap<String, String>);
impl EnvSource for MapEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn creds_env() -> MapEnv {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_ALPACA_API_KEY".to_owned(),
"PKTESTKEY0001".to_owned(),
);
let _ = env.insert(
"CHAINVIEW_ALPACA_API_SECRET".to_owned(),
"do-not-log-this-secret".to_owned(),
);
MapEnv(env)
}
#[track_caller]
fn sample_adapter() -> AlpacaAdapter {
match AlpacaAdapter::from_env(&creds_env()) {
Ok(adapter) => adapter,
Err(e) => panic!("from_env should succeed with both creds present: {e}"),
}
}
fn test_sink(
capacity: usize,
) -> (
MarketUpdateSink,
mpsc::Receiver<MarketUpdate>,
mpsc::Receiver<MarketUpdate>,
) {
let (tx_control, rx_control) = mpsc::channel::<MarketUpdate>(capacity);
let (tx_coalesced, rx_coalesced) = mpsc::channel::<MarketUpdate>(capacity);
(
MarketUpdateSink::new(tx_control, tx_coalesced),
rx_control,
rx_coalesced,
)
}
#[track_caller]
fn block<F: std::future::Future>(future: F) -> F::Output {
match tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
{
Ok(rt) => rt.block_on(future),
Err(e) => panic!("failed to build a test runtime: {e}"),
}
}
fn drain(rx: &mut mpsc::Receiver<MarketUpdate>) -> Vec<MarketUpdate> {
let mut out = Vec::new();
while let Ok(update) = rx.try_recv() {
out.push(update);
}
out
}
const CONTRACTS_PAGE_1: &str =
include_str!("../../tests/fixtures/alpaca/option_contracts_spy_page1.json");
const CONTRACTS_PAGE_2: &str =
include_str!("../../tests/fixtures/alpaca/option_contracts_spy_page2.json");
const SNAPSHOTS: &str = include_str!("../../tests/fixtures/alpaca/option_snapshots_spy.json");
#[track_caller]
fn contracts_from(json: &str) -> ContractPage {
let response: alpaca_http::endpoints::OptionContractsResponse =
match serde_json::from_str(json) {
Ok(r) => r,
Err(e) => panic!("contracts fixture must deserialize: {e}"),
};
ContractPage {
contracts: response
.option_contracts
.into_iter()
.map(map_contract)
.collect(),
next_page_token: response.next_page_token,
}
}
#[track_caller]
fn snapshots_from(json: &str) -> HashMap<String, RawSnapshot> {
let response: alpaca_http::endpoints::OptionSnapshotsResponse =
match serde_json::from_str(json) {
Ok(r) => r,
Err(e) => panic!("snapshots fixture must deserialize: {e}"),
};
response
.snapshots
.into_iter()
.map(|(symbol, snapshot)| (symbol, map_snapshot(snapshot)))
.collect()
}
struct MockDataSource {
pages: Vec<ContractPage>,
snapshots: HashMap<String, RawSnapshot>,
page_tokens: Arc<StdMutex<Vec<Option<String>>>>,
batches: Arc<StdMutex<Vec<Vec<String>>>>,
}
impl MockDataSource {
fn from_fixtures() -> Self {
Self {
pages: vec![
contracts_from(CONTRACTS_PAGE_1),
contracts_from(CONTRACTS_PAGE_2),
],
snapshots: snapshots_from(SNAPSHOTS),
page_tokens: Arc::new(StdMutex::new(Vec::new())),
batches: Arc::new(StdMutex::new(Vec::new())),
}
}
}
#[async_trait]
impl ChainDataSource for MockDataSource {
async fn discover_page(
&self,
_underlying: &str,
_expiration_date: &str,
page_token: Option<String>,
) -> Result<ContractPage, ProviderError> {
if let Ok(mut log) = self.page_tokens.lock() {
log.push(page_token.clone());
}
let index = match page_token.as_deref() {
None => 0,
Some("page2") => 1,
Some(_) => return Ok(ContractPage::default()),
};
Ok(self.pages.get(index).cloned().unwrap_or_default())
}
async fn hydrate_batch(
&self,
symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
if let Ok(mut log) = self.batches.lock() {
log.push(symbols.to_vec());
}
let mut out = HashMap::new();
for symbol in symbols {
if let Some(snapshot) = self.snapshots.get(symbol) {
let _ = out.insert(symbol.clone(), snapshot.clone());
}
}
Ok(out)
}
}
#[track_caller]
fn expiry() -> ExpirationDate {
ExpirationDate::DateTime(utc_rfc3339("2026-03-20T20:00:00+00:00"))
}
#[track_caller]
fn compose_fixture() -> ComposedChain {
let source = MockDataSource::from_fixtures();
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match block(compose_chain(
&source,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Ok(composed) => composed,
Err(e) => panic!("compose_chain should succeed for the fixtures, got: {e}"),
}
}
async fn compose_fixture_async() -> ComposedChain {
let source = MockDataSource::from_fixtures();
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match compose_chain(&source, "spy", &expiry(), &pid("alpaca"), received).await {
Ok(composed) => composed,
Err(e) => panic!("compose_chain should succeed for the fixtures, got: {e}"),
}
}
#[test]
fn test_alpaca_id_is_valid_and_reserved() {
let id = alpaca_provider_id();
assert_eq!(id.as_str(), "alpaca");
assert!(id.is_reserved());
assert!(ProviderId::new(ALPACA_ID).is_ok());
}
#[test]
fn test_alpaca_capabilities_match_section_8_row() {
let caps = alpaca_capabilities();
assert_eq!(caps.chain, ChainCapability::Native);
assert!(
!caps.depth,
"option depth is crypto-only, out of the v1 product"
);
assert_eq!(caps.greeks, GreeksCapability::Provided);
assert_eq!(caps.option_stream, OptionStreamCapability::None);
assert!(
!caps.underlying_stream,
"no underlying stream is surfaced until a real UnderlyingQuote variant lands"
);
assert_eq!(
caps.chain_poll,
ChainPollCapability::Poll {
interval_hint_secs: REFRESH_HINT_SECS
}
);
assert!(!caps.trades_tape);
assert_eq!(caps.auth, AuthKind::KeySecret);
}
#[test]
fn test_adapter_reports_capabilities_and_id_via_trait() {
let adapter: Box<dyn Provider> = Box::new(sample_adapter());
assert_eq!(adapter.id().as_str(), "alpaca");
assert_eq!(adapter.capabilities().chain, ChainCapability::Native);
assert_eq!(
adapter.capabilities().option_stream,
OptionStreamCapability::None
);
}
#[test]
fn test_credentials_never_appear_in_debug_of_adapter_secrets() {
let adapter = sample_adapter();
let rendered = format!("{:?}", adapter.api_secret);
assert!(!rendered.contains("do-not-log-this-secret"));
assert!(rendered.contains("redacted"));
}
#[test]
fn test_from_env_reads_chainview_namespace_only() {
let mut env = HashMap::new();
let _ = env.insert("CHAINVIEW_ALPACA_API_KEY".to_owned(), "key-a".to_owned());
let _ = env.insert(
"CHAINVIEW_ALPACA_API_SECRET".to_owned(),
"secret-b".to_owned(),
);
let _ = env.insert("ALPACA_API_KEY".to_owned(), "foreign".to_owned());
let adapter = match AlpacaAdapter::from_env(&MapEnv(env)) {
Ok(adapter) => adapter,
Err(e) => panic!("from_env should succeed: {e}"),
};
assert_eq!(adapter.api_key.expose(), "key-a");
assert_eq!(adapter.api_secret.expose(), "secret-b");
assert_eq!(adapter.environment, AlpacaEnvironment::Paper);
}
#[test]
fn test_from_env_environment_selector_defaults_paper_and_parses_live() {
assert_eq!(sample_adapter().environment, AlpacaEnvironment::Paper);
let mut env = HashMap::new();
let _ = env.insert("CHAINVIEW_ALPACA_API_KEY".to_owned(), "k".to_owned());
let _ = env.insert("CHAINVIEW_ALPACA_API_SECRET".to_owned(), "s".to_owned());
let _ = env.insert("CHAINVIEW_ALPACA_ENVIRONMENT".to_owned(), "LIVE".to_owned());
match AlpacaAdapter::from_env(&MapEnv(env)) {
Ok(adapter) => assert_eq!(adapter.environment, AlpacaEnvironment::Live),
Err(e) => panic!("from_env should succeed: {e}"),
}
}
#[test]
fn test_from_env_missing_credential_is_error() {
let mut env = HashMap::new();
let _ = env.insert("CHAINVIEW_ALPACA_API_KEY".to_owned(), "key-a".to_owned());
match AlpacaAdapter::from_env(&MapEnv(env)) {
Err(crate::error::ConfigError::MissingCredential(id)) => {
assert_eq!(id.as_str(), "alpaca");
}
Err(other) => panic!("expected MissingCredential, got: {other}"),
Ok(_) => panic!("expected MissingCredential, got Ok"),
}
}
#[test]
fn test_expiry_edt_resolves_to_2000_utc() {
match expiry_to_utc("2026-03-20") {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-03-20T20:00:00+00:00"),
Err(e) => panic!("EDT expiry should resolve, got: {e}"),
}
}
#[test]
fn test_expiry_est_resolves_to_2100_utc() {
match expiry_to_utc("2026-11-20") {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-11-20T21:00:00+00:00"),
Err(e) => panic!("EST expiry should resolve, got: {e}"),
}
}
#[test]
fn test_expiry_dst_boundaries() {
assert!(is_us_eastern_dst(date("2026-03-08")));
assert!(!is_us_eastern_dst(date("2026-03-07")));
assert!(!is_us_eastern_dst(date("2026-11-01")));
assert!(is_us_eastern_dst(date("2026-10-31")));
assert_eq!(
nth_weekday_of_month(2026, 3, Weekday::Sun, 2),
Some(date("2026-03-08"))
);
}
#[test]
fn test_expiry_summer_is_not_fixed_2100() {
match expiry_to_utc("2026-07-17") {
Ok(utc) => {
assert_eq!(utc.to_rfc3339(), "2026-07-17T20:00:00+00:00");
assert_ne!(utc.to_rfc3339(), "2026-07-17T21:00:00+00:00");
}
Err(e) => panic!("summer expiry should resolve, got: {e}"),
}
}
#[test]
fn test_expiry_unparseable_is_rejected() {
assert_eq!(
expiry_to_utc("not-a-date"),
Err(NormalizeKind::UnparseableExpiry)
);
assert_eq!(
expiry_to_utc("2026-13-01"),
Err(NormalizeKind::UnparseableExpiry)
);
assert_eq!(
expiry_to_utc("2026-03"),
Err(NormalizeKind::UnparseableExpiry)
);
}
#[test]
fn test_format_ymd_round_trips() {
assert_eq!(format_ymd(date("2026-03-20")), "2026-03-20");
assert_eq!(format_ymd(date("2026-11-01")), "2026-11-01");
}
#[test]
fn test_strike_positive_parses_and_rejects() {
match strike_positive("500") {
Ok(strike) => assert_eq!(strike, pos(500.0)),
Err(e) => panic!("500 should parse, got: {e}"),
}
match strike_positive("192.5") {
Ok(strike) => assert_eq!(strike, pos(192.5)),
Err(e) => panic!("192.5 should parse, got: {e}"),
}
assert_eq!(
strike_positive("0"),
Err(NormalizeKind::OutOfRange("strike"))
);
assert_eq!(
strike_positive("-5"),
Err(NormalizeKind::OutOfRange("strike"))
);
assert_eq!(
strike_positive("abc"),
Err(NormalizeKind::OutOfRange("strike"))
);
}
#[test]
fn test_iv_is_decimal_no_division() {
assert_eq!(iv_or_drop(0.35), Some(pos(0.35)));
assert_eq!(iv_or_drop(0.0), Some(Positive::ZERO));
assert_eq!(iv_or_drop(f64::NAN), None);
assert_eq!(iv_or_drop(-0.1), None);
}
#[test]
fn test_greek_keeps_negative_drops_non_finite() {
match greek_or_drop(Some(-0.45)) {
Some(delta) => assert_eq!(delta, Decimal::new(-45, 2)),
None => panic!("a negative Greek must be kept"),
}
assert_eq!(greek_or_drop(Some(f64::INFINITY)), None);
assert_eq!(greek_or_drop(None), None);
}
#[test]
fn test_normalize_quote_rules() {
match normalize_quote(Some(0.0), Some(1.0)) {
Ok(q) => {
assert_eq!(q.bid, Some(Positive::ZERO));
assert_eq!(q.ask, Some(pos(1.0)));
}
Err(e) => panic!("zero bid valid, got: {e}"),
}
assert_eq!(
normalize_quote(Some(5.0), Some(3.0)),
Err(NormalizeKind::OutOfRange("ask"))
);
assert_eq!(
normalize_quote(Some(5.0), Some(0.0)),
Err(NormalizeKind::OutOfRange("ask"))
);
match normalize_quote(Some(-1.0), Some(2.0)) {
Ok(q) => {
assert_eq!(q.bid, None);
assert_eq!(q.ask, Some(pos(2.0)));
}
Err(e) => panic!("negative bid drops only that field, got: {e}"),
}
}
#[test]
fn test_multiplier_and_open_interest_of() {
assert_eq!(multiplier_of(Some("100")), 100);
assert_eq!(multiplier_of(Some("0")), DEFAULT_SHARES_PER_CONTRACT);
assert_eq!(multiplier_of(None), DEFAULT_SHARES_PER_CONTRACT);
assert_eq!(multiplier_of(Some("junk")), DEFAULT_SHARES_PER_CONTRACT);
assert_eq!(open_interest_of(Some("1234")), Some(1234));
assert_eq!(open_interest_of(Some("")), None);
assert_eq!(open_interest_of(None), None);
}
#[test]
fn test_compose_walks_all_pages_and_publishes_discovered_set() {
let source = MockDataSource::from_fixtures();
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
let composed = match block(compose_chain(
&source,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Ok(c) => c,
Err(e) => panic!("compose should succeed, got: {e}"),
};
match source.page_tokens.lock() {
Ok(tokens) => {
assert_eq!(tokens.len(), 2, "two discovery pages walked");
assert_eq!(tokens.first(), Some(&None));
assert_eq!(tokens.get(1), Some(&Some("page2".to_owned())));
}
Err(_) => panic!("page-token log poisoned"),
}
let strikes: Vec<Positive> = composed
.fetch
.chain
.options
.iter()
.map(|o| o.strike_price)
.collect();
assert_eq!(strikes, vec![pos(500.0), pos(510.0), pos(520.0)]);
assert_eq!(composed.fetch.aliases.len(), 6);
}
#[test]
fn test_compose_hydrates_in_bounded_batches() {
let source = MockDataSource::from_fixtures();
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
let _ = block(compose_chain(
&source,
"spy",
&expiry(),
&pid("alpaca"),
received,
));
match source.batches.lock() {
Ok(batches) => {
assert!(!batches.is_empty(), "at least one hydration batch");
for batch in batches.iter() {
assert!(
batch.len() <= MAX_SYMBOLS_PER_BATCH,
"each batch is bounded by MAX_SYMBOLS_PER_BATCH"
);
}
let total: usize = batches.iter().map(Vec::len).sum();
assert_eq!(total, 6, "every discovered OCC symbol is hydrated");
}
Err(_) => panic!("batch log poisoned"),
}
}
#[test]
fn test_compose_publishes_full_strike_set_even_with_missing_snapshots() {
struct NoHydrate(MockDataSource);
#[async_trait]
impl ChainDataSource for NoHydrate {
async fn discover_page(
&self,
u: &str,
e: &str,
t: Option<String>,
) -> Result<ContractPage, ProviderError> {
self.0.discover_page(u, e, t).await
}
async fn hydrate_batch(
&self,
_symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
Ok(HashMap::new())
}
}
let source = NoHydrate(MockDataSource::from_fixtures());
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
let composed = match block(compose_chain(
&source,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Ok(c) => c,
Err(e) => panic!("compose should still succeed, got: {e}"),
};
let strikes: Vec<Positive> = composed
.fetch
.chain
.options
.iter()
.map(|o| o.strike_price)
.collect();
assert_eq!(strikes, vec![pos(500.0), pos(510.0), pos(520.0)]);
assert!(composed.overlays.is_empty(), "no snapshots -> no overlays");
}
#[test]
fn test_compose_empty_discovery_is_no_chain() {
struct Empty;
#[async_trait]
impl ChainDataSource for Empty {
async fn discover_page(
&self,
_u: &str,
_e: &str,
_t: Option<String>,
) -> Result<ContractPage, ProviderError> {
Ok(ContractPage::default())
}
async fn hydrate_batch(
&self,
_symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
Ok(HashMap::new())
}
}
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match block(compose_chain(
&Empty,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Err(ProviderError::NoChain { underlying, .. }) => assert_eq!(underlying, "SPY"),
other => panic!("expected NoChain, got {other:?}"),
}
}
#[test]
fn test_composed_chain_carries_venue_greeks_and_iv() {
let composed = compose_fixture();
assert_eq!(
composed.fetch.expiry_source.expiration_utc.to_rfc3339(),
"2026-03-20T20:00:00+00:00"
);
let row = composed
.fetch
.chain
.options
.iter()
.find(|o| o.strike_price == pos(500.0));
match row {
Some(row) => {
assert_eq!(row.implied_volatility, pos(0.2841));
assert_eq!(row.delta_call, Some(Decimal::new(55, 2)));
}
None => panic!("the 500 strike row is missing"),
}
}
#[test]
fn test_snapshot_overlays_are_tagged_provider_origin() {
let composed = compose_fixture();
let mut saw_greeks = false;
for update in &composed.overlays {
if let MarketUpdate::Greeks(row) = update {
assert_eq!(
row.origin,
GreeksOrigin::Provider,
"snapshot Greeks are venue-supplied, never ComputedLocally"
);
saw_greeks = true;
}
}
assert!(
saw_greeks,
"the fixture snapshots produce venue Greeks overlays"
);
let iv = composed.overlays.iter().find_map(|u| match u {
MarketUpdate::Greeks(row)
if row.instrument.key.strike == pos(500.0)
&& row.instrument.key.style == OptionStyle::Call =>
{
row.iv
}
_ => None,
});
assert_eq!(iv, Some(pos(0.2841)), "venue IV survives as-is, no /100");
}
fn synth_contract(index: usize, expiration_date: &str) -> RawContract {
RawContract {
symbol: format!("SPY{index}"),
underlying: "SPY".to_owned(),
expiration_date: expiration_date.to_owned(),
strike_price: format!("{}", index + 1),
style: if index.is_multiple_of(2) {
OptionStyle::Call
} else {
OptionStyle::Put
},
exercise: ExerciseStyle::American,
root_symbol: "SPY".to_owned(),
size: Some("100".to_owned()),
open_interest: None,
}
}
#[test]
fn test_discover_page_cap_reached_with_pending_is_limit_error() {
struct NeverExhausts;
#[async_trait]
impl ChainDataSource for NeverExhausts {
async fn discover_page(
&self,
_underlying: &str,
expiration_date: &str,
_page_token: Option<String>,
) -> Result<ContractPage, ProviderError> {
Ok(ContractPage {
contracts: vec![synth_contract(0, expiration_date)],
next_page_token: Some("more".to_owned()),
})
}
async fn hydrate_batch(
&self,
_symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
Ok(HashMap::new())
}
}
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match block(compose_chain(
&NeverExhausts,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Err(ProviderError::Normalize {
kind: NormalizeKind::LimitExceeded(cap),
}) => assert!(cap.contains("page"), "names the page cap, got `{cap}`"),
other => panic!("expected a LimitExceeded page-cap error, got {other:?}"),
}
}
#[test]
fn test_discover_contract_cap_reached_with_pending_is_limit_error() {
struct OverCap;
#[async_trait]
impl ChainDataSource for OverCap {
async fn discover_page(
&self,
_underlying: &str,
expiration_date: &str,
_page_token: Option<String>,
) -> Result<ContractPage, ProviderError> {
let contracts = (0..=MAX_CONTRACTS)
.map(|index| synth_contract(index, expiration_date))
.collect();
Ok(ContractPage {
contracts,
next_page_token: None,
})
}
async fn hydrate_batch(
&self,
_symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
Ok(HashMap::new())
}
}
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match block(compose_chain(
&OverCap,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Err(ProviderError::Normalize {
kind: NormalizeKind::LimitExceeded(cap),
}) => assert!(
cap.contains("contract"),
"names the contract cap, got `{cap}`"
),
other => panic!("expected a LimitExceeded contract-cap error, got {other:?}"),
}
}
#[test]
fn test_discover_exactly_at_contract_cap_is_ok() {
struct AtCap;
#[async_trait]
impl ChainDataSource for AtCap {
async fn discover_page(
&self,
_underlying: &str,
expiration_date: &str,
_page_token: Option<String>,
) -> Result<ContractPage, ProviderError> {
let contracts = (0..MAX_CONTRACTS)
.map(|index| synth_contract(index, expiration_date))
.collect();
Ok(ContractPage {
contracts,
next_page_token: None,
})
}
async fn hydrate_batch(
&self,
_symbols: &[String],
) -> Result<HashMap<String, RawSnapshot>, ProviderError> {
Ok(HashMap::new())
}
}
let received = utc_rfc3339("2026-03-19T15:00:00+00:00");
match block(compose_chain(
&AtCap,
"spy",
&expiry(),
&pid("alpaca"),
received,
)) {
Ok(composed) => assert!(!composed.fetch.chain.options.is_empty()),
Err(e) => panic!("exactly-at-cap must be a complete Ok chain, got: {e}"),
}
}
#[tokio::test(start_paused = true)]
async fn test_no_spot_pseudo_quote_emitted() {
let cancel = CancellationToken::new();
let connects = Arc::new(StdMutex::new(0));
let polls = Arc::new(StdMutex::new(0));
let transport = MockTransport {
attempts: vec![vec![RawStreamEvent::Ignored, RawStreamEvent::Ignored]],
attempt_idx: 0,
cursor: 0,
backfill: Some(compose_fixture_async().await),
connects: Arc::clone(&connects),
polls: Arc::clone(&polls),
cancel: cancel.clone(),
};
let (sink, mut rx_control, mut rx_coalesced) = test_sink(256);
run_reconnect_loop(
transport,
pid("alpaca"),
"SPY".to_owned(),
utc_rfc3339("2026-03-20T20:00:00+00:00"),
sink,
cancel,
)
.await;
let coalesced = drain(&mut rx_coalesced);
for update in &coalesced {
let (strike, native) = match update {
MarketUpdate::Quote(q) => {
(q.instrument.key.strike, q.instrument.native_symbol.as_str())
}
MarketUpdate::Greeks(g) => {
(g.instrument.key.strike, g.instrument.native_symbol.as_str())
}
other => panic!("only venue Quote/Greeks overlays are coalesced, got {other:?}"),
};
assert_ne!(
strike,
Positive::ONE,
"no update is keyed by the retired Positive::ONE spot sentinel"
);
assert_ne!(
native, "SPY",
"the underlying ticker is never emitted as a pseudo option"
);
}
let _ = drain(&mut rx_control);
}
struct MockTransport {
attempts: Vec<Vec<RawStreamEvent>>,
attempt_idx: usize,
cursor: usize,
backfill: Option<ComposedChain>,
connects: Arc<StdMutex<u32>>,
polls: Arc<StdMutex<u32>>,
cancel: CancellationToken,
}
#[async_trait]
impl AlpacaTransport for MockTransport {
async fn connect_and_subscribe(&mut self, _underlying: &str) -> Result<(), TransportGone> {
if let Ok(mut count) = self.connects.lock() {
*count += 1;
}
self.cursor = 0;
Ok(())
}
async fn receive(&mut self) -> Result<RawStreamEvent, TransportGone> {
let Some(events) = self.attempts.get(self.attempt_idx) else {
self.cancel.cancel();
return Err(TransportGone);
};
if let Some(event) = events.get(self.cursor) {
self.cursor = self.cursor.saturating_add(1);
return Ok(event.clone());
}
self.attempt_idx = self.attempt_idx.saturating_add(1);
self.cursor = 0;
if self.attempt_idx >= self.attempts.len() {
self.cancel.cancel();
}
Err(TransportGone)
}
async fn poll(
&mut self,
_underlying: &str,
_expiration: &ExpirationDate,
_received: DateTime<Utc>,
) -> Option<ComposedChain> {
if let Ok(mut count) = self.polls.lock() {
*count += 1;
}
self.backfill.as_ref().map(|composed| ComposedChain {
fetch: composed.fetch.clone(),
overlays: composed.overlays.clone(),
})
}
}
struct PendingTransport;
#[async_trait]
impl AlpacaTransport for PendingTransport {
async fn connect_and_subscribe(&mut self, _underlying: &str) -> Result<(), TransportGone> {
Ok(())
}
async fn receive(&mut self) -> Result<RawStreamEvent, TransportGone> {
std::future::pending::<()>().await;
Err(TransportGone)
}
async fn poll(
&mut self,
_underlying: &str,
_expiration: &ExpirationDate,
_received: DateTime<Utc>,
) -> Option<ComposedChain> {
None
}
}
#[tokio::test(start_paused = true)]
async fn test_reconnect_loop_resubscribes_and_backfills() {
let cancel = CancellationToken::new();
let connects = Arc::new(StdMutex::new(0));
let polls = Arc::new(StdMutex::new(0));
let transport = MockTransport {
attempts: vec![vec![RawStreamEvent::Ignored], vec![RawStreamEvent::Ignored]],
attempt_idx: 0,
cursor: 0,
backfill: Some(compose_fixture_async().await),
connects: Arc::clone(&connects),
polls: Arc::clone(&polls),
cancel: cancel.clone(),
};
let (sink, mut rx_control, mut rx_coalesced) = test_sink(64);
run_reconnect_loop(
transport,
pid("alpaca"),
"SPY".to_owned(),
utc_rfc3339("2026-03-20T20:00:00+00:00"),
sink,
cancel,
)
.await;
assert_eq!(
*connects.lock().unwrap_or_else(|e| e.into_inner()),
2,
"connected twice"
);
assert!(
*polls.lock().unwrap_or_else(|e| e.into_inner()) >= 2,
"each connect re-polled the chain (backfill)"
);
let control = drain(&mut rx_control);
assert!(
control.iter().any(|u| matches!(u, MarketUpdate::Chain(_))),
"a Chain backfill was emitted"
);
assert!(
control.iter().any(|u| matches!(
u,
MarketUpdate::Health(_, StreamHealth::Reconnecting { .. })
)),
"a Reconnecting health was emitted"
);
assert!(
control
.iter()
.any(|u| matches!(u, MarketUpdate::Health(_, StreamHealth::Live))),
"a Live health was emitted"
);
let coalesced = drain(&mut rx_coalesced);
assert!(
coalesced
.iter()
.any(|u| matches!(u, MarketUpdate::Greeks(_))),
"venue Greeks overlays reached the coalesced channel"
);
}
#[tokio::test(start_paused = true)]
async fn test_upstream_reconnect_lifecycle_surfaces_health_and_backfill() {
let cancel = CancellationToken::new();
let connects = Arc::new(StdMutex::new(0));
let polls = Arc::new(StdMutex::new(0));
let transport = MockTransport {
attempts: vec![vec![
RawStreamEvent::Reconnecting { attempt: 2 },
RawStreamEvent::Reconnected,
]],
attempt_idx: 0,
cursor: 0,
backfill: Some(compose_fixture_async().await),
connects: Arc::clone(&connects),
polls: Arc::clone(&polls),
cancel: cancel.clone(),
};
let (sink, mut rx_control, _rx_coalesced) = test_sink(256);
run_reconnect_loop(
transport,
pid("alpaca"),
"SPY".to_owned(),
utc_rfc3339("2026-03-20T20:00:00+00:00"),
sink,
cancel,
)
.await;
assert_eq!(
*connects.lock().unwrap_or_else(|e| e.into_inner()),
1,
"the upstream reconnect kept the socket alive (no ChainView reconnect)"
);
assert!(
*polls.lock().unwrap_or_else(|e| e.into_inner()) >= 2,
"the completed reconnect re-polled the chain"
);
let control = drain(&mut rx_control);
assert!(
control.iter().any(|u| matches!(
u,
MarketUpdate::Health(_, StreamHealth::Reconnecting { attempt: 2 })
)),
"the upstream reconnect surfaced Health(Reconnecting) with its attempt"
);
let chains = control
.iter()
.filter(|u| matches!(u, MarketUpdate::Chain(_)))
.count();
assert!(
chains >= 2,
"a fresh Chain backfill followed the completed reconnect, got {chains}"
);
}
#[tokio::test(start_paused = true)]
async fn test_disconnect_surfaces_reconnecting_then_backfills() {
let cancel = CancellationToken::new();
let connects = Arc::new(StdMutex::new(0));
let polls = Arc::new(StdMutex::new(0));
let transport = MockTransport {
attempts: vec![vec![RawStreamEvent::Disconnected], vec![]],
attempt_idx: 0,
cursor: 0,
backfill: Some(compose_fixture_async().await),
connects: Arc::clone(&connects),
polls: Arc::clone(&polls),
cancel: cancel.clone(),
};
let (sink, mut rx_control, _rx_coalesced) = test_sink(256);
run_reconnect_loop(
transport,
pid("alpaca"),
"SPY".to_owned(),
utc_rfc3339("2026-03-20T20:00:00+00:00"),
sink,
cancel,
)
.await;
assert_eq!(
*connects.lock().unwrap_or_else(|e| e.into_inner()),
2,
"the terminal disconnect drove a ChainView reconnect"
);
let control = drain(&mut rx_control);
assert!(
control.iter().any(|u| matches!(
u,
MarketUpdate::Health(_, StreamHealth::Reconnecting { .. })
)),
"the disconnect surfaced Health(Reconnecting)"
);
assert!(
control
.iter()
.filter(|u| matches!(u, MarketUpdate::Chain(_)))
.count()
>= 2,
"a fresh Chain backfill followed the reconnect"
);
}
#[tokio::test]
async fn test_reconnect_loop_stops_on_cancel() {
let cancel = CancellationToken::new();
let (sink, _rx_control, _rx_coalesced) = test_sink(8);
let loop_cancel = cancel.clone();
let handle = tokio::spawn(run_reconnect_loop(
PendingTransport,
pid("alpaca"),
"SPY".to_owned(),
utc_rfc3339("2026-03-20T20:00:00+00:00"),
sink,
loop_cancel,
));
tokio::task::yield_now().await;
cancel.cancel();
match handle.await {
Ok(()) => {}
Err(e) => panic!("the loop task should join cleanly on cancel, got: {e}"),
}
}
proptest! {
#[test]
fn prop_expiry_to_utc_total_and_dst_shape(
year in 2000i32..2100,
month in 1u32..=12,
day in 1u32..=28,
junk in "\\PC{0,16}",
) {
let _ = expiry_to_utc(&junk);
let date_str = format!("{year:04}-{month:02}-{day:02}");
match expiry_to_utc(&date_str) {
Ok(utc) => {
let hour = utc.hour();
prop_assert!(hour == 20 || hour == 21, "unexpected UTC hour {hour} for {date_str}");
}
Err(kind) => prop_assert_eq!(kind, NormalizeKind::UnparseableExpiry),
}
}
#[test]
fn prop_strike_positive_total(raw in "\\PC{0,12}") {
match strike_positive(&raw) {
Ok(strike) => prop_assert!(strike > Positive::ZERO),
Err(kind) => prop_assert_eq!(kind, NormalizeKind::OutOfRange("strike")),
}
}
#[test]
fn prop_normalize_quote_total(bid in -1.0e6f64..1.0e6, ask in -1.0e6f64..1.0e6) {
match normalize_quote(Some(bid), Some(ask)) {
Ok(quote) => {
if let (Some(b), Some(a)) = (quote.bid, quote.ask) {
prop_assert!(a >= b, "an accepted quote is never crossed");
}
}
Err(kind) => prop_assert_eq!(kind, NormalizeKind::OutOfRange("ask")),
}
}
}
use crate::chain::{ChainStore, MergeOutcome};
#[track_caller]
fn at(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn spy_fetch_without_dollar_strike() -> ChainFetch {
let expiry = utc_rfc3339("2026-03-20T20:00:00+00:00");
let mut chain = OptionChain::new("SPY", pos(402.0), expiry.to_rfc3339(), None, None);
for strike in [400.0, 405.0] {
chain.add_option(
pos(strike),
Some(pos(1.5)),
Some(pos(1.7)),
Some(pos(1.4)),
Some(pos(1.6)),
Positive::ZERO,
None,
None,
None,
None,
None,
None,
);
}
ChainFetch::new(
chain,
ExpirySource::new("SPY", expiry, pid("alpaca")),
AliasCatalog::new(),
)
}
#[track_caller]
fn call_bid_at(store: &ChainStore, strike: Positive) -> Option<Positive> {
store
.chain()
.options
.iter()
.find(|o| o.strike_price == strike)
.and_then(|o| o.call_bid)
}
#[test]
fn test_spot_pseudo_instrument_buffers_then_ttl_expires_never_touches_a_row() {
let seeded_at = at(1_700_000_000);
let mut store = ChainStore::seed(
spy_fetch_without_dollar_strike(),
ChainSource::Merged,
Duration::from_secs(2),
seeded_at,
);
let spot = Instrument {
key: InstrumentKey {
underlying: "SPY".to_owned(),
expiration_utc: utc_rfc3339("2026-03-20T20:00:00+00:00"),
strike: Positive::ONE,
style: OptionStyle::Call,
},
provider: pid("alpaca"),
native_symbol: "SPY".to_owned(),
stream_symbol: Some("SPY".to_owned()),
spec: ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "SPY".to_owned(),
},
};
assert_eq!(spot.key.strike, Positive::ONE);
let spot_quote = QuoteUpdate {
instrument: spot.clone(),
bid: Some(pos(500.25)),
ask: Some(pos(500.30)),
last: None,
bid_size: None,
ask_size: None,
event_time: None,
received_time: at(1_700_000_001),
};
assert_eq!(store.apply_quote(&spot_quote), MergeOutcome::Buffered);
assert_eq!(store.pending_len(), 1);
assert!(!store.contains_strike(Positive::ONE));
assert_eq!(call_bid_at(&store, pos(400.0)), Some(pos(1.5)));
assert_eq!(call_bid_at(&store, pos(405.0)), Some(pos(1.5)));
store.apply_poll(spy_fetch_without_dollar_strike(), at(1_700_000_010));
assert_eq!(store.pending_len(), 0, "the spot update TTL-expired");
assert!(!store.contains_strike(Positive::ONE));
assert_eq!(call_bid_at(&store, pos(400.0)), Some(pos(1.5)));
assert_eq!(call_bid_at(&store, pos(405.0)), Some(pos(1.5)));
}
use futures_util::SinkExt as _;
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{WebSocketStream, accept_async};
const REDACTION_KEY: &str = "PKGATELIFTREDACTZx9Q";
const REDACTION_KEY_MASKED: &str = "****Zx9Q";
const REDACTION_SECRET: &str = "do-not-log-this-alpaca-secret";
const CONTROL_CANARY: &str = "chainview-canary-9c3f-present";
#[derive(Clone, Default)]
struct LogBuffer(Arc<StdMutex<Vec<u8>>>);
impl LogBuffer {
fn contents(&self) -> String {
match self.0.lock() {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(poisoned) => String::from_utf8_lossy(&poisoned.into_inner()).into_owned(),
}
}
}
impl std::io::Write for LogBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Ok(mut bytes) = self.0.lock() {
bytes.extend_from_slice(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer {
type Writer = LogBuffer;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
async fn next_client_text(ws: &mut WebSocketStream<TcpStream>) -> String {
loop {
match ws.next().await {
Some(Ok(WsMessage::Text(text))) => return text.to_string(),
Some(Ok(_)) => continue,
other => panic!("expected a text frame from the client, got {other:?}"),
}
}
}
async fn run_mock_alpaca_server(listener: TcpListener) -> String {
let (tcp, _) = match listener.accept().await {
Ok(pair) => pair,
Err(e) => panic!("mock server accept: {e}"),
};
let mut ws = match accept_async(tcp).await {
Ok(ws) => ws,
Err(e) => panic!("mock server ws handshake: {e}"),
};
let _ = ws
.send(WsMessage::Text(
r#"[{"T":"success","msg":"connected"}]"#.into(),
))
.await;
let auth = next_client_text(&mut ws).await;
let _ = ws
.send(WsMessage::Text(
r#"[{"T":"success","msg":"authenticated"}]"#.into(),
))
.await;
let _subscribe = next_client_text(&mut ws).await;
let _ = ws
.send(WsMessage::Text(
r#"[{"T":"subscription","trades":["SPY"],"quotes":["SPY"],"bars":[]}]"#.into(),
))
.await;
let _ = ws
.send(WsMessage::Text(
r#"[{"T":"t","S":"SPY","t":"2026-03-19T15:00:00Z","p":500.0,"s":1,"x":"V","c":[],"i":1}]"#
.into(),
))
.await;
let _ = ws.close(None).await;
auth
}
#[tokio::test]
async fn test_auth_subscribe_cycle_never_logs_credentials() {
let logs = LogBuffer::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_ansi(false)
.with_writer(logs.clone())
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
tracing::debug!("{CONTROL_CANARY}");
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(e) => panic!("bind mock server: {e}"),
};
let addr = match listener.local_addr() {
Ok(addr) => addr,
Err(e) => panic!("mock server addr: {e}"),
};
let server = tokio::spawn(run_mock_alpaca_server(listener));
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_ALPACA_API_KEY".to_owned(),
REDACTION_KEY.to_owned(),
);
let _ = env.insert(
"CHAINVIEW_ALPACA_API_SECRET".to_owned(),
REDACTION_SECRET.to_owned(),
);
let adapter = match AlpacaAdapter::from_env(&MapEnv(env)) {
Ok(adapter) => adapter.with_ws_url(format!("ws://{addr}")),
Err(e) => panic!("from_env should succeed with both creds present: {e}"),
};
let client = adapter.ws_client();
let subscription = SubscribeMessage {
trades: Some(vec!["SPY".to_owned()]),
quotes: Some(vec!["SPY".to_owned()]),
bars: None,
trade_updates: None,
};
let config = alpaca_websocket::WebSocketConfig::new().no_reconnect();
let saw_update = match tokio::time::timeout(Duration::from_secs(10), async move {
let mut stream = match client
.subscribe_market_data_with_config(subscription, config)
.await
{
Ok(stream) => stream,
Err(e) => panic!("subscribe over the mock should succeed: {e}"),
};
let mut saw = false;
while let Some(event) = stream.next().await {
if matches!(event, MarketDataEvent::Update(_)) {
saw = true;
}
}
saw
})
.await
{
Ok(saw) => saw,
Err(_) => panic!("the mock auth/subscribe cycle timed out"),
};
let auth_frame = match server.await {
Ok(auth) => auth,
Err(e) => panic!("mock server task: {e}"),
};
assert!(
saw_update,
"the mock cycle yields at least one market-data update"
);
assert!(
auth_frame.contains(REDACTION_KEY) && auth_frame.contains(REDACTION_SECRET),
"the wire auth frame carries the real credentials"
);
let output = logs.contents();
assert!(!output.is_empty(), "expected captured tracing output");
assert!(
output.contains(CONTROL_CANARY),
"the capturing sink must record debug content (control):\n{output}"
);
assert!(
!output.contains(REDACTION_SECRET),
"the API secret leaked into logs:\n{output}"
);
assert!(
!output.contains(REDACTION_KEY),
"the API key leaked into logs:\n{output}"
);
assert!(
output.contains(REDACTION_KEY_MASKED),
"expected the masked key marker {REDACTION_KEY_MASKED} in logs:\n{output}"
);
}
}