Skip to main content

flowsurface_data/
metadata.rs

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
14/// Cache is background-refreshed when older than this.
15const MARKET_METADATA_CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
16
17/// Metadata as persisted on disk. The ticker is stored once, as the map key,
18/// instead of being duplicated inside every value.
19#[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/// Freshness is tracked per venue, so one failed venue can never be masked by
48/// successful fetches of another.
49#[derive(Serialize, Deserialize)]
50struct VenueCacheFile {
51    #[serde(default)]
52    last_updated: DateTime<Utc>,
53    tickers: HashMap<Ticker, Option<MetadataCacheInfo>>,
54}
55
56/// Origin of a metadata result. Network results are authoritative and always
57/// applied; cache seeds carry the on-disk freshness stamp they were persisted
58/// with, so a seed can never overwrite a newer result that already landed.
59#[derive(Clone, Copy, Debug)]
60pub enum MetadataSource {
61    /// A fresh network fetch result.
62    Fresh,
63    /// A cache seed served from disk, with the venue's persisted freshness.
64    /// `refresh_pending` marks a background network refresh queued behind the
65    /// seed; the venue's fetch stays in-flight until that refresh lands.
66    Cached {
67        fetched_at: DateTime<Utc>,
68        refresh_pending: bool,
69    },
70}
71
72/// The app's market metadata: the fetched ticker info, the cache toggle, the
73/// freshness stamps, and the per-venue in-flight set. Freshness is tracked per
74/// venue, so one failed venue can never be masked by successful fetches of
75/// another, and a missing stamp counts as stale (which drives a fetch).
76#[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    /// Create with the cache toggle from saved settings, loading the disk
86    /// cache when it is enabled. Stale entries are still served and refreshed
87    /// in the background instead of blocking startup.
88    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    /// Entries available for a venue — session-fetched or cache-seeded —
114    /// regardless of the cache toggle. Used for startup cache hits and for
115    /// deciding whether a failed refresh can degrade gracefully.
116    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    /// The freshness stamp persisted for a venue, if any.
125    pub fn last_updated_for(&self, venue: Venue) -> Option<DateTime<Utc>> {
126        self.last_updated.get(&venue).copied()
127    }
128
129    /// The most recent freshness stamp among the given venues, if any.
130    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    /// Whether a venue's metadata is missing or too old to trust. This is the
139    /// single rule that decides when a (re)fetch is needed; a missing stamp
140    /// counts as stale so the venue gets fetched.
141    pub fn needs_fetch(&self, venue: Venue) -> bool {
142        !self.is_venue_fresh(venue)
143    }
144
145    /// Mark a metadata fetch as started for a venue. Returns `false` if one is
146    /// already in flight, so concurrent fetches for the same venue are
147    /// impossible by construction.
148    pub fn begin_fetch(&mut self, venue: Venue) -> bool {
149        self.in_flight.insert(venue)
150    }
151
152    /// Mark a metadata fetch as finished for a venue, whether it succeeded or
153    /// failed. The freshness stamp is untouched by failure: a failed refresh
154    /// leaves the previous stamp in place, so `needs_fetch` keeps driving
155    /// retries.
156    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    /// Apply a metadata result for a venue, stamping the venue's freshness.
169    ///
170    /// Single ingest rule: fresh network results always apply; cache seeds
171    /// apply only when at least as new as the current stamp. A seed landing
172    /// after a newer result (e.g. a network fetch that already completed) is
173    /// skipped instead of clobbering it. Empty results never apply, so a
174    /// degraded fetch can't renew the freshness window with nothing.
175    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    /// Merge this snapshot into the on-disk cache and persist it, newest
210    /// entries winning per venue. Returns whether the data was persisted.
211    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    /// Per-venue merge where the side with the newer `last_updated` stamp
239    /// wins. Writes are serialized by the app, so this reconciles against the
240    /// file left by an earlier session instead of clobbering it.
241    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    /// Load the cache from disk. Returns `None` if the file is missing,
269    /// unreadable, or fails to parse.
270    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}