1use crate::write_json_to_file;
2use exchange::adapter::Venue;
3use exchange::unit::{ContractSize, MinQtySize, MinTicksize};
4use exchange::{Ticker, TickerInfo};
5
6use chrono::{DateTime, Utc};
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::collections::HashMap;
10use std::time::Duration;
11
12const MARKET_METADATA_CACHE_PATH: &str = "metadata-cache.json";
13
14const MARKET_METADATA_CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
16
17#[derive(Serialize, Deserialize)]
20struct MetadataCacheInfo {
21 min_ticksize: MinTicksize,
22 min_qty: MinQtySize,
23 contract_size: Option<ContractSize>,
24}
25
26impl From<TickerInfo> for MetadataCacheInfo {
27 fn from(info: TickerInfo) -> Self {
28 Self {
29 min_ticksize: info.min_ticksize,
30 min_qty: info.min_qty,
31 contract_size: info.contract_size,
32 }
33 }
34}
35
36impl MetadataCacheInfo {
37 fn into_ticker_info(self, ticker: Ticker) -> TickerInfo {
38 TickerInfo {
39 ticker,
40 min_ticksize: self.min_ticksize,
41 min_qty: self.min_qty,
42 contract_size: self.contract_size,
43 }
44 }
45}
46
47#[derive(Serialize, Deserialize)]
50struct VenueCacheFile {
51 #[serde(default)]
52 last_updated: DateTime<Utc>,
53 tickers: HashMap<Ticker, Option<MetadataCacheInfo>>,
54}
55
56#[derive(Clone, Copy, Debug)]
60pub enum MetadataSource {
61 Fresh,
63 Cached {
67 fetched_at: DateTime<Utc>,
68 refresh_pending: bool,
69 },
70}
71
72#[derive(Default, Clone)]
77pub struct MarketMetadata {
78 cache_enabled: bool,
79 tickers: FxHashMap<Ticker, Option<TickerInfo>>,
80 last_updated: FxHashMap<Venue, DateTime<Utc>>,
81 in_flight: FxHashSet<Venue>,
82}
83
84impl MarketMetadata {
85 pub fn with_cache_enabled(cache_enabled: bool) -> Self {
89 let mut this = cache_enabled.then(Self::load).flatten().unwrap_or_default();
90 this.cache_enabled = cache_enabled;
91 this
92 }
93
94 pub fn set_cache_enabled(&mut self, enabled: bool) {
95 if enabled
96 && self.tickers.is_empty()
97 && self.last_updated.is_empty()
98 && let Some(loaded) = Self::load()
99 {
100 *self = loaded;
101 }
102 self.cache_enabled = enabled;
103 }
104
105 pub fn cache_enabled(&self) -> bool {
106 self.cache_enabled
107 }
108
109 pub fn tickers(&self) -> &FxHashMap<Ticker, Option<TickerInfo>> {
110 &self.tickers
111 }
112
113 pub fn usable_entries_for(&self, venue: Venue) -> HashMap<Ticker, Option<TickerInfo>> {
117 self.tickers
118 .iter()
119 .filter(|(ticker, info)| ticker.exchange.venue() == venue && info.is_some())
120 .map(|(ticker, info)| (*ticker, *info))
121 .collect()
122 }
123
124 pub fn last_updated_for(&self, venue: Venue) -> Option<DateTime<Utc>> {
126 self.last_updated.get(&venue).copied()
127 }
128
129 pub fn latest_update_for(&self, venues: &FxHashSet<Venue>) -> Option<DateTime<Utc>> {
131 self.last_updated
132 .iter()
133 .filter(|(venue, _)| venues.contains(venue))
134 .map(|(_, updated)| *updated)
135 .max()
136 }
137
138 pub fn needs_fetch(&self, venue: Venue) -> bool {
142 !self.is_venue_fresh(venue)
143 }
144
145 pub fn begin_fetch(&mut self, venue: Venue) -> bool {
149 self.in_flight.insert(venue)
150 }
151
152 pub fn complete_fetch(&mut self, venue: Venue) {
157 self.in_flight.remove(&venue);
158 }
159
160 pub fn is_in_flight(&self, venue: Venue) -> bool {
161 self.in_flight.contains(&venue)
162 }
163
164 pub fn any_in_flight(&self) -> bool {
165 !self.in_flight.is_empty()
166 }
167
168 pub fn ingest(
176 &mut self,
177 venue: Venue,
178 entries: &HashMap<Ticker, Option<TickerInfo>>,
179 source: MetadataSource,
180 ) {
181 if entries.is_empty() {
182 return;
183 }
184
185 let fetched_at = match source {
186 MetadataSource::Fresh => Utc::now(),
187 MetadataSource::Cached { fetched_at, .. } => fetched_at,
188 };
189
190 let applies = match source {
191 MetadataSource::Fresh => true,
192 MetadataSource::Cached { fetched_at, .. } => self
193 .last_updated
194 .get(&venue)
195 .is_none_or(|stamp| fetched_at >= *stamp),
196 };
197
198 if !applies {
199 return;
200 }
201
202 self.tickers
203 .retain(|ticker, _| ticker.exchange.venue() != venue);
204 self.tickers
205 .extend(entries.iter().map(|(ticker, info)| (*ticker, *info)));
206 self.last_updated.insert(venue, fetched_at);
207 }
208
209 pub fn save_to_file(&self) -> bool {
212 let snapshot = match Self::disk_cache() {
213 Some(disk) => self.merged_with(disk),
214 None => self.clone(),
215 };
216
217 match serde_json::to_string(&snapshot) {
218 Ok(json) => match write_json_to_file(&json, MARKET_METADATA_CACHE_PATH) {
219 Ok(()) => true,
220 Err(e) => {
221 log::warn!("Failed to write market metadata cache: {e}");
222 false
223 }
224 },
225 Err(e) => {
226 log::warn!("Failed to serialize market metadata cache: {e}");
227 false
228 }
229 }
230 }
231
232 fn disk_cache() -> Option<Self> {
233 let path = crate::data_path(Some(MARKET_METADATA_CACHE_PATH));
234 let contents = std::fs::read_to_string(&path).ok()?;
235 serde_json::from_str(&contents).ok()
236 }
237
238 fn merged_with(&self, disk: MarketMetadata) -> Self {
242 let mut merged = self.clone();
243
244 for (venue, disk_updated) in &disk.last_updated {
245 let newer_on_disk = merged
246 .last_updated
247 .get(venue)
248 .is_none_or(|snapshot_updated| *disk_updated > *snapshot_updated);
249
250 if newer_on_disk {
251 merged.last_updated.insert(*venue, *disk_updated);
252 merged
253 .tickers
254 .retain(|ticker, _| ticker.exchange.venue() != *venue);
255 for (ticker, info) in disk
256 .tickers
257 .iter()
258 .filter(|(ticker, _)| ticker.exchange.venue() == *venue)
259 {
260 merged.tickers.insert(*ticker, *info);
261 }
262 }
263 }
264
265 merged
266 }
267
268 fn load() -> Option<Self> {
271 let path = crate::data_path(Some(MARKET_METADATA_CACHE_PATH));
272
273 let contents = match std::fs::read_to_string(&path) {
274 Ok(contents) => contents,
275 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
276 Err(e) => {
277 log::warn!("Failed to read market metadata cache: {e}");
278 return None;
279 }
280 };
281
282 let cache: MarketMetadata = match serde_json::from_str(&contents) {
283 Ok(cache) => cache,
284 Err(e) => {
285 log::warn!("Failed to parse market metadata cache: {e}");
286 let backup = path.with_extension("old.json");
287 if let Err(rename_err) = std::fs::rename(&path, &backup) {
288 log::warn!("Failed to quarantine corrupt market metadata cache: {rename_err}");
289 }
290 return None;
291 }
292 };
293 Some(cache)
294 }
295
296 fn is_venue_fresh(&self, venue: Venue) -> bool {
297 self.last_updated
298 .get(&venue)
299 .and_then(|updated| Utc::now().signed_duration_since(*updated).to_std().ok())
300 .is_some_and(|age| age < MARKET_METADATA_CACHE_MAX_AGE)
301 }
302}
303
304#[derive(Serialize, Deserialize)]
305struct MetadataCacheFile {
306 venues: HashMap<Venue, VenueCacheFile>,
307}
308
309impl Serialize for MarketMetadata {
310 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
311 let mut venues: HashMap<Venue, VenueCacheFile> = HashMap::default();
312 for (ticker, info) in &self.tickers {
313 let venue = ticker.exchange.venue();
314 venues
315 .entry(venue)
316 .or_insert_with(|| VenueCacheFile {
317 last_updated: self.last_updated.get(&venue).copied().unwrap_or_default(),
318 tickers: HashMap::default(),
319 })
320 .tickers
321 .insert(*ticker, info.map(MetadataCacheInfo::from));
322 }
323
324 MetadataCacheFile { venues }.serialize(serializer)
325 }
326}
327
328impl<'de> Deserialize<'de> for MarketMetadata {
329 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
330 let file = MetadataCacheFile::deserialize(deserializer)?;
331
332 let mut tickers = FxHashMap::default();
333 let mut last_updated = FxHashMap::default();
334 for (venue, entry) in file.venues {
335 last_updated.insert(venue, entry.last_updated);
336 for (ticker, info) in entry.tickers {
337 tickers.insert(ticker, info.map(|info| info.into_ticker_info(ticker)));
338 }
339 }
340
341 Ok(Self {
342 tickers,
343 last_updated,
344 ..Default::default()
345 })
346 }
347}