use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use tokio::task::AbortHandle;
use tokio_util::sync::CancellationToken;
use crate::chain::{Instrument, ProviderId, StreamHealth};
use crate::config::{CliOverrides, Config, EnvSource, ModeSelect, ProcessEnv};
use crate::error::{ChainViewError, ConfigError, ProviderError, RegistryError};
#[cfg(feature = "alpaca")]
use crate::providers::alpaca::AlpacaAdapter;
use crate::providers::deribit::DeribitAdapter;
#[cfg(feature = "dxlink")]
use crate::providers::dxlink::DxlinkAdapter;
#[cfg(feature = "ibkr")]
use crate::providers::ibkr::IbkrAdapter;
#[cfg(feature = "ig")]
use crate::providers::ig::IgAdapter;
#[cfg(feature = "tastytrade")]
use crate::providers::tastytrade::TastytradeAdapter;
use crate::providers::{Provider, SubscriptionRequest};
use super::{BridgeSenders, SourceBinding, Supervisor, chain_present};
pub struct ProviderRegistry {
by_id: HashMap<ProviderId, Arc<dyn Provider>>,
}
impl ProviderRegistry {
fn new() -> Self {
Self {
by_id: HashMap::new(),
}
}
fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
fn get(&self, id: &ProviderId) -> Option<&Arc<dyn Provider>> {
self.by_id.get(id)
}
fn contains(&self, id: &ProviderId) -> bool {
self.by_id.contains_key(id)
}
fn insert(&mut self, id: ProviderId, provider: Arc<dyn Provider>) {
let _ = self.by_id.insert(id, provider);
}
}
impl fmt::Debug for ProviderRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut ids: Vec<&ProviderId> = self.by_id.keys().collect();
ids.sort();
f.debug_struct("ProviderRegistry")
.field("ids", &ids)
.finish()
}
}
#[derive(Debug, Clone, Copy)]
pub struct ChainViewApp;
impl ChainViewApp {
#[must_use]
pub fn builder() -> ChainViewAppBuilder {
ChainViewAppBuilder::new()
}
}
#[derive(Debug)]
pub struct ChainViewAppBuilder {
registry: ProviderRegistry,
config: Option<Config>,
pending: Option<ChainViewError>,
}
impl Default for ChainViewAppBuilder {
fn default() -> Self {
Self::new()
}
}
impl ChainViewAppBuilder {
#[must_use]
fn new() -> Self {
Self {
registry: ProviderRegistry::new(),
config: None,
pending: None,
}
}
#[must_use]
pub fn with_builtins(self) -> Self {
self.with_builtins_from_env(&ProcessEnv)
}
#[must_use]
fn with_builtins_from_env(self, env: &dyn EnvSource) -> Self {
let builder = self.register_builtin(DeribitAdapter::new());
#[cfg(feature = "alpaca")]
let builder = builder.register_credentialed_builtin(env, alpaca_builtin_factory);
#[cfg(feature = "ig")]
let builder = builder.register_credentialed_builtin(env, ig_builtin_factory);
#[cfg(feature = "ibkr")]
let builder = builder.register_credentialed_builtin(env, ibkr_builtin_factory);
#[cfg(not(any(feature = "alpaca", feature = "ig", feature = "ibkr")))]
let _ = env;
builder
}
#[must_use]
pub fn with_gated_builtin(mut self, id: ProviderId) -> Self {
#[cfg(any(feature = "tastytrade", feature = "dxlink"))]
note_gated_builtins();
self.record(RegistryError::Gated(id).into());
self
}
#[must_use]
pub fn register(mut self, provider: impl Provider + 'static) -> Self {
self.register_arc(Arc::new(provider));
self
}
#[must_use]
pub fn with_config(mut self, config: Config) -> Self {
self.config = Some(config);
self
}
pub fn run(self) -> Result<(), ChainViewError> {
let _resolved = self.resolve()?;
Ok(())
}
pub fn resolve(self) -> Result<Resolved, ChainViewError> {
let Self {
registry,
config,
pending,
} = self;
if let Some(error) = pending {
return Err(error);
}
let config = match config {
Some(config) => config,
None => Config::load(CliOverrides::default())?,
};
match &config.mode {
ModeSelect::Live => {
let (provider, source) = resolve_source(®istry, &config)?;
Ok(Resolved::Live {
provider,
source,
config,
})
}
ModeSelect::Replay(dir) => {
let dir = dir.clone();
Ok(Resolved::Replay { dir, config })
}
}
}
fn register_builtin(mut self, provider: impl Provider + 'static) -> Self {
let arc: Arc<dyn Provider> = Arc::new(provider);
let id = arc.id();
if self.registry.contains(&id) {
self.record(RegistryError::DuplicateId(id).into());
} else {
self.registry.insert(id, arc);
}
self
}
#[cfg(any(feature = "alpaca", feature = "ig", feature = "ibkr"))]
fn register_credentialed_builtin(
mut self,
env: &dyn EnvSource,
factory: CredentialedBuiltinFactory,
) -> Self {
match factory(env) {
Ok(arc) => {
let id = arc.id();
if self.registry.contains(&id) {
self.record(RegistryError::DuplicateId(id).into());
} else {
self.registry.insert(id, arc);
}
}
Err(ConfigError::MissingCredential(_)) => {}
Err(other) => self.record(other.into()),
}
self
}
fn register_arc(&mut self, provider: Arc<dyn Provider>) {
let id = provider.id();
if id.is_reserved() {
self.record(RegistryError::ReservedId(id).into());
return;
}
if self.registry.contains(&id) {
self.record(RegistryError::DuplicateId(id).into());
return;
}
self.registry.insert(id, provider);
}
fn record(&mut self, error: ChainViewError) {
if self.pending.is_none() {
self.pending = Some(error);
}
}
}
#[cfg(any(feature = "alpaca", feature = "ig", feature = "ibkr"))]
type CredentialedBuiltinFactory = fn(&dyn EnvSource) -> Result<Arc<dyn Provider>, ConfigError>;
#[cfg(feature = "alpaca")]
fn alpaca_builtin_factory(env: &dyn EnvSource) -> Result<Arc<dyn Provider>, ConfigError> {
Ok(Arc::new(AlpacaAdapter::from_env(env)?) as Arc<dyn Provider>)
}
#[cfg(feature = "ig")]
fn ig_builtin_factory(env: &dyn EnvSource) -> Result<Arc<dyn Provider>, ConfigError> {
Ok(Arc::new(IgAdapter::from_env(env)?) as Arc<dyn Provider>)
}
#[cfg(feature = "ibkr")]
fn ibkr_builtin_factory(env: &dyn EnvSource) -> Result<Arc<dyn Provider>, ConfigError> {
Ok(Arc::new(IbkrAdapter::from_env(env)?) as Arc<dyn Provider>)
}
#[cfg(any(feature = "tastytrade", feature = "dxlink"))]
type GatedBuiltinFactory =
fn(&dyn crate::config::EnvSource) -> Result<Arc<dyn Provider>, ConfigError>;
#[cfg(any(feature = "tastytrade", feature = "dxlink"))]
fn note_gated_builtins() {
#[cfg(feature = "tastytrade")]
{
let _tastytrade: GatedBuiltinFactory =
|env| Ok(Arc::new(TastytradeAdapter::from_env(env)?) as Arc<dyn Provider>);
let _ = _tastytrade;
}
#[cfg(feature = "dxlink")]
{
let _dxlink: GatedBuiltinFactory =
|env| Ok(Arc::new(DxlinkAdapter::from_env(env)?) as Arc<dyn Provider>);
let _ = _dxlink;
}
}
fn resolve_source(
registry: &ProviderRegistry,
config: &Config,
) -> Result<(Arc<dyn Provider>, SourceBinding), ChainViewError> {
if registry.is_empty() {
return Err(RegistryError::Empty.into());
}
let provider = registry.get(&config.provider).ok_or_else(|| {
ChainViewError::from(ConfigError::UnknownProvider(
config.provider.as_str().to_owned(),
))
})?;
let capabilities = provider.capabilities();
if !chain_present(capabilities.chain) {
return Err(ChainViewError::from(ConfigError::InvalidValue {
field: "provider".to_owned(),
reason: format!(
"provider `{}` produces no option chain, so it cannot be a live \
chain source; a chain-less feed can only overlay an external \
chain source",
config.provider
),
}));
}
let binding = SourceBinding::new(
config.provider.clone(),
capabilities,
StreamHealth::Reconnecting { attempt: 1 },
);
Ok((Arc::clone(provider), binding))
}
#[allow(clippy::large_enum_variant)]
pub enum Resolved {
Live {
provider: Arc<dyn Provider>,
source: SourceBinding,
config: Config,
},
Replay {
dir: PathBuf,
config: Config,
},
}
#[derive(Debug, Clone)]
pub struct ProviderSubscription {
id: ProviderId,
cancel: CancellationToken,
abort: AbortHandle,
}
impl ProviderSubscription {
#[must_use]
pub fn provider(&self) -> &ProviderId {
&self.id
}
pub fn cancel(&self) {
self.cancel.cancel();
}
pub fn abort(&self) {
self.abort.abort();
}
}
pub async fn spawn_supervised_subscription(
provider: &Arc<dyn Provider>,
underlying: &str,
expiration_utc: DateTime<Utc>,
instruments: Vec<Instrument>,
senders: &BridgeSenders,
supervisor: &mut Supervisor,
) -> Result<Option<ProviderSubscription>, ProviderError> {
let cancel = supervisor.child_token();
let sink = senders.market_update_sink();
let request = SubscriptionRequest::new(underlying, expiration_utc, instruments, cancel.clone());
match provider.subscribe(request, sink).await {
Ok(mut handle) => match handle.take_join_handle() {
Some(join) => {
let abort = supervisor.watch(join);
Ok(Some(ProviderSubscription {
id: provider.id(),
cancel,
abort,
}))
}
None => Ok(None),
},
Err(ProviderError::Unsupported(_)) => Ok(None),
Err(other) => Err(other),
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use optionstratlib::ExpirationDate;
use proptest::prelude::*;
use super::{ChainViewApp, ProviderRegistry, resolve_source, spawn_supervised_subscription};
use crate::app::{
BridgeSenders, EventBridge, FinalTeardown, LiveScreen, SourceBinding, Supervisor,
is_screen_reachable,
};
use crate::chain::{
ChainFetch, Instrument, MarketUpdate, ProviderId, RESERVED_PROVIDER_IDS, StreamHealth,
};
use crate::config::{Config, EnvSource, ModeSelect, ThemeChoice};
use crate::error::{ChainViewError, ConfigError, ProviderError, RegistryError};
use crate::providers::{
ChainCapability, GreeksCapability, MarketUpdateSink, Provider, ProviderCapabilities,
SubscriptionHandle, SubscriptionRequest, UnderlyingRef,
};
#[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}"),
}
}
struct TestEnv(HashMap<String, String>);
impl EnvSource for TestEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn empty_env() -> TestEnv {
TestEnv(HashMap::new())
}
fn alpaca_creds_env() -> TestEnv {
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(),
"test-secret-value".to_owned(),
);
TestEnv(env)
}
fn chainful_caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Assemble)
.greeks(GreeksCapability::Provided)
.build()
}
fn chainless_caps() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::None)
.build()
}
fn live_config(provider: &str) -> Config {
Config {
provider: pid(provider),
underlying: "BTC".to_owned(),
refresh_interval: Duration::from_secs(2),
tick_interval: Duration::from_millis(250),
channel_capacity: 1024,
log_file: None,
theme: ThemeChoice::Auto,
no_color: false,
providers: BTreeMap::new(),
mode: ModeSelect::Live,
}
}
fn replay_config(dir: &str) -> Config {
Config {
mode: ModeSelect::Replay(PathBuf::from(dir)),
..live_config("deribit")
}
}
struct FakeProvider {
id: ProviderId,
capabilities: ProviderCapabilities,
capability_calls: Arc<AtomicUsize>,
}
impl FakeProvider {
fn new(id: ProviderId, capabilities: ProviderCapabilities) -> Self {
Self {
id,
capabilities,
capability_calls: Arc::new(AtomicUsize::new(0)),
}
}
fn chainful(id: ProviderId) -> Self {
Self::new(id, chainful_caps())
}
}
#[async_trait]
impl Provider for FakeProvider {
fn id(&self) -> ProviderId {
self.id.clone()
}
fn capabilities(&self) -> ProviderCapabilities {
let _ = self.capability_calls.fetch_add(1, Ordering::SeqCst);
self.capabilities
}
async fn discover(&self) -> Result<Vec<UnderlyingRef>, ProviderError> {
Ok(Vec::new())
}
async fn fetch_chain(
&self,
_underlying: &str,
_expiration: &ExpirationDate,
) -> Result<ChainFetch, ProviderError> {
Err(ProviderError::Unsupported("fake provider has no chain"))
}
async fn subscribe(
&self,
_req: SubscriptionRequest,
_sink: MarketUpdateSink,
) -> Result<SubscriptionHandle, ProviderError> {
Err(ProviderError::Unsupported("fake provider has no stream"))
}
}
fn registry_with(id: &str) -> ProviderRegistry {
let mut registry = ProviderRegistry::new();
registry.insert(pid(id), Arc::new(FakeProvider::chainful(pid(id))));
registry
}
#[test]
fn test_register_duplicate_id_is_duplicate_error() {
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(pid("mybroker")))
.register(FakeProvider::chainful(pid("mybroker")))
.with_config(live_config("mybroker"))
.run();
match result {
Err(ChainViewError::Registry(RegistryError::DuplicateId(id))) => {
assert_eq!(id.as_str(), "mybroker");
}
other => panic!("expected DuplicateId(mybroker), got {other:?}"),
}
}
#[test]
fn test_register_reserved_id_is_reserved_error() {
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(pid("deribit")))
.with_config(live_config("deribit"))
.run();
match result {
Err(ChainViewError::Registry(RegistryError::ReservedId(id))) => {
assert_eq!(id.as_str(), "deribit");
}
other => panic!("expected ReservedId(deribit), got {other:?}"),
}
}
#[test]
fn test_register_duplicate_wins_over_later_reserved_first_error_wins() {
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(pid("mybroker")))
.register(FakeProvider::chainful(pid("mybroker")))
.register(FakeProvider::chainful(pid("alpaca")))
.with_config(live_config("mybroker"))
.run();
assert!(matches!(
result,
Err(ChainViewError::Registry(RegistryError::DuplicateId(_)))
));
}
#[test]
fn test_run_empty_registry_is_empty_error() {
let result = ChainViewApp::builder()
.with_config(live_config("deribit"))
.run();
assert!(matches!(
result,
Err(ChainViewError::Registry(RegistryError::Empty))
));
}
#[test]
fn test_with_builtins_registers_deribit_and_resolves_source() {
let result = ChainViewApp::builder()
.with_builtins()
.with_config(live_config("deribit"))
.run();
assert!(
result.is_ok(),
"expected deribit to resolve, got {result:?}"
);
}
#[test]
fn test_run_unknown_provider_is_unknown_provider_error() {
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(pid("mybroker")))
.with_config(live_config("othervendor"))
.run();
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "othervendor");
}
other => panic!("expected UnknownProvider(othervendor), got {other:?}"),
}
}
#[test]
fn test_provider_selection_invalid_grammar_is_invalid_value() {
match ProviderId::new("Bad-Upper") {
Err(ConfigError::InvalidValue { field, .. }) => assert_eq!(field, "provider id"),
other => panic!("expected InvalidValue on provider id, got {other:?}"),
}
}
#[test]
fn test_with_gated_builtin_fails_while_gate_holds() {
let result = ChainViewApp::builder()
.with_gated_builtin(pid("tastytrade"))
.register(FakeProvider::chainful(pid("mybroker")))
.with_config(live_config("mybroker"))
.run();
match result {
Err(ChainViewError::Registry(RegistryError::Gated(id))) => {
assert_eq!(id.as_str(), "tastytrade");
}
other => panic!("expected Gated(tastytrade), got {other:?}"),
}
}
#[test]
fn test_with_builtins_enables_alpaca_when_configured() {
let env = alpaca_creds_env();
let result = ChainViewApp::builder()
.with_builtins_from_env(&env)
.with_config(live_config("alpaca"))
.run();
#[cfg(feature = "alpaca")]
assert!(
result.is_ok(),
"with the gate lifted, a configured alpaca resolves as a registered built-in: {result:?}"
);
#[cfg(not(feature = "alpaca"))]
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "alpaca");
}
other => panic!("without the alpaca feature the adapter is not compiled in: {other:?}"),
}
}
#[test]
fn test_with_builtins_skips_unconfigured_alpaca_preserving_zero_config() {
let result = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("alpaca"))
.run();
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "alpaca");
}
other => panic!("expected UnknownProvider(alpaca) when unconfigured, got {other:?}"),
}
let deribit = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("deribit"))
.run();
assert!(
deribit.is_ok(),
"the zero-config deribit default is unaffected: {deribit:?}"
);
}
fn ig_creds_env() -> TestEnv {
let mut env = HashMap::new();
let _ = env.insert("CHAINVIEW_IG_USERNAME".to_owned(), "alice".to_owned());
let _ = env.insert("CHAINVIEW_IG_PASSWORD".to_owned(), "test-pw".to_owned());
let _ = env.insert("CHAINVIEW_IG_API_KEY".to_owned(), "test-key".to_owned());
TestEnv(env)
}
#[test]
fn test_with_builtins_enables_ig_when_configured() {
let env = ig_creds_env();
let result = ChainViewApp::builder()
.with_builtins_from_env(&env)
.with_config(live_config("ig"))
.run();
#[cfg(feature = "ig")]
assert!(
result.is_ok(),
"a configured IG resolves as a registered built-in under --features ig: {result:?}"
);
#[cfg(not(feature = "ig"))]
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "ig");
}
other => panic!("without the ig feature the adapter is not compiled in: {other:?}"),
}
}
#[test]
fn test_with_builtins_skips_unconfigured_ig_preserving_zero_config() {
let result = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("ig"))
.run();
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "ig");
}
other => panic!("expected UnknownProvider(ig) when unconfigured, got {other:?}"),
}
let deribit = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("deribit"))
.run();
assert!(
deribit.is_ok(),
"the zero-config deribit default is unaffected: {deribit:?}"
);
}
fn ibkr_endpoint_env() -> TestEnv {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_IBKR_ENDPOINT".to_owned(),
"127.0.0.1:7497".to_owned(),
);
TestEnv(env)
}
#[test]
fn test_with_builtins_enables_ibkr_when_configured() {
let env = ibkr_endpoint_env();
let result = ChainViewApp::builder()
.with_builtins_from_env(&env)
.with_config(live_config("ibkr"))
.run();
#[cfg(feature = "ibkr")]
assert!(
result.is_ok(),
"a configured IBKR resolves as a registered built-in under --features ibkr: {result:?}"
);
#[cfg(not(feature = "ibkr"))]
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "ibkr");
}
other => panic!("without the ibkr feature the adapter is not compiled in: {other:?}"),
}
}
#[test]
fn test_with_builtins_skips_unconfigured_ibkr_preserving_zero_config() {
let result = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("ibkr"))
.run();
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "ibkr");
}
other => panic!("expected UnknownProvider(ibkr) when unconfigured, got {other:?}"),
}
let deribit = ChainViewApp::builder()
.with_builtins_from_env(&empty_env())
.with_config(live_config("deribit"))
.run();
assert!(
deribit.is_ok(),
"the zero-config deribit default is unaffected: {deribit:?}"
);
}
#[test]
fn test_with_gated_builtin_dxlink_fails_while_gate_holds() {
let result = ChainViewApp::builder()
.with_gated_builtin(pid("dxlink"))
.register(FakeProvider::chainful(pid("mybroker")))
.with_config(live_config("mybroker"))
.run();
match result {
Err(ChainViewError::Registry(RegistryError::Gated(id))) => {
assert_eq!(id.as_str(), "dxlink");
}
other => panic!("expected Gated(dxlink), got {other:?}"),
}
}
#[test]
fn test_with_builtins_never_enables_dxlink() {
let result = ChainViewApp::builder()
.with_builtins()
.with_config(live_config("dxlink"))
.run();
match result {
Err(ChainViewError::Config(ConfigError::UnknownProvider(id))) => {
assert_eq!(id, "dxlink");
}
other => panic!("expected UnknownProvider(dxlink), got {other:?}"),
}
}
#[test]
fn test_run_resolves_live_source_reading_capabilities_once() {
let calls = Arc::new(AtomicUsize::new(0));
let provider = FakeProvider {
id: pid("mybroker"),
capabilities: chainful_caps(),
capability_calls: Arc::clone(&calls),
};
let result = ChainViewApp::builder()
.register(provider)
.with_config(live_config("mybroker"))
.run();
assert!(
result.is_ok(),
"a registered chain source resolves: {result:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"capabilities are read exactly once at startup"
);
}
#[test]
fn test_resolve_source_wires_declared_capabilities_into_source_binding() {
let registry = registry_with("mybroker");
let config = live_config("mybroker");
match resolve_source(®istry, &config) {
Ok((provider, binding)) => {
assert_eq!(provider.id().as_str(), "mybroker");
assert_eq!(binding.provider.as_str(), "mybroker");
assert_eq!(binding.capabilities, chainful_caps());
assert!(matches!(
binding.health,
StreamHealth::Reconnecting { attempt: 1 }
));
}
Err(e) => panic!("expected a resolved SourceBinding, got {e}"),
}
}
#[test]
fn test_resolve_chainless_source_without_overlay_is_invalid_value() {
let mut registry = ProviderRegistry::new();
registry.insert(
pid("myoverlay"),
Arc::new(FakeProvider::new(pid("myoverlay"), chainless_caps())),
);
match resolve_source(®istry, &live_config("myoverlay")) {
Err(ChainViewError::Config(ConfigError::InvalidValue { field, .. })) => {
assert_eq!(field, "provider");
}
Err(other) => panic!("expected InvalidValue on provider, got {other}"),
Ok(_) => panic!("expected InvalidValue on provider, got a resolved source"),
}
}
#[test]
fn test_run_replay_mode_ignores_empty_registry() {
let result = ChainViewApp::builder()
.with_config(replay_config("/bundle"))
.run();
assert!(result.is_ok(), "replay needs no live provider: {result:?}");
}
#[test]
fn test_ui_facing_seam_is_capabilities_not_the_registry() {
let binding = SourceBinding::new(pid("mybroker"), chainful_caps(), StreamHealth::Live);
let _caps: ProviderCapabilities = binding.capabilities;
let _id: ProviderId = binding.provider;
assert!(is_screen_reachable(LiveScreen::Chain, &chainful_caps()));
}
#[test]
fn test_registry_debug_lists_sorted_ids_only() {
let mut registry = ProviderRegistry::new();
registry.insert(pid("zeta"), Arc::new(FakeProvider::chainful(pid("zeta"))));
registry.insert(
pid("mybroker"),
Arc::new(FakeProvider::chainful(pid("mybroker"))),
);
let rendered = format!("{registry:?}");
assert!(rendered.contains("mybroker"));
assert!(rendered.contains("zeta"));
let mybroker = rendered.find("mybroker");
let zeta = rendered.find("zeta");
assert!(mybroker < zeta, "ids are rendered sorted: {rendered}");
}
struct NoopTeardown;
impl FinalTeardown for NoopTeardown {
fn run(self: Box<Self>) {}
}
#[track_caller]
fn expiry() -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(1_751_011_200, 0) {
Some(t) => t,
None => panic!("valid fixed expiry"),
}
}
struct StreamingFake {
id: ProviderId,
panic_mid_run: bool,
}
#[async_trait]
impl Provider for StreamingFake {
fn id(&self) -> ProviderId {
self.id.clone()
}
fn capabilities(&self) -> ProviderCapabilities {
chainful_caps()
}
async fn discover(&self) -> Result<Vec<UnderlyingRef>, ProviderError> {
Ok(vec![UnderlyingRef::new("BTC")])
}
async fn fetch_chain(
&self,
_underlying: &str,
_expiration: &ExpirationDate,
) -> Result<ChainFetch, ProviderError> {
Err(ProviderError::Unsupported("no chain in this test"))
}
async fn subscribe(
&self,
req: SubscriptionRequest,
mut sink: MarketUpdateSink,
) -> Result<SubscriptionHandle, ProviderError> {
let _ = sink
.send(MarketUpdate::Health(self.id.clone(), StreamHealth::Live))
.await;
let cancel = req.cancel;
let panic_mid_run = self.panic_mid_run;
let loop_cancel = cancel.clone();
let join = tokio::spawn(async move {
if panic_mid_run {
panic!("mid-run provider panic");
}
loop_cancel.cancelled().await;
});
Ok(SubscriptionHandle::spawned(cancel, join))
}
}
#[tokio::test]
async fn test_spawn_supervised_subscription_returns_cancel_handle_and_joins_clean() {
let provider: Arc<dyn Provider> = Arc::new(StreamingFake {
id: pid("faux"),
panic_mid_run: false,
});
let (_bridge, senders) = EventBridge::new(64);
let mut supervisor = Supervisor::new(Box::new(NoopTeardown));
let sub = spawn_supervised_subscription(
&provider,
"BTC",
expiry(),
Vec::<Instrument>::new(),
&senders,
&mut supervisor,
)
.await;
let handle = match sub {
Ok(Some(handle)) => handle,
other => panic!("expected a supervised subscription handle, got {other:?}"),
};
assert_eq!(handle.provider().as_str(), "faux");
handle.cancel();
supervisor.request_quit();
assert!(
supervisor.run().await.is_clean(),
"the supervised, cooperatively-cancelled loop joins clean"
);
}
#[tokio::test]
async fn test_spawn_supervised_provider_death_mid_run_wakes_supervisor() {
let provider: Arc<dyn Provider> = Arc::new(StreamingFake {
id: pid("faux"),
panic_mid_run: true,
});
let (_bridge, senders) = EventBridge::new(64);
let mut supervisor = Supervisor::new(Box::new(NoopTeardown));
let sub = spawn_supervised_subscription(
&provider,
"BTC",
expiry(),
Vec::<Instrument>::new(),
&senders,
&mut supervisor,
)
.await;
assert!(
matches!(sub, Ok(Some(_))),
"the streaming provider task is watched under the supervisor"
);
let cause = supervisor.run().await;
assert_eq!(
cause.exit_code(),
1,
"a watched provider panicking mid-run wakes the supervisor as fatal"
);
}
#[tokio::test]
async fn test_spawn_supervised_subscription_poll_only_is_none() {
let provider: Arc<dyn Provider> = Arc::new(FakeProvider::chainful(pid("mybroker")));
let (_bridge, senders): (EventBridge, BridgeSenders) = EventBridge::new(64);
let mut supervisor = Supervisor::new(Box::new(NoopTeardown));
let sub = spawn_supervised_subscription(
&provider,
"BTC",
expiry(),
Vec::<Instrument>::new(),
&senders,
&mut supervisor,
)
.await;
assert!(
matches!(sub, Ok(None)),
"a poll-only provider has no supervised stream task"
);
supervisor.request_quit();
assert!(supervisor.run().await.is_clean());
}
fn valid_custom_id() -> impl Strategy<Value = ProviderId> {
"[a-z][a-z0-9]{1,10}"
.prop_map(ProviderId::new)
.prop_filter("valid, non-reserved id", |r| {
r.as_ref().is_ok_and(|p| !p.is_reserved())
})
.prop_map(|r| match r {
Ok(p) => p,
Err(e) => panic!("filtered id was invalid: {e}"),
})
}
fn chain_capability(idx: u8) -> ChainCapability {
match idx % 4 {
0 => ChainCapability::Native,
1 => ChainCapability::Assemble,
2 => ChainCapability::Partial,
_ => ChainCapability::None,
}
}
fn greeks_capability(idx: u8) -> GreeksCapability {
match idx % 3 {
0 => GreeksCapability::Provided,
1 => GreeksCapability::ComputedLocally,
_ => GreeksCapability::None,
}
}
proptest! {
#[test]
fn prop_registry_rejects_reserved_id(idx in 0usize..RESERVED_PROVIDER_IDS.len()) {
let id_str = match RESERVED_PROVIDER_IDS.get(idx) {
Some(s) => *s,
None => return Ok(()),
};
let id = match ProviderId::new(id_str) {
Ok(p) => p,
Err(e) => panic!("reserved id `{id_str}` must be grammar-valid: {e}"),
};
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(id))
.run();
prop_assert!(matches!(
result,
Err(ChainViewError::Registry(RegistryError::ReservedId(_)))
));
}
#[test]
fn prop_registry_rejects_duplicate_id(id in valid_custom_id()) {
let result = ChainViewApp::builder()
.register(FakeProvider::chainful(id.clone()))
.register(FakeProvider::chainful(id))
.run();
prop_assert!(matches!(
result,
Err(ChainViewError::Registry(RegistryError::DuplicateId(_)))
));
}
#[test]
fn prop_capabilities_total(
id in valid_custom_id(),
depth in any::<bool>(),
chain_idx in any::<u8>(),
greeks_idx in any::<u8>(),
) {
let chain = chain_capability(chain_idx);
let greeks = greeks_capability(greeks_idx);
let caps = ProviderCapabilities::builder()
.chain(chain)
.depth(depth)
.greeks(greeks)
.build();
let provider = FakeProvider::new(id, caps);
prop_assert_eq!(provider.capabilities(), caps);
let chain_ok = !matches!(chain, ChainCapability::None);
let greeks_ok = !matches!(greeks, GreeksCapability::None);
prop_assert_eq!(is_screen_reachable(LiveScreen::Chain, &caps), chain_ok);
prop_assert_eq!(is_screen_reachable(LiveScreen::Payoff, &caps), chain_ok);
prop_assert_eq!(is_screen_reachable(LiveScreen::Depth, &caps), depth);
prop_assert_eq!(is_screen_reachable(LiveScreen::Surface, &caps), greeks_ok);
}
}
}