pub(crate) mod client;
pub(crate) mod models;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use crate::error::{FinanceError, Result};
use crate::models::discovery::figi::{SecurityIdKind, SecurityMapping};
use crate::rate_limiter::RateLimiter;
use client::OpenFigiClient;
use models::{FigiRecord, MappingJob};
const OPENFIGI_ANONYMOUS_RATE_PER_SEC: f64 = 25.0 / 60.0;
const OPENFIGI_AUTHENTICATED_RATE_PER_SEC: f64 = 25.0 / 6.0;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
static ANONYMOUS_LIMITER: OnceLock<Arc<RateLimiter>> = OnceLock::new();
static AUTHENTICATED_LIMITER: OnceLock<Arc<RateLimiter>> = OnceLock::new();
fn limiter(authenticated: bool) -> Arc<RateLimiter> {
let (slot, rate) = if authenticated {
(&AUTHENTICATED_LIMITER, OPENFIGI_AUTHENTICATED_RATE_PER_SEC)
} else {
(&ANONYMOUS_LIMITER, OPENFIGI_ANONYMOUS_RATE_PER_SEC)
};
Arc::clone(slot.get_or_init(|| Arc::new(RateLimiter::new(rate))))
}
fn client() -> Result<OpenFigiClient> {
let api_key = std::env::var("OPENFIGI_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty());
let request_limiter = limiter(api_key.is_some());
OpenFigiClient::new(
DEFAULT_TIMEOUT,
request_limiter,
client::OPENFIGI_BASE,
api_key,
)
}
fn to_mapping(record: FigiRecord) -> SecurityMapping {
SecurityMapping {
figi: record.figi,
ticker: record.ticker,
name: record.name,
exchange_code: record.exch_code,
composite_figi: record.composite_figi,
share_class_figi: record.share_class_figi,
security_type: record.security_type,
market_sector: record.market_sector,
}
}
pub(crate) async fn resolve(kind: SecurityIdKind, id: &str) -> Result<Vec<SecurityMapping>> {
let mut results = resolve_many(kind, std::slice::from_ref(&id)).await?;
Ok(results.pop().unwrap_or_default())
}
pub(crate) async fn resolve_many(
kind: SecurityIdKind,
ids: &[&str],
) -> Result<Vec<Vec<SecurityMapping>>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let client = client()?;
let max_jobs = client.max_jobs_per_request();
let mut out: Vec<Vec<SecurityMapping>> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(max_jobs) {
let jobs: Vec<MappingJob<'_>> = chunk
.iter()
.map(|id| MappingJob {
id_type: kind.as_str(),
id_value: id,
})
.collect();
for (result, id) in client.map(&jobs).await?.into_iter().zip(chunk) {
if let Some(error) = result.error {
return Err(FinanceError::InvalidParameter {
param: format!("{kind}"),
reason: format!("OpenFIGI rejected '{id}': {error}"),
});
}
if let Some(warning) = result.warning {
tracing::debug!("OpenFIGI: {kind} '{id}' matched nothing ({warning})");
}
out.push(
result
.data
.unwrap_or_default()
.into_iter()
.map(to_mapping)
.collect(),
);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> OpenFigiClient {
OpenFigiClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
None,
)
.unwrap()
}
fn keyed_test_client(base_url: &str) -> OpenFigiClient {
OpenFigiClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
Some("test-key".to_string()),
)
.unwrap()
}
#[test]
fn api_key_enables_the_documented_batch_size() {
let limiter = Arc::new(RateLimiter::new(100.0));
let anonymous = OpenFigiClient::new(
Duration::from_secs(5),
Arc::clone(&limiter),
client::OPENFIGI_BASE,
None,
)
.unwrap();
let authenticated = OpenFigiClient::new(
Duration::from_secs(5),
limiter,
client::OPENFIGI_BASE,
Some("test-key".to_string()),
)
.unwrap();
assert_eq!(anonymous.max_jobs_per_request(), 10);
assert_eq!(authenticated.max_jobs_per_request(), 100);
}
#[tokio::test]
async fn api_key_is_sent_in_the_documented_header() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/mapping")
.match_header("X-OPENFIGI-APIKEY", "test-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[{"warning":"No identifier found."}]"#)
.create_async()
.await;
let jobs = [MappingJob {
id_type: "TICKER",
id_value: "UNKNOWN",
}];
keyed_test_client(&server.url()).map(&jobs).await.unwrap();
}
fn apple_payload() -> String {
serde_json::json!([{
"data": [
{ "figi": "BBG000B9XRY4", "name": "APPLE INC", "ticker": "AAPL",
"exchCode": "US", "compositeFIGI": "BBG000B9XRY4",
"securityType": "Common Stock", "marketSector": "Equity",
"shareClassFIGI": "BBG001S5N8V8", "securityType2": "Common Stock",
"securityDescription": "AAPL" },
{ "figi": "BBG000B9XVV8", "name": "APPLE INC", "ticker": "AAPL",
"exchCode": "UN", "compositeFIGI": "BBG000B9XRY4",
"securityType": "Common Stock", "marketSector": "Equity",
"shareClassFIGI": "BBG001S5N8V8", "securityType2": "Common Stock",
"securityDescription": "AAPL" }
]
}])
.to_string()
}
#[test]
fn id_kinds_use_openfigis_own_type_names() {
assert_eq!(SecurityIdKind::Cusip.as_str(), "ID_CUSIP");
assert_eq!(SecurityIdKind::Isin.as_str(), "ID_ISIN");
assert_eq!(SecurityIdKind::Sedol.as_str(), "ID_SEDOL");
assert_eq!(SecurityIdKind::Figi.as_str(), "ID_BB_GLOBAL");
assert_eq!(SecurityIdKind::Ticker.as_str(), "TICKER");
}
#[tokio::test]
async fn a_cusip_resolves_to_every_listing_it_covers() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/mapping")
.match_body(mockito::Matcher::PartialJson(serde_json::json!([{
"idType": "ID_CUSIP",
"idValue": "037833100"
}])))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(apple_payload())
.create_async()
.await;
let jobs = vec![MappingJob {
id_type: "ID_CUSIP",
id_value: "037833100",
}];
let results = test_client(&server.url()).map(&jobs).await.unwrap();
let mappings: Vec<_> = results[0]
.data
.clone()
.unwrap()
.into_iter()
.map(to_mapping)
.collect();
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].figi, "BBG000B9XRY4");
assert_eq!(mappings[0].ticker.as_deref(), Some("AAPL"));
assert_eq!(mappings[0].name.as_deref(), Some("APPLE INC"));
assert_eq!(mappings[0].exchange_code.as_deref(), Some("US"));
assert_eq!(mappings[1].exchange_code.as_deref(), Some("UN"));
assert_eq!(mappings[0].composite_figi.as_deref(), Some("BBG000B9XRY4"));
assert_eq!(mappings[1].composite_figi.as_deref(), Some("BBG000B9XRY4"));
assert_eq!(
mappings[0].share_class_figi.as_deref(),
Some("BBG001S5N8V8")
);
}
#[tokio::test]
async fn a_length_mismatch_is_rejected_rather_than_mispaired() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/mapping")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!([{ "warning": "No identifier found." }]).to_string())
.create_async()
.await;
let jobs = vec![
MappingJob {
id_type: "ID_CUSIP",
id_value: "037833100",
},
MappingJob {
id_type: "ID_CUSIP",
id_value: "594918104",
},
];
let err = test_client(&server.url()).map(&jobs).await.unwrap_err();
assert!(
matches!(err, FinanceError::ResponseStructureError { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn an_oversized_batch_explains_the_limit() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/mapping")
.with_status(413)
.create_async()
.await;
let jobs = vec![MappingJob {
id_type: "ID_CUSIP",
id_value: "037833100",
}];
let err = test_client(&server.url()).map(&jobs).await.unwrap_err();
match err {
FinanceError::InvalidParameter { reason, .. } => {
assert!(reason.contains("at most 10"), "got {reason}");
}
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
#[tokio::test]
async fn rate_limit_maps_to_rate_limited() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/mapping")
.with_status(429)
.create_async()
.await;
let jobs = vec![MappingJob {
id_type: "ID_CUSIP",
id_value: "037833100",
}];
let err = test_client(&server.url()).map(&jobs).await.unwrap_err();
assert!(matches!(err, FinanceError::RateLimited { .. }), "{err:?}");
}
#[tokio::test]
async fn resolving_nothing_makes_no_request() {
assert!(
resolve_many(SecurityIdKind::Cusip, &[])
.await
.unwrap()
.is_empty()
);
}
}