Skip to main content

rhood_core/
resolver_cache.rs

1//! In-memory caches for identity/metadata lookups.
2//!
3//! See [`ResolverCache`] for the full policy. Financial data is **never**
4//! stored here, only immutable identifiers and metadata that the Robinhood
5//! API returns repeatedly over the lifetime of a client.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use moka::future::Cache;
11use tokio::sync::OnceCell;
12
13use crate::config::CacheConfig;
14use crate::models::futures::FuturesContract;
15use crate::models::stock::{IndexInstrument, Instrument};
16
17/// Per-client caches backing symbol↔id resolution.
18///
19/// Contents are cloneable because all fields are reference-counted internally
20/// (moka caches clone by bumping an atomic, [`OnceCell`] is shared via
21/// [`Arc`]). Dropping the last clone releases the allocations.
22///
23/// # Policy
24///
25/// The cache stores only identity and metadata lookups that are effectively
26/// immutable over the server lifetime:
27///
28/// - `symbol → Instrument` (equity metadata)
29/// - `uuid → symbol` (reverse lookup for enrichment)
30/// - `symbol → IndexInstrument`
31/// - `symbol → FuturesContract`
32/// - The singleton futures account id
33///
34/// Financial data such as quotes, prices, candles, positions, orders, fundamentals,
35/// news, ratings are **never** stored here.
36#[derive(Clone)]
37pub struct ResolverCache {
38    pub(crate) enabled: bool,
39    pub(crate) enrichment_batch_size: usize,
40    pub(crate) instruments_by_symbol: Cache<String, Arc<Instrument>>,
41    pub(crate) instruments_by_id: Cache<String, String>,
42    pub(crate) index_instruments: Cache<String, Arc<IndexInstrument>>,
43    pub(crate) futures_contracts: Cache<String, Arc<FuturesContract>>,
44    pub(crate) futures_account_id: Arc<OnceCell<String>>,
45}
46
47impl ResolverCache {
48    /// Builds a cache from a configuration.
49    ///
50    /// The returned value is cheap to clone and share across
51    /// [`RobinhoodClient`](crate::RobinhoodClient) clones: every field is
52    /// internally reference-counted.
53    pub fn from_config(config: &CacheConfig) -> Self {
54        let instruments_by_symbol = Cache::builder()
55            .max_capacity(config.instrument_max_entries)
56            .time_to_live(Duration::from_secs(config.instrument_ttl_secs))
57            .build();
58        let instruments_by_id = Cache::builder()
59            .max_capacity(config.instrument_id_max_entries)
60            .time_to_live(Duration::from_secs(config.instrument_id_ttl_secs))
61            .build();
62        let index_instruments = Cache::builder()
63            .max_capacity(config.index_max_entries)
64            .time_to_live(Duration::from_secs(config.index_ttl_secs))
65            .build();
66        let futures_contracts = Cache::builder()
67            .max_capacity(config.futures_max_entries)
68            .time_to_live(Duration::from_secs(config.futures_ttl_secs))
69            .build();
70        Self {
71            enabled: config.enabled,
72            enrichment_batch_size: config.enrichment_batch_size,
73            instruments_by_symbol,
74            instruments_by_id,
75            index_instruments,
76            futures_contracts,
77            futures_account_id: Arc::new(OnceCell::new()),
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[tokio::test]
87    async fn from_config_builds_empty_caches() {
88        let cache = ResolverCache::from_config(&CacheConfig::default());
89        assert!(cache.enabled);
90        assert_eq!(cache.instruments_by_symbol.entry_count(), 0);
91        assert_eq!(cache.instruments_by_id.entry_count(), 0);
92        assert_eq!(cache.index_instruments.entry_count(), 0);
93        assert_eq!(cache.futures_contracts.entry_count(), 0);
94        assert!(cache.futures_account_id.get().is_none());
95    }
96
97    #[tokio::test]
98    async fn disabled_flag_propagates() {
99        let cfg = CacheConfig {
100            enabled: false,
101            ..CacheConfig::default()
102        };
103        let cache = ResolverCache::from_config(&cfg);
104        assert!(!cache.enabled);
105    }
106}