Skip to main content

fynd_core/feed/
market_data.rs

1//! Shared market data structure.
2//!
3//! This is the single source of truth for all market data.
4//! It's protected by a RwLock and shared across all components:
5//! - TychoIndexer: WRITE access to update data
6//! - Solvers: READ access to query states during solving
7//!
8//! We use tokio RwLock (which is write-preferring) to avoid writer starvation.
9//!
10//! # Overlay design
11//!
12//! Labeled overlay states (used by solver components to inject per-request component states) are
13//! stored in a separate `Arc<RwLock<...>>` on `MarketData` rather than inside the main
14//! `MarketState` lock. This decouples overlay writes from base-state reads: a TychoFeed block
15//! update no longer stalls overlay registrations and vice versa.
16
17use std::sync::Arc;
18
19use rustc_hash::{FxHashMap, FxHashSet};
20use tokio::sync::RwLock;
21use tycho_simulation::{
22    tycho_client::feed::SynchronizerState,
23    tycho_common::{
24        models::{protocol::ProtocolComponent, token::Token, Address},
25        simulation::protocol_sim::ProtocolSim,
26    },
27    tycho_ethereum::gas::BlockGasPrice,
28};
29
30use crate::types::{BlockInfo, ComponentId};
31
32/// A label identifying an overlay state layer.
33///
34/// Each labeled overlay is an independent snapshot of component states that can be layered
35/// on top of the base market state for a specific worker component or request context.
36pub type StateLabel = String;
37
38/// An immutable snapshot of per-component simulation states for one overlay layer.
39pub type OverlayStates = Arc<FxHashMap<ComponentId, Box<dyn ProtocolSim>>>;
40
41/// A named simulation-state overlay with a block-number expiry.
42pub struct OverlayEntry {
43    /// The overlay component states (only components that differ from base state).
44    pub states: OverlayStates,
45    /// Last block number for which this overlay is valid.
46    /// The overlay is automatically evicted before block `valid_until + 1` is applied.
47    pub valid_until: u64,
48}
49
50/// The shared overlay registry: maps each label to its snapshot.
51type OverlayRegistry = Arc<RwLock<FxHashMap<StateLabel, OverlayEntry>>>;
52
53/// Error returned by [`MarketData::read_labeled`] when the requested label cannot be resolved.
54#[derive(Debug, thiserror::Error)]
55pub enum ReadLabeledError {
56    /// The label is not registered as an overlay and does not match the current base-state label.
57    #[error("label not found: {0}")]
58    NotFound(StateLabel),
59}
60
61/// The main entry point for accessing market data.
62///
63/// Cloning is cheap — all clones share the same underlying data and overlay registry.
64/// Pass an optional label to `read` to scope the view to a specific overlay.
65#[derive(Clone)]
66pub struct MarketData {
67    data: Arc<RwLock<MarketState>>,
68    /// Per-label overlay states. Stored separately from the base data lock so that
69    /// overlay writes do not block base-state reads.
70    overlays: OverlayRegistry,
71}
72
73impl MarketData {
74    /// Creates a new handle wrapping the given data store.
75    pub fn new(data: Arc<RwLock<MarketState>>) -> Self {
76        Self { data, overlays: Arc::new(RwLock::new(FxHashMap::default())) }
77    }
78
79    /// Creates a new empty market data store wrapped in a `MarketData`.
80    pub fn new_shared() -> Self {
81        Self::new(Arc::new(RwLock::new(MarketState::new())))
82    }
83
84    /// Acquires a base view of the market data with no overlay applied.
85    pub async fn read(&self) -> MarketDataView<'_> {
86        MarketDataView { guard: self.data.read().await, overlay: None }
87    }
88
89    /// Acquires an overlay-aware view scoped to `label`.
90    ///
91    /// Succeeds when `label` is registered as an overlay **or** matches the current base-state
92    /// label (the block-number string set by `apply_block_update`). Returns
93    /// [`ReadLabeledError::NotFound`] otherwise so callers cannot silently fall back to stale data.
94    ///
95    /// The overlay lock is held only briefly to clone the snapshot pointer; it is released
96    /// before the view is returned, so solving never holds two locks simultaneously.
97    pub async fn read_labeled(
98        &self,
99        label: &StateLabel,
100    ) -> Result<MarketDataView<'_>, ReadLabeledError> {
101        let guard = self.data.read().await;
102        if let Some(e) = self.overlays.read().await.get(label) {
103            let states = Arc::clone(&e.states);
104            return Ok(MarketDataView { guard, overlay: Some((label.clone(), states)) });
105        }
106        if &guard.label == label {
107            return Ok(MarketDataView { guard, overlay: None });
108        }
109        Err(ReadLabeledError::NotFound(label.clone()))
110    }
111
112    /// Acquires an exclusive write guard on the base data store.
113    pub async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, MarketState> {
114        self.data.write().await
115    }
116
117    /// Attempts a non-blocking read of the base data store.
118    ///
119    /// Returns `None` if the lock is currently held for writing.
120    pub fn try_read(&self) -> Option<tokio::sync::RwLockReadGuard<'_, MarketState>> {
121        self.data.try_read().ok()
122    }
123
124    /// Attempts a non-blocking write lock on the base data store.
125    ///
126    /// Returns `None` if the lock is currently held for reading or writing.
127    pub fn try_write(&self) -> Option<tokio::sync::RwLockWriteGuard<'_, MarketState>> {
128        self.data.try_write().ok()
129    }
130
131    /// Attempts a non-blocking read and wraps the result in a `MarketDataView`.
132    ///
133    /// The overlay is not applied, so this only exposes the base market state. Suitable for
134    /// callers that read base data (e.g. token decimals) and do not depend on overlay state,
135    /// such as the quote price-impact fallback. Returns `None` if the lock is currently held
136    /// for writing (callers must treat that as "data unavailable", not an error).
137    pub fn try_read_blocking(&self) -> Option<MarketDataView<'_>> {
138        self.data
139            .try_read()
140            .ok()
141            .map(|guard| MarketDataView { guard, overlay: None })
142    }
143
144    // ==================== Overlay CRUD ====================
145
146    /// Registers or replaces an overlay for the given label.
147    pub async fn register_labeled_state(
148        &self,
149        label: StateLabel,
150        states: FxHashMap<ComponentId, Box<dyn ProtocolSim>>,
151        valid_until: u64,
152    ) {
153        self.overlays
154            .write()
155            .await
156            .insert(label, OverlayEntry { states: Arc::new(states), valid_until });
157    }
158
159    /// Removes the overlay for the given label, if it exists.
160    pub async fn remove_labeled_state(&self, label: &StateLabel) {
161        self.overlays
162            .write()
163            .await
164            .remove(label);
165    }
166
167    /// Clears all overlays.
168    pub async fn clear_labeled_states(&self) {
169        self.overlays.write().await.clear();
170    }
171
172    /// Atomically evicts stale overlays then applies a block update to base state.
173    ///
174    /// Overlays with `valid_until < new_block_number` are removed under the overlay
175    /// lock before the base write lock is acquired. This guarantees no solver can
176    /// observe new base state alongside an overlay that was built against the previous
177    /// block.
178    pub async fn apply_block_update(
179        &self,
180        new_block_number: u64,
181        update: impl FnOnce(&mut MarketState),
182    ) {
183        self.overlays
184            .write()
185            .await
186            .retain(|_, entry| entry.valid_until >= new_block_number);
187        let mut data = self.data.write().await;
188        data.label = new_block_number.to_string();
189        update(&mut data);
190    }
191
192    /// Returns the labels of all registered overlays.
193    pub async fn labeled_state_ids(&self) -> Vec<StateLabel> {
194        self.overlays
195            .read()
196            .await
197            .keys()
198            .cloned()
199            .collect()
200    }
201}
202
203/// An overlay-aware view of the market data, held for the duration of a read lock.
204///
205/// Holds a read lock on the base `MarketState` and an optional overlay snapshot.
206/// Use `get_simulation_state` for overlay-aware component lookups. All other accessors
207/// delegate to the base data.
208pub struct MarketDataView<'a> {
209    guard: tokio::sync::RwLockReadGuard<'a, MarketState>,
210    overlay: Option<(StateLabel, OverlayStates)>,
211}
212
213impl<'a> MarketDataView<'a> {
214    /// Returns the label identifying the active overlay, or `None` if no overlay is in effect.
215    pub fn state_label(&self) -> Option<&StateLabel> {
216        self.overlay
217            .as_ref()
218            .map(|(label, _)| label)
219    }
220
221    /// Returns the simulation state for the given component, checking the overlay first.
222    pub fn get_simulation_state(&self, id: &str) -> Option<&dyn ProtocolSim> {
223        if let Some((_, ref states)) = self.overlay {
224            if let Some(s) = states.get(id) {
225                return Some(s.as_ref());
226            }
227        }
228        self.guard.get_simulation_state(id)
229    }
230
231    /// Extracts a base-data subset for the given component IDs, then layers the active overlay
232    /// on top by replacing any simulation states found in both the subset and the overlay.
233    ///
234    /// If no overlay is active, this is equivalent to `self.extract_subset(component_ids)`.
235    pub fn extract_subset_with_overlay(
236        &self,
237        component_ids: &FxHashSet<&ComponentId>,
238    ) -> MarketState {
239        let mut subset = self.guard.extract_subset(component_ids);
240        if let Some((ref label, ref states)) = self.overlay {
241            for (id, state) in states.iter() {
242                if subset
243                    .simulation_states
244                    .contains_key(id)
245                {
246                    subset
247                        .simulation_states
248                        .insert(id.clone(), state.clone_box());
249                }
250            }
251            subset.label = label.clone();
252        }
253        subset
254    }
255
256    /// Returns the component topology from the base data.
257    pub fn component_topology(&self) -> FxHashMap<ComponentId, Vec<Address>> {
258        self.guard.component_topology()
259    }
260
261    /// Extracts a base-data subset for the given component IDs (no overlay applied).
262    pub fn extract_subset(&self, component_ids: &FxHashSet<&ComponentId>) -> MarketState {
263        self.guard.extract_subset(component_ids)
264    }
265
266    /// Returns a reference to the token registry from the base data.
267    pub fn token_registry_ref(&self) -> &FxHashMap<Address, Arc<Token>> {
268        self.guard.token_registry_ref()
269    }
270
271    /// Returns the current gas price from the base data.
272    pub fn gas_price(&self) -> Option<&BlockGasPrice> {
273        self.guard.gas_price()
274    }
275
276    /// Returns the block info for the last base-state update.
277    pub fn last_updated(&self) -> Option<&BlockInfo> {
278        self.guard.last_updated()
279    }
280
281    /// Returns a token by address from the base data.
282    pub fn get_token(&self, address: &Address) -> Option<&Token> {
283        self.guard.get_token(address)
284    }
285
286    /// Returns a token by address from the base data, to be held rather than copied.
287    pub fn get_token_shared(&self, address: &Address) -> Option<&Arc<Token>> {
288        self.guard.get_token_shared(address)
289    }
290
291    /// Returns a component by ID from the base data.
292    pub fn get_component(&self, id: &str) -> Option<&ProtocolComponent> {
293        self.guard.get_component(id)
294    }
295
296    /// Returns a reference to the underlying base market state, bypassing any overlay.
297    pub fn base_market_state(&self) -> &MarketState {
298        &self.guard
299    }
300}
301
302/// Shared market data containing all component states and market information.
303///
304/// This struct is the single source of truth for market data.
305/// The indexer updates it, and solvers read from it.
306#[derive(Debug, Default)]
307pub struct MarketState {
308    /// Identifies the block or overlay this state was produced from.
309    ///
310    /// Set to the block number string by `apply_block_update`; copied from the overlay label by
311    /// `extract_subset_with_overlay` when an overlay is active. Empty string until the first block
312    /// is applied.
313    label: StateLabel,
314    /// All components indexed by their ID.
315    components: FxHashMap<ComponentId, Arc<ProtocolComponent>>,
316    /// All states indexed by their component ID.
317    simulation_states: FxHashMap<ComponentId, Box<dyn ProtocolSim>>,
318    /// All tokens indexed by their address. Shared for the same reason as `components`.
319    tokens: FxHashMap<Address, Arc<Token>>,
320    /// Current gas price. None if not fetched yet.
321    gas_price: Option<BlockGasPrice>,
322    /// Protocol sync status indexed by their protocol system name.
323    protocol_sync_status: FxHashMap<String, SynchronizerState>,
324    /// Block info for the last update (only updated when protocols reported "Ready" status).
325    /// None if no block has been processed yet.
326    last_updated: Option<BlockInfo>,
327    /// Number of components per protocol system, maintained incrementally on
328    /// upsert/remove so readers never scan the full component map.
329    component_counts: FxHashMap<String, u64>,
330}
331
332impl MarketState {
333    /// Creates a new empty MarketState.
334    pub fn new() -> Self {
335        Self {
336            label: String::new(),
337            components: FxHashMap::default(),
338            simulation_states: FxHashMap::default(),
339            tokens: FxHashMap::default(),
340            gas_price: None,
341            protocol_sync_status: FxHashMap::default(),
342            last_updated: None,
343            component_counts: FxHashMap::default(),
344        }
345    }
346
347    /// Returns the label identifying the block or overlay this state was produced from.
348    pub fn label(&self) -> &StateLabel {
349        &self.label
350    }
351
352    /// Returns the block info for the last update.
353    pub fn last_updated(&self) -> Option<&BlockInfo> {
354        self.last_updated.as_ref()
355    }
356
357    /// Number of protocol components (components) currently tracked.
358    pub fn component_count(&self) -> usize {
359        self.components.len()
360    }
361
362    /// Number of tokens currently tracked.
363    pub fn token_count(&self) -> usize {
364        self.tokens.len()
365    }
366
367    /// Number of components (components) per protocol system.
368    ///
369    /// Entries stay present at zero after all of a protocol's components are
370    /// removed so exported gauges reset instead of freezing at the last value.
371    pub fn component_counts_by_protocol(&self) -> &FxHashMap<String, u64> {
372        &self.component_counts
373    }
374
375    /// Returns the sync status of every protocol system.
376    pub fn protocol_sync_states(&self) -> &FxHashMap<String, SynchronizerState> {
377        &self.protocol_sync_status
378    }
379
380    /// Returns the protocol sync status indexed by their protocol system name.
381    pub fn get_protocol_sync_status(&self, protocol_system: &String) -> Option<&SynchronizerState> {
382        self.protocol_sync_status
383            .get(protocol_system)
384    }
385
386    /// Returns the component topology.
387    /// This is a simple mapping from component ID to their token addresses.
388    pub fn component_topology(&self) -> FxHashMap<ComponentId, Vec<Address>> {
389        self.components
390            .iter()
391            .map(|(id, component)| (id.clone(), component.tokens.clone()))
392            .collect()
393    }
394
395    /// Gets a component by ID.
396    pub fn get_component(&self, id: &str) -> Option<&ProtocolComponent> {
397        self.components.get(id).map(Arc::as_ref)
398    }
399
400    /// Gets a component by ID as a shared handle, for callers that need to keep it.
401    pub fn get_component_shared(&self, id: &str) -> Option<&Arc<ProtocolComponent>> {
402        self.components.get(id)
403    }
404
405    /// Gets a simulation state by ID.
406    pub fn get_simulation_state(&self, id: &str) -> Option<&dyn ProtocolSim> {
407        self.simulation_states
408            .get(id)
409            .map(|b| b.as_ref())
410    }
411
412    /// Gets a token by address.
413    pub fn get_token(&self, address: &Address) -> Option<&Token> {
414        self.tokens
415            .get(address)
416            .map(Arc::as_ref)
417    }
418
419    /// Gets a token as a shared handle, for callers that need to keep it.
420    pub fn get_token_shared(&self, address: &Address) -> Option<&Arc<Token>> {
421        self.tokens.get(address)
422    }
423
424    /// Returns the current gas price. None if not fetched yet.
425    pub fn gas_price(&self) -> Option<&BlockGasPrice> {
426        self.gas_price.as_ref()
427    }
428
429    /// Returns a reference to the token registry.
430    pub fn token_registry_ref(&self) -> &FxHashMap<Address, Arc<Token>> {
431        &self.tokens
432    }
433
434    /// Inserts or updates a component.
435    pub fn upsert_components(&mut self, components: impl IntoIterator<Item = ProtocolComponent>) {
436        for component in components {
437            let protocol_system = component.protocol_system.clone();
438            let previous = self
439                .components
440                .insert(component.id.clone(), Arc::new(component));
441            if previous.is_none() {
442                *self
443                    .component_counts
444                    .entry(protocol_system)
445                    .or_default() += 1;
446            }
447        }
448    }
449
450    /// Inserts or updates tokens.
451    pub fn upsert_tokens(&mut self, tokens: impl IntoIterator<Item = Token>) {
452        for token in tokens {
453            self.tokens
454                .insert(token.address.clone(), Arc::new(token));
455        }
456    }
457
458    /// Updates the protocol sync status.
459    pub fn update_protocol_sync_status(
460        &mut self,
461        sync_states: impl IntoIterator<Item = (String, SynchronizerState)>,
462    ) {
463        for (protocol_system, status) in sync_states {
464            self.protocol_sync_status
465                .insert(protocol_system, status);
466        }
467    }
468
469    /// Removes a component.
470    pub fn remove_components<'a>(&mut self, ids: impl IntoIterator<Item = &'a ComponentId>) {
471        for id in ids {
472            if let Some(component) = self.components.remove(id) {
473                if let Some(count) = self
474                    .component_counts
475                    .get_mut(&component.protocol_system)
476                {
477                    *count = count.saturating_sub(1);
478                }
479            }
480            self.simulation_states.remove(id);
481        }
482    }
483
484    /// Updates a component's state.
485    pub fn update_states(
486        &mut self,
487        states: impl IntoIterator<Item = (ComponentId, Box<dyn ProtocolSim>)>,
488    ) {
489        for (id, state) in states {
490            self.simulation_states.insert(id, state);
491        }
492    }
493
494    /// Updates the gas price.
495    pub fn update_gas_price(&mut self, gas_price: BlockGasPrice) {
496        self.gas_price = Some(gas_price);
497    }
498
499    /// Updates the last updated block info.
500    pub fn update_last_updated(&mut self, block_info: BlockInfo) {
501        self.last_updated = Some(block_info);
502    }
503
504    /// Creates a filtered subset containing only data needed for the given components.
505    ///
506    /// This is used to create a local snapshot of market data that can be used for
507    /// simulation without holding the main lock. The subset includes:
508    /// - Components matching the provided IDs
509    /// - Simulation states for those components (cloned via `clone_box`)
510    /// - Tokens referenced by those components
511    /// - Gas price and block info
512    pub fn extract_subset(&self, component_ids: &FxHashSet<&ComponentId>) -> MarketState {
513        let mut components =
514            FxHashMap::with_capacity_and_hasher(component_ids.len(), rustc_hash::FxBuildHasher);
515        let mut simulation_states =
516            FxHashMap::with_capacity_and_hasher(component_ids.len(), rustc_hash::FxBuildHasher);
517        // Tokens are shared between components, so this collects addresses first and resolves
518        // them once each rather than per component that mentions them.
519        let mut token_addresses: FxHashSet<&Address> =
520            FxHashSet::with_capacity_and_hasher(component_ids.len() * 2, rustc_hash::FxBuildHasher);
521
522        for &id in component_ids {
523            if let Some(component) = self.components.get(id) {
524                token_addresses.extend(&component.tokens);
525                components.insert(id.clone(), component.clone());
526            }
527            // A component without a simulation state is legitimate: the recording skips `vm:*`
528            // states, and a component can be announced a block before its first state arrives.
529            if let Some(state) = self.simulation_states.get(id) {
530                simulation_states.insert(id.clone(), state.clone_box());
531            }
532        }
533
534        let mut tokens =
535            FxHashMap::with_capacity_and_hasher(token_addresses.len(), rustc_hash::FxBuildHasher);
536        for address in token_addresses {
537            if let Some(token) = self.tokens.get(address) {
538                tokens.insert(address.clone(), token.clone());
539            }
540        }
541
542        MarketState {
543            label: self.label.clone(),
544            components,
545            simulation_states,
546            tokens,
547            gas_price: self.gas_price.clone(),
548            protocol_sync_status: FxHashMap::default(), // Not needed for simulation
549            last_updated: self.last_updated.clone(),
550            component_counts: FxHashMap::default(), // Not needed for simulation
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use num_bigint::BigUint;
558    use tycho_simulation::tycho_ethereum::gas::GasPrice;
559
560    use super::*;
561    use crate::algorithm::test_utils::{
562        component, component_with_protocol, token, MockProtocolSim,
563    };
564
565    #[test]
566    fn component_counts_by_protocol_tracks_upserts_and_removals() {
567        let mut market = MarketState::new();
568        let component_tokens = [token(0x0A, "A"), token(0x0B, "B")];
569
570        market.upsert_components([
571            component_with_protocol("component_1", "uniswap_v2", &component_tokens),
572            component_with_protocol("component_2", "uniswap_v2", &component_tokens),
573            component_with_protocol("component_3", "uniswap_v3", &component_tokens),
574        ]);
575        let counts = market.component_counts_by_protocol();
576        assert_eq!(counts.get("uniswap_v2"), Some(&2));
577        assert_eq!(counts.get("uniswap_v3"), Some(&1));
578
579        // Re-upserting an existing component is an update, not a new component.
580        market.upsert_components([component_with_protocol(
581            "component_1",
582            "uniswap_v2",
583            &component_tokens,
584        )]);
585        assert_eq!(
586            market
587                .component_counts_by_protocol()
588                .get("uniswap_v2"),
589            Some(&2)
590        );
591
592        // Removals decrement; the entry stays at zero so exported gauges reset
593        // instead of freezing at the last non-zero value.
594        let removed_ids = ["component_1".to_string(), "component_3".to_string()];
595        market.remove_components(removed_ids.iter());
596        let counts = market.component_counts_by_protocol();
597        assert_eq!(counts.get("uniswap_v2"), Some(&1));
598        assert_eq!(counts.get("uniswap_v3"), Some(&0));
599
600        // Removing an unknown id leaves counts untouched.
601        let unknown_ids = ["unknown_component".to_string()];
602        market.remove_components(unknown_ids.iter());
603        assert_eq!(
604            market
605                .component_counts_by_protocol()
606                .get("uniswap_v2"),
607            Some(&1)
608        );
609    }
610
611    #[test]
612    fn extract_subset_filters_by_component_ids() {
613        // Setup: market with 2 components (A-B, B-C) and 3 tokens
614        let mut market = MarketState::new();
615
616        let token_a = token(0x0A, "A");
617        let token_b = token(0x0B, "B");
618        let token_c = token(0x0C, "C");
619
620        market.upsert_components([
621            component("component_ab", &[token_a.clone(), token_b.clone()]),
622            component("component_bc", &[token_b.clone(), token_c.clone()]),
623        ]);
624        market.upsert_tokens([token_a.clone(), token_b.clone(), token_c.clone()]);
625        market.update_states([
626            (
627                "component_ab".to_string(),
628                Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
629            ),
630            (
631                "component_bc".to_string(),
632                Box::new(MockProtocolSim::new(3.0)) as Box<dyn ProtocolSim>,
633            ),
634        ]);
635        market.update_gas_price(BlockGasPrice {
636            block_number: 1,
637            block_hash: Default::default(),
638            block_timestamp: 0,
639            pricing: GasPrice::Legacy { gas_price: BigUint::from(1u64) },
640        });
641        market.update_last_updated(BlockInfo::new(12345, "0xabc".to_string(), 0));
642
643        // Extract only component_ab
644        let component_ab = "component_ab".to_string();
645        let ids: FxHashSet<&ComponentId> = [&component_ab].into_iter().collect();
646        let subset = market.extract_subset(&ids);
647
648        // Components: only component_ab
649        assert_eq!(subset.components.len(), 1);
650        assert!(subset
651            .components
652            .contains_key("component_ab"));
653
654        // Tokens: only A and B (referenced by component_ab), not C
655        assert_eq!(subset.tokens.len(), 2);
656        assert!(subset
657            .tokens
658            .contains_key(&token_a.address));
659        assert!(subset
660            .tokens
661            .contains_key(&token_b.address));
662        assert!(!subset
663            .tokens
664            .contains_key(&token_c.address));
665
666        // Simulation states: only component_ab
667        assert_eq!(subset.simulation_states.len(), 1);
668        assert!(subset
669            .simulation_states
670            .contains_key("component_ab"));
671
672        // Gas price and block info are copied
673        assert_eq!(subset.gas_price, market.gas_price);
674        assert!(subset.last_updated.is_some());
675
676        // Empty IDs returns empty subset
677        let empty_subset = market.extract_subset(&FxHashSet::default());
678        assert!(empty_subset.components.is_empty());
679        assert!(empty_subset.tokens.is_empty());
680        assert!(empty_subset
681            .simulation_states
682            .is_empty());
683    }
684
685    // ==================== MarketData overlay tests ====================
686
687    #[tokio::test]
688    async fn register_and_retrieve_overlay_via_labeled_read() {
689        let market_ref = MarketData::new_shared();
690
691        let label = "test_label".to_string();
692        let mut states: FxHashMap<ComponentId, Box<dyn ProtocolSim>> = FxHashMap::default();
693        states.insert(
694            "component_ab".to_string(),
695            Box::new(MockProtocolSim::new(99.0)) as Box<dyn ProtocolSim>,
696        );
697
698        market_ref
699            .register_labeled_state(label.clone(), states, u64::MAX)
700            .await;
701
702        let guard = market_ref
703            .read_labeled(&label)
704            .await
705            .expect("label was just registered");
706        // Base data is empty — overlay provides the state
707        let sim = guard.get_simulation_state("component_ab");
708        assert!(sim.is_some());
709    }
710
711    #[tokio::test]
712    async fn read_without_label_returns_no_overlay() {
713        let market_ref = MarketData::new_shared();
714
715        market_ref
716            .register_labeled_state(
717                "my_label".to_string(),
718                FxHashMap::from_iter([(
719                    "component1".to_string(),
720                    Box::new(MockProtocolSim::new(5.0)) as Box<dyn ProtocolSim>,
721                )]),
722                u64::MAX,
723            )
724            .await;
725
726        // A handle with no label must not see the overlay
727        let guard = market_ref.read().await;
728        assert!(guard
729            .get_simulation_state("component1")
730            .is_none());
731    }
732
733    #[tokio::test]
734    async fn remove_labeled_state_clears_overlay() {
735        let market_ref = MarketData::new_shared();
736        let label = "lbl".to_string();
737
738        market_ref
739            .register_labeled_state(
740                label.clone(),
741                FxHashMap::from_iter([(
742                    "component".to_string(),
743                    Box::new(MockProtocolSim::new(1.0)) as Box<dyn ProtocolSim>,
744                )]),
745                u64::MAX,
746            )
747            .await;
748
749        market_ref
750            .remove_labeled_state(&label)
751            .await;
752
753        let ids = market_ref.labeled_state_ids().await;
754        assert!(ids.is_empty());
755    }
756
757    #[tokio::test]
758    async fn clear_labeled_states_removes_all() {
759        let market_ref = MarketData::new_shared();
760
761        for i in 0..3u8 {
762            market_ref
763                .register_labeled_state(
764                    format!("label_{i}"),
765                    FxHashMap::from_iter([(
766                        format!("component_{i}"),
767                        Box::new(MockProtocolSim::new(f64::from(i))) as Box<dyn ProtocolSim>,
768                    )]),
769                    u64::MAX,
770                )
771                .await;
772        }
773
774        market_ref.clear_labeled_states().await;
775        assert!(market_ref
776            .labeled_state_ids()
777            .await
778            .is_empty());
779    }
780
781    #[tokio::test]
782    async fn clone_shares_overlay_registry() {
783        // Registering via one clone must be visible when reading via any other clone pointing at
784        // the same overlay registry.
785        let base = MarketData::new_shared();
786        let clone_a = base.clone();
787        let clone_b = base.clone();
788
789        base.register_labeled_state(
790            "shared".to_string(),
791            FxHashMap::from_iter([(
792                "component_x".to_string(),
793                Box::new(MockProtocolSim::new(7.0)) as Box<dyn ProtocolSim>,
794            )]),
795            u64::MAX,
796        )
797        .await;
798
799        let label = "shared".to_string();
800        let guard_a = clone_a
801            .read_labeled(&label)
802            .await
803            .expect("label was just registered");
804        assert!(guard_a
805            .get_simulation_state("component_x")
806            .is_some());
807        drop(guard_a);
808
809        let guard_b = clone_b
810            .read_labeled(&label)
811            .await
812            .expect("label was just registered");
813        assert!(guard_b
814            .get_simulation_state("component_x")
815            .is_some());
816    }
817
818    #[tokio::test]
819    async fn extract_subset_with_overlay_replaces_matching_states() {
820        use crate::algorithm::test_utils::{component as mk_component, token as mk_token};
821
822        let market_ref = MarketData::new_shared();
823
824        let tok_a = mk_token(0x01, "A");
825        let tok_b = mk_token(0x02, "B");
826
827        {
828            let mut data = market_ref.write().await;
829            data.upsert_components([mk_component("component_ab", &[tok_a.clone(), tok_b.clone()])]);
830            data.upsert_tokens([tok_a.clone(), tok_b.clone()]);
831            data.update_states([(
832                "component_ab".to_string(),
833                Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
834            )]);
835        }
836
837        let label = "overlay".to_string();
838        market_ref
839            .register_labeled_state(
840                label.clone(),
841                FxHashMap::from_iter([(
842                    "component_ab".to_string(),
843                    Box::new(MockProtocolSim::new(99.0)) as Box<dyn ProtocolSim>,
844                )]),
845                u64::MAX,
846            )
847            .await;
848
849        let guard = market_ref
850            .read_labeled(&label)
851            .await
852            .expect("label was just registered");
853        let component_ab = "component_ab".to_string();
854        let ids: FxHashSet<&ComponentId> = [&component_ab].into_iter().collect();
855        let subset = guard.extract_subset_with_overlay(&ids);
856
857        let sim = subset
858            .get_simulation_state("component_ab")
859            .unwrap();
860        let mock = sim
861            .as_any()
862            .downcast_ref::<MockProtocolSim>()
863            .unwrap();
864        assert_eq!(mock.spot_price, 99.0, "overlay state should replace base state");
865    }
866
867    #[tokio::test]
868    async fn apply_block_update_evicts_stale_overlays() {
869        let market_ref = MarketData::new_shared();
870
871        // Register two overlays: one valid until block 10, one valid until block 20.
872        market_ref
873            .register_labeled_state(
874                "stale".to_string(),
875                FxHashMap::from_iter([(
876                    "component_stale".to_string(),
877                    Box::new(MockProtocolSim::new(1.0)) as Box<dyn ProtocolSim>,
878                )]),
879                10,
880            )
881            .await;
882        market_ref
883            .register_labeled_state(
884                "fresh".to_string(),
885                FxHashMap::from_iter([(
886                    "component_fresh".to_string(),
887                    Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
888                )]),
889                20,
890            )
891            .await;
892
893        // Apply block 11: the "stale" overlay (valid_until=10) must be evicted.
894        market_ref
895            .apply_block_update(11, |_data| {})
896            .await;
897
898        let ids = market_ref.labeled_state_ids().await;
899        assert!(!ids.contains(&"stale".to_string()), "stale overlay must be evicted");
900        assert!(ids.contains(&"fresh".to_string()), "fresh overlay must survive");
901    }
902
903    #[tokio::test]
904    async fn apply_block_update_applies_mutation() {
905        let market_ref = MarketData::new_shared();
906
907        market_ref
908            .apply_block_update(1, |data| {
909                data.update_last_updated(BlockInfo::new(1, "0xabc".to_string(), 0));
910            })
911            .await;
912
913        let guard = market_ref.read().await;
914        assert_eq!(
915            guard
916                .last_updated()
917                .expect("last_updated must be set")
918                .number(),
919            1
920        );
921    }
922
923    #[tokio::test]
924    async fn component_and_token_counts_track_upserts_and_removals() {
925        let market = MarketData::new_shared();
926        let tok_a = token(1, "A");
927        let tok_b = token(2, "B");
928
929        market
930            .apply_block_update(1, |data| {
931                data.upsert_components([component(
932                    "component_ab",
933                    &[tok_a.clone(), tok_b.clone()],
934                )]);
935                data.upsert_tokens([tok_a.clone(), tok_b.clone()]);
936            })
937            .await;
938        {
939            let data = market.read().await;
940            assert_eq!(
941                data.base_market_state()
942                    .component_count(),
943                1
944            );
945            assert_eq!(data.base_market_state().token_count(), 2);
946        }
947
948        market
949            .apply_block_update(2, |data| {
950                data.remove_components(["component_ab".to_string()].iter());
951            })
952            .await;
953        let data = market.read().await;
954        assert_eq!(
955            data.base_market_state()
956                .component_count(),
957            0
958        );
959        assert_eq!(
960            data.base_market_state().token_count(),
961            2,
962            "tokens are not removed with their components"
963        );
964    }
965}