pub mod convert;
pub mod feed;
pub mod historical;
#[cfg(feature = "simulator")]
pub mod simulator;
#[cfg(feature = "simulator")]
pub use convert::chain_response_to_snapshot;
pub use convert::{RawQuote, SnapshotMeta, raw_quotes_to_snapshot, snapshot_to_option_chain};
pub use feed::{DataFeed, FeedKind, TapeMeta, feed_catalogue};
pub use historical::{CsvFeed, ParquetFeed};
pub(crate) use historical::SharedParquetTape;
#[cfg(feature = "simulator")]
pub use simulator::SimulatorFeed;
use serde::{Deserialize, Serialize};
#[cfg(feature = "simulator")]
use crate::data::simulator::CreateSessionRequest;
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DataSourceSpec {
Csv {
path: String,
sha256: String,
},
Parquet {
path: String,
sha256: String,
},
#[cfg(feature = "simulator")]
Simulator(SimulatorSourceSpec),
}
#[cfg(feature = "simulator")]
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimulatorSourceSpec {
pub session: CreateSessionRequest,
pub base_url: String,
pub data_seed: u64,
pub tape_sha256: String,
pub simulator_version: Option<String>,
}
#[cfg(feature = "simulator")]
fn redact_url_userinfo(url: &str) -> String {
let Some(scheme_sep) = url.find("://") else {
let authority_end = url.find(['/', '?', '#']).unwrap_or(url.len());
let authority = url.get(..authority_end).unwrap_or(url);
if authority.contains('@') {
return "[redacted-url]".to_string();
}
return url.to_string();
};
let authority_start = scheme_sep + "://".len();
let (Some(prefix), Some(rest)) = (url.get(..authority_start), url.get(authority_start..))
else {
return "[redacted-url]".to_string();
};
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let Some(authority) = rest.get(..authority_end) else {
return "[redacted-url]".to_string();
};
let Some(at) = authority.rfind('@') else {
return url.to_string();
};
match rest.get(at + 1..) {
Some(host_and_tail) => format!("{prefix}{host_and_tail}"),
None => "[redacted-url]".to_string(),
}
}
#[cfg(feature = "simulator")]
impl Serialize for SimulatorSourceSpec {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("SimulatorSourceSpec", 5)?;
state.serialize_field("session", &self.session)?;
state.serialize_field("base_url", &redact_url_userinfo(&self.base_url))?;
state.serialize_field("data_seed", &self.data_seed)?;
state.serialize_field("tape_sha256", &self.tape_sha256)?;
state.serialize_field("simulator_version", &self.simulator_version)?;
state.end()
}
}
#[cfg(test)]
mod tests {
use super::DataSourceSpec;
#[test]
fn test_data_source_spec_parquet_kind_tagged_round_trip() {
let spec = DataSourceSpec::Parquet {
path: "chains/spx.parquet".to_string(),
sha256: String::new(),
};
let json = serde_json::to_string(&spec).unwrap_or_default();
assert!(json.contains("\"kind\":\"parquet\""));
let back: Result<DataSourceSpec, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(ref s) if *s == spec));
}
#[test]
fn test_data_source_spec_csv_kind_tagged_round_trip() {
let spec = DataSourceSpec::Csv {
path: "chains/spx/".to_string(),
sha256: "deadbeef".to_string(),
};
let json = serde_json::to_string(&spec).unwrap_or_default();
assert!(json.contains("\"kind\":\"csv\""));
let back: Result<DataSourceSpec, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(ref s) if *s == spec));
}
#[cfg(feature = "simulator")]
fn simulator_spec() -> DataSourceSpec {
use crate::data::simulator::CreateSessionRequest;
use serde_json::json;
DataSourceSpec::Simulator(super::SimulatorSourceSpec {
session: CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 20,
initial_price: 100.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: json!({"GeometricBrownian": {"dt": 0.004, "drift": 0.05, "volatility": 0.25}}),
time_frame: "Day".to_string(),
chain_size: Some(15),
strike_interval: Some(5.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: Some(7),
},
base_url: "http://localhost:7070".to_string(),
data_seed: 7,
tape_sha256: "cafef00d".to_string(),
simulator_version: Some("0.1.0".to_string()),
})
}
#[cfg(feature = "simulator")]
#[test]
fn test_data_source_spec_simulator_kind_tagged_round_trip() {
let spec = simulator_spec();
let json = serde_json::to_string(&spec).unwrap_or_default();
assert!(json.contains("\"kind\":\"simulator\""));
assert!(
json.contains("\"base_url\""),
"the newtype payload inlines beside the kind tag: {json}"
);
let back: Result<DataSourceSpec, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(ref s) if *s == spec));
}
#[cfg(feature = "simulator")]
#[test]
fn test_data_source_spec_simulator_rejects_unknown_top_level_field() {
let spec = simulator_spec();
let json = serde_json::to_string(&spec).unwrap_or_default();
let Some(poisoned) = json
.strip_suffix('}')
.map(|j| format!("{j},\"surprise\":1}}"))
else {
panic!("serialised spec must be a JSON object");
};
let back: Result<DataSourceSpec, _> = serde_json::from_str(&poisoned);
assert!(
back.is_err(),
"an unknown key in the simulator payload must be rejected"
);
}
#[cfg(feature = "simulator")]
#[test]
fn test_data_source_spec_simulator_rejects_unknown_session_field() {
let spec = simulator_spec();
let json = serde_json::to_string(&spec).unwrap_or_default();
let poisoned = json.replace("\"session\":{", "\"session\":{\"surprise\":1,");
assert_ne!(poisoned, json, "the session object must be present");
let back: Result<DataSourceSpec, _> = serde_json::from_str(&poisoned);
assert!(
back.is_err(),
"an unknown key nested in the session request must be rejected"
);
}
#[cfg(feature = "simulator")]
#[test]
fn test_redact_url_userinfo_strips_credentials_but_keeps_identity() {
use super::redact_url_userinfo;
assert_eq!(
redact_url_userinfo("http://ci-bot:s3cr3t@sim.internal:7070/api/v1/chain"),
"http://sim.internal:7070/api/v1/chain"
);
assert_eq!(
redact_url_userinfo("https://token@host:443"),
"https://host:443"
);
assert_eq!(
redact_url_userinfo("http://user:p@ss@host:7070"),
"http://host:7070"
);
assert_eq!(
redact_url_userinfo("http://localhost:7070"),
"http://localhost:7070"
);
assert_eq!(
redact_url_userinfo("http://127.0.0.1:59440"),
"http://127.0.0.1:59440"
);
assert_eq!(redact_url_userinfo("localhost:7070"), "localhost:7070");
assert_eq!(redact_url_userinfo("ci-bot:SECRET@host"), "[redacted-url]");
assert_eq!(
redact_url_userinfo("ci-bot:SECRET@host:7070/api/v1/chain"),
"[redacted-url]"
);
assert_eq!(
redact_url_userinfo("host:7070/path@notsecret"),
"host:7070/path@notsecret"
);
}
#[cfg(feature = "simulator")]
#[test]
fn test_simulator_spec_serialisation_redacts_userinfo_credential() {
use crate::data::simulator::CreateSessionRequest;
use serde_json::json;
const SECRET: &str = "SUPERSECRETTOKEN123";
let spec = DataSourceSpec::Simulator(super::SimulatorSourceSpec {
session: CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 5,
initial_price: 100.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: json!({"GeometricBrownian": {"dt": 0.004, "drift": 0.0, "volatility": 0.2}}),
time_frame: "Day".to_string(),
chain_size: Some(15),
strike_interval: Some(5.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: Some(42),
},
base_url: format!("http://ci-bot:{SECRET}@sim.internal:7070"),
data_seed: 42,
tape_sha256: "cafef00d".to_string(),
simulator_version: None,
});
let json = serde_json::to_string(&spec).unwrap_or_default();
assert!(
!json.contains(SECRET),
"the credential must never reach a serialised (manifest) copy: {json}"
);
assert!(
!json.contains('@'),
"no userinfo separator survives: {json}"
);
assert!(
json.contains("sim.internal:7070"),
"the host identity is still recorded: {json}"
);
assert!(
json.contains("cafef00d"),
"the tape identity is still recorded: {json}"
);
let back: DataSourceSpec = match serde_json::from_str(&json) {
Ok(spec) => spec,
Err(e) => panic!("redacted simulator spec must round-trip: {e}"),
};
let super::DataSourceSpec::Simulator(back) = back else {
panic!("a simulator spec must round-trip to a simulator spec");
};
assert_eq!(back.base_url, "http://sim.internal:7070");
}
}