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