From 937fae09f5c7842241d2fd71f101e87c17229eb0 Mon Sep 17 00:00:00 2001 From: JuaniRios Date: Thu, 8 Oct 2026 12:32:08 -0300 Subject: [PATCH] [RAI-2998] Publish per-chain inventory and USDC corridor liq_ series --- SPEC.md | 15 + crates/config/src/rebalancing.rs | 5 + docs/observability.md | 6 + scripts/liq-parity/compare.py | 6 + scripts/liq-parity/testdata/bot.prom | 2 + src/inventory/view.rs | 483 +++++++++++++++++++++++++-- src/lib.rs | 3 +- src/metrics/liquidity.rs | 60 +++- src/metrics/liquidity/inventory.rs | 231 ++++++++++++- src/metrics/liquidity/settings.rs | 156 ++++++++- 10 files changed, 931 insertions(+), 36 deletions(-) diff --git a/SPEC.md b/SPEC.md index da014d05d..ae90a4afd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1763,6 +1763,8 @@ instant), `liq_settings_*` and `liq_asset_*` for every symbol the primary chain lists. An optional setting that is not configured has no series. `liq_asset_extended_hours` is absent while counter trading is disabled. `liq_settings_info{trading_mode}` is always empty, as it was in the exporter. +`liq_usdc_corridor_target{chain}` and `liq_usdc_corridor_deviation{chain}` give +the band of every configured USDC corridor, whatever the USDC mode. Inventory is published each time the inventory write lock is released, from a copy of the balances taken while the lock is held. Each copy is numbered in @@ -1780,6 +1782,19 @@ inventory series stay unpublished until the next start and an error is logged. 0. The reserve is the configured cash reserve, else the gross broker cash minus the available broker cash once the gross is read, else 0. It is absent until withdrawable cash is read. +- `liq_equity_chain_available{symbol,chain}` and + `liq_usdc_chain_{available,inflight,ratio}{chain}` list each hedged chain's + vault separately. A chain whose vault no snapshot has read yet is left out: a + slot that only a fill, transfer or inflight created holds a delta from 0, not + a balance. A chain the config no longer hedges is left out too. + `liq_equity_chain_available` is in that chain's wrapped vault shares. Each + chain's wrapper has its own underlying-per-wrapped ratio, so the series must + not be summed across chains or added to broker shares. `liq_usdc_chain_ratio` + is that vault's available USDC over itself plus the gross broker cash. It is + absent until the gross broker cash is read and while both are 0: it is a + bot-only series, so the Values rule applies and no exporter sentinel stands + in. It is not the corridor's trigger ratio, which uses the vault total, in + flight included. **Exception to financial-integrity rules.** Some ported series reproduce the exporter's published sentinels: a ratio with a zero denominator is published as diff --git a/crates/config/src/rebalancing.rs b/crates/config/src/rebalancing.rs index 6e5a05f55..fa7a5b032 100644 --- a/crates/config/src/rebalancing.rs +++ b/crates/config/src/rebalancing.rs @@ -214,6 +214,11 @@ impl UsdcCorridors { self.by_chain.values().filter(move |_| enabled) } + /// Every corridor table, in chain order, whatever the USDC mode. + pub fn configured(&self) -> impl Iterator { + self.by_chain.values() + } + /// The corridors this build runs a cash transfer service for. pub const fn served(&self) -> &BTreeSet { &self.served diff --git a/docs/observability.md b/docs/observability.md index 8f8fc2a94..87fb8fd63 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -142,6 +142,8 @@ second writer. A test scans `src/` for this. | `liq_asset_counter_trading` | `symbol` | `settings` | `1` or `0`, every symbol the primary chain lists | | `liq_asset_extended_hours` | `symbol` | `settings` | `1` or `0`; absent while counter trading is disabled | | `liq_asset_rebalancing` | `symbol` | `settings` | `1` when the symbol starts new rebalancing operations | +| `liq_usdc_corridor_target` | `chain` | `settings` | every configured USDC corridor, whatever the USDC mode | +| `liq_usdc_corridor_deviation` | `chain` | `settings` | as above | | `liq_equity_onchain_available` | `symbol` | `inventory` | primary chain vault | | `liq_equity_offchain_available` | `symbol` | `inventory` | broker | | `liq_equity_inflight_total` | `symbol` | `inventory` | primary chain vault plus broker in flight | @@ -149,6 +151,7 @@ second writer. A test scans `src/` for this. | `liq_equity_unwrapped` | `symbol` | `inventory` | Base wallet unwrapped tokens | | `liq_equity_wrapped` | `symbol` | `inventory` | Base wallet wrapped tokens | | `liq_equity_ratio` | `symbol` | `inventory` | onchain over onchain plus offchain; `0` when both are 0 | +| `liq_equity_chain_available` | `chain`, `symbol` | `inventory` | each chain vault a snapshot read, in its wrapped shares: never sum across chains or with broker | | `liq_usdc_onchain_available` | | `inventory` | primary chain vault | | `liq_usdc_onchain_inflight` | | `inventory` | primary chain vault in flight | | `liq_usdc_offchain_available` | | `inventory` | broker cash after the reserve | @@ -162,6 +165,9 @@ second writer. A test scans `src/` for this. | `liq_usdc_total` | | `inventory` | vault plus broker total plus in flight | | `liq_usdc_ratio` | | `inventory` | vault over vault plus broker total; `0` when both are 0 | | `liq_usdc_rebalanceable` | | `inventory` | withdrawable minus the reserve, never below 0; absent until withdrawable is read | +| `liq_usdc_chain_available` | `chain` | `inventory` | each hedged chain vault a snapshot has read | +| `liq_usdc_chain_inflight` | `chain` | `inventory` | as above | +| `liq_usdc_chain_ratio` | `chain` | `inventory` | vault available over itself plus gross broker cash; absent until the gross is read or both are 0 | | `liq_collector_last_success_ts_seconds` | `collector` | store | Unix time each family was last published | `symbol` labels drop a leading `wt` or `t` only before an uppercase letter diff --git a/scripts/liq-parity/compare.py b/scripts/liq-parity/compare.py index 768a31cf3..faa053e3d 100644 --- a/scripts/liq-parity/compare.py +++ b/scripts/liq-parity/compare.py @@ -95,6 +95,12 @@ # Names only the bot publishes. BOT_ONLY = { "liq_collector_last_success_ts_seconds", + "liq_usdc_corridor_target", + "liq_usdc_corridor_deviation", + "liq_equity_chain_available", + "liq_usdc_chain_available", + "liq_usdc_chain_inflight", + "liq_usdc_chain_ratio", } # Exporter names that are not ported, because nothing reads them or the bot diff --git a/scripts/liq-parity/testdata/bot.prom b/scripts/liq-parity/testdata/bot.prom index a7c2464ff..1de047aee 100644 --- a/scripts/liq-parity/testdata/bot.prom +++ b/scripts/liq-parity/testdata/bot.prom @@ -13,6 +13,8 @@ liq_settings_cash_reserved 1000 liq_asset_rebalancing{symbol="AAPL"} 1 # TYPE liq_equity_total gauge liq_equity_total{symbol="AAPL"} 5 +# TYPE liq_usdc_chain_available gauge +liq_usdc_chain_available{chain="robinhood"} 10 # TYPE liq_collector_last_success_ts_seconds gauge liq_collector_last_success_ts_seconds{collector="settings"} 1700000100 # TYPE hedge_trades_total counter diff --git a/src/inventory/view.rs b/src/inventory/view.rs index 711e5d941..8d8a0e6c6 100644 --- a/src/inventory/view.rs +++ b/src/inventory/view.rs @@ -183,7 +183,10 @@ pub(crate) enum ActiveUsdcRebalance { /// Inventory at a pair of venues (onchain/offchain). /// -/// Venues are `Option` to distinguish "not yet polled" from "polled with zero balance". +/// The offchain venue is `Option` to distinguish "not yet polled" from "polled with zero +/// balance". An onchain slot holds a reading only when its chain is not in +/// `unpolled_onchain`: a fill, transfer or inflight can create the slot first. Use +/// `polled_onchain()`, not `onchain.contains_key`, to ask whether a vault was read. /// Imbalance detection requires both venues to have been initialized by snapshot events. /// /// Fields are private - mutation is only possible through the closure-returning @@ -192,10 +195,23 @@ pub(crate) enum ActiveUsdcRebalance { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct Inventory { /// On-chain balance per chain: the same symbol listed on two chains is - /// two slots. Absent entry = that chain not yet polled. + /// two slots. Absent entry = that chain not yet polled; a present entry + /// is a reading only when its chain is not in `unpolled_onchain`. onchain: BTreeMap>, offchain: Option>, last_rebalancing: Option>, + /// Chains whose onchain slot a fill, transfer or inflight created before + /// any vault snapshot set it: the slot's available is a delta from 0, + /// not a reading. A snapshot of that chain clears it. + #[serde(default)] + unpolled_onchain: BTreeSet, +} + +/// An onchain slot copied by a reset, with whether a vault snapshot set it. +#[derive(Debug, Clone, Copy)] +struct CarriedSlot { + balance: VenueBalance, + unpolled: bool, } /// Impl block with minimal bounds for `has_inflight` - shared by all other impl blocks. @@ -249,6 +265,35 @@ where Ok(true) } + /// The onchain slot on `chain` with its poll marker, for a reset that + /// keeps the slot: it must also keep whether the slot holds a reading. + fn carried_onchain_slot(&self, chain: Chain) -> Option> + where + VenueBalance: Copy, + { + self.onchain.get(&chain).map(|balance| CarriedSlot { + balance: *balance, + unpolled: self.unpolled_onchain.contains(&chain), + }) + } + + fn insert_carried_slot(&mut self, chain: Chain, slot: CarriedSlot) { + self.onchain.insert(chain, slot.balance); + if slot.unpolled { + self.unpolled_onchain.insert(chain); + } else { + self.unpolled_onchain.remove(&chain); + } + } + + /// The onchain slots a vault snapshot has set, in chain order. A slot + /// that only a fill, transfer or inflight created holds no reading yet. + fn polled_onchain(&self) -> impl Iterator)> { + self.onchain + .iter() + .filter(|(chain, _)| !self.unpolled_onchain.contains(chain)) + } + fn get_venue(&self, venue: Venue, chain: Chain) -> Option> where VenueBalance: Copy, @@ -259,13 +304,29 @@ where } } + /// Set a venue from a snapshot: the onchain slot now holds a reading. + fn set_polled_venue(self, venue: Venue, chain: Chain, balance: VenueBalance) -> Self { + let mut inventory = self.set_venue(venue, chain, Some(balance)); + match venue { + Venue::MarketMaking => { + inventory.unpolled_onchain.remove(&chain); + } + Venue::Hedging => {} + } + inventory + } + fn set_venue(mut self, venue: Venue, chain: Chain, balance: Option>) -> Self { match venue { Venue::MarketMaking => { - match balance { - Some(balance) => self.onchain.insert(chain, balance), - None => self.onchain.remove(&chain), - }; + if let Some(balance) = balance { + if self.onchain.insert(chain, balance).is_none() { + self.unpolled_onchain.insert(chain); + } + } else { + self.onchain.remove(&chain); + self.unpolled_onchain.remove(&chain); + } self } Venue::Hedging => Self { @@ -282,6 +343,7 @@ impl Default for Inventory { onchain: BTreeMap::new(), offchain: None, last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), } } } @@ -473,7 +535,7 @@ where .unwrap_or_default() .apply_snapshot(snapshot_balance)?; - Ok(inventory.set_venue(venue, chain, Some(balance))) + Ok(inventory.set_polled_venue(venue, chain, balance)) }) } @@ -493,7 +555,7 @@ where .unwrap_or_default() .apply_snapshot(snapshot_balance)?; - Ok(inventory.set_venue(venue, chain, Some(balance))) + Ok(inventory.set_polled_venue(venue, chain, balance)) }) } @@ -512,7 +574,7 @@ where .unwrap_or_default() .apply_snapshot_keeping_inflight(snapshot_balance); - Ok(inventory.set_venue(venue, chain, Some(balance))) + Ok(inventory.set_polled_venue(venue, chain, balance)) }) } @@ -536,7 +598,7 @@ where .unwrap_or_default() .force_apply_snapshot(snapshot_balance, &recovering_from); - Ok(inventory.set_venue(venue, chain, Some(balance))) + Ok(inventory.set_polled_venue(venue, chain, balance)) }) } } @@ -763,6 +825,28 @@ pub(crate) struct UsdcBalances { pub(crate) base_wallet: Option, } +/// Each hedged chain's vault balances that a snapshot has read. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct OnchainByChain { + pub(crate) equities: Vec, + pub(crate) usdc: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ChainEquityBalance { + pub(crate) symbol: Symbol, + pub(crate) chain: Chain, + pub(crate) available: FractionalShares, +} + +/// One chain's settlement-stable vault balance (USDC, or USDG on Robinhood). +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ChainUsdcBalance { + pub(crate) chain: Chain, + pub(crate) available: Usdc, + pub(crate) inflight: Usdc, +} + /// Cross-aggregate projection tracking inventory across venues. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub(crate) struct InventoryView { @@ -771,6 +855,11 @@ pub(crate) struct InventoryView { /// slot through [`Venue::MarketMaking`] means this chain's slot. #[serde(default = "crate::onchain::legacy_chain")] primary_chain: Chain, + /// The chains the config hedges. The dashboard lists only their onchain + /// slots: hydration replays every persisted chain, so a chain removed + /// from the config would otherwise keep showing its last balance. + #[serde(default)] + hedged_chains: BTreeSet, usdc: Inventory, equities: HashMap>, last_updated: DateTime, @@ -1139,9 +1228,8 @@ impl InventoryView { let inventory = self.equities.get(symbol); // The primary chain's slot alone, never a cross-chain total: // a wrapped share is worth its own chain's underlying, so the - // chains cannot be added. Surfacing the other chains needs a - // chain-qualified field the dashboard can render per chain, - // not a wider sum here. + // chains cannot be added. Every hedged chain's polled slot is + // listed apart in `onchain_by_chain` instead. let (onchain_available, onchain_inflight) = inventory .map_or((FractionalShares::ZERO, FractionalShares::ZERO), |item| { venue_balances(item.onchain.get(&self.primary_chain).copied()) @@ -1200,6 +1288,42 @@ impl InventoryView { } } + /// Every hedged chain's onchain balances that a vault snapshot has read, + /// in symbol and chain order. A slot that only a fill, transfer or + /// inflight created holds a delta from 0, not a reading, so it is left + /// out; so is a chain the config no longer hedges, whose persisted + /// snapshots still replay. + pub(crate) fn onchain_by_chain(&self) -> OnchainByChain { + let equities = self + .equities + .iter() + .sorted_by(|(left, _), (right, _)| left.cmp(right)) + .flat_map(|(symbol, inventory)| { + inventory + .polled_onchain() + .filter(|(chain, _)| self.hedged_chains.contains(chain)) + .map(|(chain, balance)| ChainEquityBalance { + symbol: symbol.clone(), + chain: *chain, + available: balance.available(), + }) + }) + .collect(); + + let usdc = self + .usdc + .polled_onchain() + .filter(|(chain, _)| self.hedged_chains.contains(chain)) + .map(|(chain, balance)| ChainUsdcBalance { + chain: *chain, + available: balance.available(), + inflight: balance.inflight(), + }) + .collect(); + + OnchainByChain { equities, usdc } + } + /// Extracts a flat list of observed balances for the daily portfolio /// snapshot (RAI-1457). Mirrors [`Self::to_dto`]'s per-venue iteration, /// but never merges wrapped/unwrapped equity or wallet-transit balances @@ -1332,9 +1456,18 @@ impl InventoryView { pub(crate) fn for_primary_chain(chain: Chain) -> Self { Self { primary_chain: chain, + hedged_chains: BTreeSet::from([chain]), ..Self::default() } } + + /// The chains the config hedges, which the dashboard lists per chain. + pub(crate) fn with_hedged_chains(self, chains: impl IntoIterator) -> Self { + Self { + hedged_chains: chains.into_iter().collect(), + ..self + } + } } impl Default for InventoryView { @@ -1371,6 +1504,7 @@ impl Default for InventoryView { restart_tainted_offchain_symbols: HashSet::new(), restart_tainted_offchain_cash: false, primary_chain: Chain::Base, + hedged_chains: BTreeSet::from([Chain::Base]), } } } @@ -1396,6 +1530,7 @@ impl InventoryView { FractionalShares::ZERO, )), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ); self @@ -1659,6 +1794,7 @@ impl InventoryView { )]), offchain: Some(VenueBalance::new(offchain_available, Usdc::ZERO)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ..self } @@ -1686,6 +1822,7 @@ impl InventoryView { )]), offchain: Some(VenueBalance::new(offchain_available, offchain_inflight)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ..self } @@ -1806,6 +1943,7 @@ impl InventoryView { restart_tainted_offchain_symbols: self.restart_tainted_offchain_symbols, restart_tainted_offchain_cash: self.restart_tainted_offchain_cash, primary_chain: self.primary_chain, + hedged_chains: self.hedged_chains, }) } @@ -1859,6 +1997,7 @@ impl InventoryView { restart_tainted_offchain_symbols: self.restart_tainted_offchain_symbols, restart_tainted_offchain_cash: self.restart_tainted_offchain_cash, primary_chain: self.primary_chain, + hedged_chains: self.hedged_chains, }) } @@ -2248,6 +2387,7 @@ impl InventoryView { FractionalShares::ZERO, )), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ) }) @@ -2266,16 +2406,15 @@ impl InventoryView { let mut onchain_equity_snapshot_block_watermarks: HashMap> = HashMap::new(); for stranded in self.startup_stranded_redemptions.values() { - if let Some(balance) = self + if let Some(slot) = self .equities .get(&stranded.symbol) - .and_then(|inventory| inventory.get_venue(Venue::MarketMaking, stranded.chain)) + .and_then(|inventory| inventory.carried_onchain_slot(stranded.chain)) { equities .entry(stranded.symbol.clone()) .or_default() - .onchain - .insert(stranded.chain, balance); + .insert_carried_slot(stranded.chain, slot); self.carry_hedging_available( &stranded.symbol, &mut equities, @@ -2343,6 +2482,7 @@ impl InventoryView { restart_tainted_offchain_symbols: self.restart_tainted_offchain_symbols.clone(), restart_tainted_offchain_cash: self.restart_tainted_offchain_cash, primary_chain: self.primary_chain, + hedged_chains: self.hedged_chains.clone(), ..Self::default() } } @@ -2389,16 +2529,15 @@ impl InventoryView { continue; } for symbol in symbols { - if let Some(balance) = self + if let Some(slot) = self .equities .get(symbol) - .and_then(|inventory| inventory.onchain.get(chain)) + .and_then(|inventory| inventory.carried_onchain_slot(*chain)) { view.equities .entry(symbol.clone()) .or_default() - .onchain - .insert(*chain, *balance); + .insert_carried_slot(*chain, slot); self.carry_hedging_available( symbol, &mut view.equities, @@ -3147,6 +3286,7 @@ impl InventoryView { restart_tainted_offchain_symbols: self.restart_tainted_offchain_symbols, restart_tainted_offchain_cash: self.restart_tainted_offchain_cash, primary_chain: self.primary_chain, + hedged_chains: self.hedged_chains, }) } @@ -4598,6 +4738,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(onchain_available, onchain_inflight))]), offchain: Some(venue(offchain_available, offchain_inflight)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), } } @@ -4736,6 +4877,7 @@ mod tests { )]), offchain: Some(usdc_venue(offchain_available, offchain_inflight)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), } } @@ -4772,6 +4914,7 @@ mod tests { restart_tainted_offchain_symbols: HashSet::new(), restart_tainted_offchain_cash: false, primary_chain: Chain::Base, + hedged_chains: BTreeSet::from([Chain::Base]), } } @@ -4818,6 +4961,7 @@ mod tests { restart_tainted_offchain_symbols: HashSet::new(), restart_tainted_offchain_cash: false, primary_chain: Chain::Base, + hedged_chains: BTreeSet::from([Chain::Base]), } } @@ -5635,6 +5779,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(50, 0))]), offchain: Some(venue(50, 0)), last_rebalancing: Some(last_rebalancing), + unpolled_onchain: BTreeSet::new(), }; // Stale snapshot should be rejected — inventory unchanged @@ -5651,6 +5796,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(50, 0))]), offchain: Some(venue(50, 0)), last_rebalancing: Some(last_rebalancing), + unpolled_onchain: BTreeSet::new(), }; // fetched_at == last_rebalancing should apply @@ -5671,6 +5817,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(50, 0))]), offchain: Some(venue(50, 0)), last_rebalancing: Some(last_rebalancing), + unpolled_onchain: BTreeSet::new(), }; let update_fn = Inventory::on_snapshot(Venue::MarketMaking, shares(999), fresh_fetched_at); @@ -5687,6 +5834,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(50, 0))]), offchain: Some(venue(50, 0)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }; let update_fn = Inventory::on_snapshot(Venue::MarketMaking, shares(999), Utc::now()); @@ -5847,6 +5995,7 @@ mod tests { onchain: BTreeMap::new(), offchain: Some(VenueBalance::new(shares(100), FractionalShares::ZERO)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, )) .collect(), @@ -6820,6 +6969,7 @@ mod tests { )]), offchain: Some(VenueBalance::new(shares(10), FractionalShares::ZERO)), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ), ( @@ -6831,6 +6981,7 @@ mod tests { )]), offchain: Some(VenueBalance::new(shares(20), shares(5))), last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, ), ] @@ -7111,6 +7262,78 @@ mod tests { ); } + /// A reset keeps a stranded slot's poll marker: a slot that only the + /// stranded inflight created stays out of the per-chain list, while a + /// slot a vault snapshot set stays in it. + #[test] + fn snapshot_error_reset_keeps_whether_each_stranded_slot_was_polled() { + let symbol = Symbol::new("AAPL").unwrap(); + let now = Utc::now(); + let view = InventoryView::default() + .with_hedged_chains([Chain::Base, Chain::Robinhood]) + .update_equity_at( + &symbol, + Chain::Base, + Inventory::on_snapshot(Venue::MarketMaking, shares(50), now), + now, + ) + .unwrap() + .seed_stranded_redemption( + &RedemptionAggregateId::generate(), + &symbol, + Chain::Base, + shares(3), + now, + ) + .unwrap() + .seed_stranded_redemption( + &RedemptionAggregateId::generate(), + &symbol, + Chain::Robinhood, + shares(7), + now, + ) + .unwrap() + .reset_preserving_offchain_order_state(); + + assert_eq!( + view.equity_inflight_at(&symbol, Venue::MarketMaking, Chain::Robinhood), + Some(shares(7)) + ); + assert_eq!( + view.onchain_by_chain().equities, + vec![ChainEquityBalance { + symbol, + chain: Chain::Base, + available: shares(50), + }] + ); + } + + /// The provider-failure reset carries other chains' slots with their + /// poll marker too. + #[test] + fn provider_failure_reset_keeps_an_unpolled_carried_slot_unlisted() { + let symbol = Symbol::new("AAPL").unwrap(); + let now = Utc::now(); + let view = InventoryView::default() + .with_hedged_chains([Chain::Base, Chain::Robinhood]) + .apply_inflight_redemptions_at( + Chain::Robinhood, + &BTreeMap::from([(symbol.clone(), shares(2))]), + now, + now, + ) + .unwrap() + .reset_carrying_other_provider_inflight(Chain::Base, now); + + assert_eq!( + view.equity_inflight_at(&symbol, Venue::MarketMaking, Chain::Robinhood), + Some(shares(2)) + ); + assert_eq!(view.onchain_by_chain(), OnchainByChain::default()); + } + #[test] fn stranded_exposure_survives_force_applied_vault_snapshot() { let symbol = Symbol::new("AAPL").unwrap(); @@ -7750,6 +7973,219 @@ mod tests { assert_eq!(dto.usdc.symbol, "USDG"); } + /// Every hedged chain the view holds a vault reading for gets its own + /// entry, in symbol and chain order, Robinhood's USDG vault included; the + /// primary-chain balances stay as they were. + #[test] + fn onchain_by_chain_lists_every_hedged_chains_vault_reading() { + let aapl = Symbol::new("AAPL").unwrap(); + let tsla = Symbol::new("TSLA").unwrap(); + let view = InventoryView { + equities: HashMap::from([ + ( + tsla.clone(), + Inventory { + onchain: BTreeMap::from([(Chain::Base, venue(4, 0))]), + offchain: None, + last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), + }, + ), + ( + aapl.clone(), + Inventory { + onchain: BTreeMap::from([ + (Chain::Robinhood, venue(9, 1)), + (Chain::Base, venue(50, 5)), + ]), + offchain: Some(venue(40, 0)), + last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), + }, + ), + ]), + usdc: Inventory { + onchain: BTreeMap::from([ + (Chain::Robinhood, usdc_venue(300, 0)), + (Chain::Base, usdc_venue(2000, 250)), + ]), + offchain: Some(usdc_venue(1000, 0)), + last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), + }, + ..InventoryView::for_primary_chain(Chain::Base) + .with_hedged_chains([Chain::Base, Chain::Robinhood]) + }; + + assert_eq!( + view.onchain_by_chain(), + OnchainByChain { + equities: vec![ + ChainEquityBalance { + symbol: aapl.clone(), + chain: Chain::Base, + available: shares(50), + }, + ChainEquityBalance { + symbol: aapl, + chain: Chain::Robinhood, + available: shares(9), + }, + ChainEquityBalance { + symbol: tsla, + chain: Chain::Base, + available: shares(4), + }, + ], + usdc: vec![ + ChainUsdcBalance { + chain: Chain::Base, + available: Usdc::new(float!(2000)), + inflight: Usdc::new(float!(250)), + }, + ChainUsdcBalance { + chain: Chain::Robinhood, + available: Usdc::new(float!(300)), + inflight: Usdc::ZERO, + }, + ], + } + ); + + let dto = view.to_dto(); + assert_eq!(dto.per_symbol[0].onchain_available, shares(50)); + assert_eq!(dto.per_symbol[0].onchain_inflight, shares(5)); + assert_eq!(dto.usdc.onchain_available, Usdc::new(float!(2000))); + assert_eq!(dto.usdc.symbol, "USDC"); + } + + /// The production vault reads (`apply_snapshot_event`, which hydration + /// replays too) mark a non-primary chain as read for equities and cash, + /// and a chain the config no longer hedges is not listed even though its + /// persisted snapshots replay. + #[test] + fn onchain_by_chain_lists_hedged_chains_read_through_snapshot_events_only() { + let aapl = Symbol::new("AAPL").unwrap(); + let now = Utc::now(); + let read = |view: InventoryView, chain: Chain| { + view.apply_snapshot_event( + &InventorySnapshotEvent::OnchainEquity { + chain, + balances: BTreeMap::from([(aapl.clone(), shares(7))]), + fetched_at: now, + block_number: Some(100), + }, + now, + ) + .unwrap() + .apply_snapshot_event( + &InventorySnapshotEvent::OnchainUsdc { + chain, + usdc_balance: Usdc::new(float!(300)), + fetched_at: now, + block_number: Some(100), + }, + now, + ) + .unwrap() + }; + let view = read( + read( + InventoryView::for_primary_chain(Chain::Base) + .with_hedged_chains([Chain::Base, Chain::Robinhood]), + Chain::Robinhood, + ), + Chain::HyperEvm, + ); + + assert_eq!( + view.onchain_by_chain(), + OnchainByChain { + equities: vec![ChainEquityBalance { + symbol: aapl, + chain: Chain::Robinhood, + available: shares(7), + }], + usdc: vec![ChainUsdcBalance { + chain: Chain::Robinhood, + available: Usdc::new(float!(300)), + inflight: Usdc::ZERO, + }], + } + ); + assert_eq!( + view.reset_preserving_offchain_order_state().hedged_chains, + BTreeSet::from([Chain::Base, Chain::Robinhood]), + "a reset keeps the hedged chains" + ); + } + + /// An inflight redemption on a chain whose vault was never read creates + /// that chain's slot with available 0. That is not a reading, so the + /// chain stays out of the per-chain list. + #[test] + fn onchain_by_chain_lists_no_balance_for_a_chain_holding_only_inflight() { + let aapl = Symbol::new("AAPL").unwrap(); + let now = Utc::now(); + + let view = InventoryView::for_primary_chain(Chain::Base) + .with_hedged_chains([Chain::Base, Chain::Robinhood, Chain::HyperEvm]) + .update_equity_at( + &aapl, + Chain::Robinhood, + Inventory::set_inflight(Venue::MarketMaking, shares(5)), + now, + ) + .unwrap() + .update_usdc_at( + Chain::HyperEvm, + Inventory::set_inflight(Venue::MarketMaking, Usdc::new(float!(250))), + now, + ) + .unwrap(); + + assert_eq!(view.symbol_balances()[0].symbol, aapl); + assert_eq!(view.onchain_by_chain(), OnchainByChain::default()); + } + + /// A fill on a chain whose vault was never read holds a delta from 0, + /// not a reading. The chain is listed once a vault snapshot sets it. + #[test] + fn onchain_by_chain_lists_a_chain_once_its_vault_snapshot_applies() { + let aapl = Symbol::new("AAPL").unwrap(); + let now = Utc::now(); + + let view = InventoryView::for_primary_chain(Chain::Base) + .with_hedged_chains([Chain::Base, Chain::Robinhood]) + .update_equity_at( + &aapl, + Chain::Robinhood, + Inventory::available(Venue::MarketMaking, Operator::Add, shares(3)), + now, + ) + .unwrap(); + + assert_eq!(view.onchain_by_chain(), OnchainByChain::default()); + + let view = view + .update_equity_at( + &aapl, + Chain::Robinhood, + Inventory::on_snapshot(Venue::MarketMaking, shares(40), now), + now, + ) + .unwrap(); + + assert_eq!( + view.onchain_by_chain().equities, + vec![ChainEquityBalance { + symbol: aapl, + chain: Chain::Robinhood, + available: shares(40), + }] + ); + } + #[test] fn to_dto_converts_equities_and_usdc() { let aapl = Symbol::new("AAPL").unwrap(); @@ -7815,6 +8251,7 @@ mod tests { restart_tainted_offchain_symbols: HashSet::new(), restart_tainted_offchain_cash: false, primary_chain: Chain::Base, + hedged_chains: BTreeSet::from([Chain::Base]), }; let dto = view.to_dto(); @@ -7850,6 +8287,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(75, 0))]), offchain: None, last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, )) .collect(), @@ -7883,6 +8321,7 @@ mod tests { restart_tainted_offchain_symbols: HashSet::new(), restart_tainted_offchain_cash: false, primary_chain: Chain::Base, + hedged_chains: BTreeSet::from([Chain::Base]), }; let dto = view.to_dto(); @@ -8054,6 +8493,7 @@ mod tests { assert_eq!(aapl_dto.symbol, aapl); assert_eq!(aapl_dto.onchain_available, FractionalShares::ZERO); + assert_eq!(view.onchain_by_chain(), OnchainByChain::default()); assert_eq!(aapl_dto.offchain_available, FractionalShares::ZERO); assert_eq!( aapl_dto.inflight_equity.base_wallet_unwrapped, @@ -8219,6 +8659,7 @@ mod tests { onchain: BTreeMap::from([(Chain::Base, venue(75, 0))]), offchain: None, last_rebalancing: None, + unpolled_onchain: BTreeSet::new(), }, )) .collect(), diff --git a/src/lib.rs b/src/lib.rs index a0e3563ef..20d235250 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -310,7 +310,8 @@ async fn run_bot_session_inner( let inventory = Arc::new( inventory::BroadcastingInventory::new( - inventory::InventoryView::for_primary_chain(ctx.chains.primary().chain), + inventory::InventoryView::for_primary_chain(ctx.chains.primary().chain) + .with_hedged_chains(ctx.chains.hedged().map(|hedged| hedged.chain)), event_sender.clone(), ) .publishing_liq_metrics(metrics::liquidity::inventory::InventoryPublisher::new( diff --git a/src/metrics/liquidity.rs b/src/metrics/liquidity.rs index 095cf01bb..22ec38d7b 100644 --- a/src/metrics/liquidity.rs +++ b/src/metrics/liquidity.rs @@ -51,6 +51,8 @@ pub(crate) enum LiqMetric { AssetCounterTrading, AssetExtendedHours, AssetRebalancing, + UsdcCorridorTarget, + UsdcCorridorDeviation, EquityOnchainAvailable, EquityOffchainAvailable, EquityInflightTotal, @@ -58,6 +60,7 @@ pub(crate) enum LiqMetric { EquityUnwrapped, EquityWrapped, EquityRatio, + EquityChainAvailable, UsdcOnchainAvailable, UsdcOnchainInflight, UsdcOffchainAvailable, @@ -71,6 +74,9 @@ pub(crate) enum LiqMetric { UsdcTotal, UsdcRatio, UsdcRebalanceable, + UsdcChainAvailable, + UsdcChainInflight, + UsdcChainRatio, CollectorLastSuccessTsSeconds, } @@ -78,7 +84,7 @@ impl LiqMetric { /// Every variant, for the catalog tests. A new variant needs an entry /// here and its name in the catalog test. #[cfg(test)] - pub(crate) const ALL: [Self; 36] = [ + pub(crate) const ALL: [Self; 42] = [ Self::BotInfo, Self::BotStartTimestampSeconds, Self::SettingsInfo, @@ -94,6 +100,8 @@ impl LiqMetric { Self::AssetCounterTrading, Self::AssetExtendedHours, Self::AssetRebalancing, + Self::UsdcCorridorTarget, + Self::UsdcCorridorDeviation, Self::EquityOnchainAvailable, Self::EquityOffchainAvailable, Self::EquityInflightTotal, @@ -101,6 +109,7 @@ impl LiqMetric { Self::EquityUnwrapped, Self::EquityWrapped, Self::EquityRatio, + Self::EquityChainAvailable, Self::UsdcOnchainAvailable, Self::UsdcOnchainInflight, Self::UsdcOffchainAvailable, @@ -114,6 +123,9 @@ impl LiqMetric { Self::UsdcTotal, Self::UsdcRatio, Self::UsdcRebalanceable, + Self::UsdcChainAvailable, + Self::UsdcChainInflight, + Self::UsdcChainRatio, Self::CollectorLastSuccessTsSeconds, ]; @@ -134,6 +146,8 @@ impl LiqMetric { Self::AssetCounterTrading => "liq_asset_counter_trading", Self::AssetExtendedHours => "liq_asset_extended_hours", Self::AssetRebalancing => "liq_asset_rebalancing", + Self::UsdcCorridorTarget => "liq_usdc_corridor_target", + Self::UsdcCorridorDeviation => "liq_usdc_corridor_deviation", Self::EquityOnchainAvailable => "liq_equity_onchain_available", Self::EquityOffchainAvailable => "liq_equity_offchain_available", Self::EquityInflightTotal => "liq_equity_inflight_total", @@ -141,6 +155,7 @@ impl LiqMetric { Self::EquityUnwrapped => "liq_equity_unwrapped", Self::EquityWrapped => "liq_equity_wrapped", Self::EquityRatio => "liq_equity_ratio", + Self::EquityChainAvailable => "liq_equity_chain_available", Self::UsdcOnchainAvailable => "liq_usdc_onchain_available", Self::UsdcOnchainInflight => "liq_usdc_onchain_inflight", Self::UsdcOffchainAvailable => "liq_usdc_offchain_available", @@ -154,6 +169,9 @@ impl LiqMetric { Self::UsdcTotal => "liq_usdc_total", Self::UsdcRatio => "liq_usdc_ratio", Self::UsdcRebalanceable => "liq_usdc_rebalanceable", + Self::UsdcChainAvailable => "liq_usdc_chain_available", + Self::UsdcChainInflight => "liq_usdc_chain_inflight", + Self::UsdcChainRatio => "liq_usdc_chain_ratio", Self::CollectorLastSuccessTsSeconds => "liq_collector_last_success_ts_seconds", } } @@ -189,6 +207,12 @@ impl LiqMetric { trading is disabled" } Self::AssetRebalancing => "1 when the symbol starts new rebalancing operations, else 0", + Self::UsdcCorridorTarget => { + "Target onchain share of each configured USDC corridor's chain vault" + } + Self::UsdcCorridorDeviation => { + "Rebalancing band half-width of each configured USDC corridor" + } Self::EquityOnchainAvailable => "Primary chain vault shares available", Self::EquityOffchainAvailable => "Broker shares available", Self::EquityInflightTotal => "Primary chain vault plus broker shares in flight", @@ -201,6 +225,9 @@ impl LiqMetric { Self::EquityRatio => { "Primary chain vault share of available shares; 0 when nothing is available" } + Self::EquityChainAvailable => { + "Wrapped vault shares on each hedged chain a snapshot read; never sum chains" + } Self::UsdcOnchainAvailable => "Primary chain vault settlement stable available", Self::UsdcOnchainInflight => "Primary chain vault settlement stable in flight", Self::UsdcOffchainAvailable => "Broker cash available after the reserve", @@ -222,6 +249,15 @@ impl LiqMetric { Self::UsdcRebalanceable => { "Withdrawable broker cash above the reserve; absent until withdrawable is read" } + Self::UsdcChainAvailable => { + "Settlement stable available in each hedged chain vault a snapshot has read" + } + Self::UsdcChainInflight => { + "Settlement stable in flight in each hedged chain vault a snapshot has read" + } + Self::UsdcChainRatio => { + "Vault share of itself plus gross broker cash; absent until gross is read or both 0" + } Self::CollectorLastSuccessTsSeconds => { "Unix time each liq_ collector last published its family" } @@ -252,6 +288,12 @@ impl LiqMetric { | Self::EquityUnwrapped | Self::EquityWrapped | Self::EquityRatio => &["symbol"], + Self::EquityChainAvailable => &["chain", "symbol"], + Self::UsdcCorridorTarget + | Self::UsdcCorridorDeviation + | Self::UsdcChainAvailable + | Self::UsdcChainInflight + | Self::UsdcChainRatio => &["chain"], Self::CollectorLastSuccessTsSeconds => &["collector"], Self::BotStartTimestampSeconds | Self::SettingsEquityTarget @@ -296,7 +338,9 @@ impl LiqMetric { | Self::SettingsDeploymentBlock | Self::AssetCounterTrading | Self::AssetExtendedHours - | Self::AssetRebalancing => Some(LiqFamily::Settings), + | Self::AssetRebalancing + | Self::UsdcCorridorTarget + | Self::UsdcCorridorDeviation => Some(LiqFamily::Settings), Self::EquityOnchainAvailable | Self::EquityOffchainAvailable | Self::EquityInflightTotal @@ -304,6 +348,7 @@ impl LiqMetric { | Self::EquityUnwrapped | Self::EquityWrapped | Self::EquityRatio + | Self::EquityChainAvailable | Self::UsdcOnchainAvailable | Self::UsdcOnchainInflight | Self::UsdcOffchainAvailable @@ -316,7 +361,10 @@ impl LiqMetric { | Self::UsdcInflightBaseWallet | Self::UsdcTotal | Self::UsdcRatio - | Self::UsdcRebalanceable => Some(LiqFamily::Inventory), + | Self::UsdcRebalanceable + | Self::UsdcChainAvailable + | Self::UsdcChainInflight + | Self::UsdcChainRatio => Some(LiqFamily::Inventory), Self::CollectorLastSuccessTsSeconds => None, } } @@ -849,6 +897,8 @@ pub(crate) mod tests { "liq_asset_counter_trading", "liq_asset_extended_hours", "liq_asset_rebalancing", + "liq_usdc_corridor_target", + "liq_usdc_corridor_deviation", "liq_equity_onchain_available", "liq_equity_offchain_available", "liq_equity_inflight_total", @@ -856,6 +906,7 @@ pub(crate) mod tests { "liq_equity_unwrapped", "liq_equity_wrapped", "liq_equity_ratio", + "liq_equity_chain_available", "liq_usdc_onchain_available", "liq_usdc_onchain_inflight", "liq_usdc_offchain_available", @@ -869,6 +920,9 @@ pub(crate) mod tests { "liq_usdc_total", "liq_usdc_ratio", "liq_usdc_rebalanceable", + "liq_usdc_chain_available", + "liq_usdc_chain_inflight", + "liq_usdc_chain_ratio", "liq_collector_last_success_ts_seconds", ] ); diff --git a/src/metrics/liquidity/inventory.rs b/src/metrics/liquidity/inventory.rs index 1034bc3f3..4561becf5 100644 --- a/src/metrics/liquidity/inventory.rs +++ b/src/metrics/liquidity/inventory.rs @@ -1,5 +1,6 @@ //! The `Inventory` family: equity and cash balances on the primary chain -//! vault, at the broker and in the Base wallet. +//! vault, at the broker and in the Base wallet, and each hedged chain's vault +//! on its own. //! //! The inventory write guard publishes this family each time it releases the //! write lock (see `BroadcastingWriteGuard`), so the series follow every @@ -21,7 +22,7 @@ use super::{ strip_prefix, }; use crate::inventory::InventoryView; -use crate::inventory::view::{SymbolBalances, UsdcBalances}; +use crate::inventory::view::{OnchainByChain, SymbolBalances, UsdcBalances}; /// What the inventory builder reads, copied from the view while its lock is /// held so the samples can be built after the lock is released. @@ -29,6 +30,7 @@ use crate::inventory::view::{SymbolBalances, UsdcBalances}; pub(crate) struct InventoryInput { symbols: Vec, usdc: UsdcBalances, + by_chain: OnchainByChain, } impl InventoryInput { @@ -36,6 +38,7 @@ impl InventoryInput { Self { symbols: view.symbol_balances(), usdc: view.usdc_balances(), + by_chain: view.onchain_by_chain(), } } } @@ -118,9 +121,9 @@ impl InventoryPublisher { } } -/// Every `liq_equity_*` and `liq_usdc_*` sample, with the exporter's -/// formulas: ratios are 0 when their denominator is 0, and the primary-chain -/// series exclude other chains and wallet tokens. +/// Every `liq_equity_*` and `liq_usdc_*` sample. The ported series keep the +/// exporter's formulas: their ratios are 0 when the denominator is 0, and the +/// primary-chain series exclude other chains and wallet tokens. pub(crate) fn inventory_samples( input: &InventoryInput, cash_reserved: Option, @@ -132,10 +135,55 @@ pub(crate) fn inventory_samples( } usdc_samples(&mut samples, &input.usdc, cash_reserved); + let broker_gross = input.usdc.offchain_gross.map(Usdc::inner); + chain_samples(&mut samples, &input.by_chain, broker_gross); samples } +/// The per-chain series. Each vault's ratio divides by the gross broker +/// cash, so it is absent until that cash is read, and absent while the vault +/// and the broker are both 0: these series are the bot's own, with no +/// exporter value to keep. +fn chain_samples( + samples: &mut Vec, + by_chain: &OnchainByChain, + broker_gross: Option, +) { + for balance in &by_chain.equities { + push_sample( + samples, + LiqMetric::EquityChainAvailable, + vec![ + ("chain", balance.chain.as_str().to_string()), + ("symbol", strip_prefix(balance.symbol.as_str()).to_string()), + ], + float_value(balance.available.inner()), + ); + } + + for balance in &by_chain.usdc { + let labels = vec![("chain", balance.chain.as_str().to_string())]; + let available = balance.available.inner(); + let values = [ + (LiqMetric::UsdcChainAvailable, Ok(available)), + (LiqMetric::UsdcChainInflight, Ok(balance.inflight.inner())), + ]; + for (metric, value) in values { + push_sample(samples, metric, labels.clone(), exact(value)); + } + + let ratio = broker_gross.and_then(|broker_gross| { + (available + broker_gross) + .and_then(|whole| defined_share_of(available, whole)) + .transpose() + }); + if let Some(ratio) = ratio { + push_sample(samples, LiqMetric::UsdcChainRatio, labels, exact(ratio)); + } + } +} + fn symbol_samples(samples: &mut Vec, balances: &SymbolBalances) { let labels = vec![("symbol", strip_prefix(balances.symbol.as_str()).to_string())]; let onchain = balances.onchain_available.inner(); @@ -173,6 +221,7 @@ fn symbol_samples(samples: &mut Vec, balances: &SymbolBalances) { } } +/// Pushes the unlabelled cash samples. fn usdc_samples(samples: &mut Vec, usdc: &UsdcBalances, cash_reserved: Option) { let onchain = usdc.onchain_available.inner(); let onchain_inflight = usdc.onchain_inflight.inner(); @@ -267,6 +316,15 @@ fn share_of(part: Float, whole: Float) -> Result { part / whole } +/// `part / whole`, or `None` when `whole` is 0 and the share is undefined. +fn defined_share_of(part: Float, whole: Float) -> Result, FloatError> { + if whole.is_zero()? { + return Ok(None); + } + + (part / whole).map(Some) +} + fn exact(value: Result) -> Result { float_value(value?) } @@ -279,10 +337,12 @@ pub(crate) mod tests { use serde_json::{Value, json}; use st0x_config::create_test_ctx_with_order_owner; + use st0x_evm::Chain; use st0x_execution::{FractionalShares, Symbol}; use st0x_float_macro::float; use super::*; + use crate::inventory::view::{ChainEquityBalance, ChainUsdcBalance}; use crate::metrics::liquidity::tests::{SeriesKey, parse_exposition, series}; fn shares(value: Float) -> FractionalShares { @@ -316,7 +376,11 @@ pub(crate) mod tests { } fn input(symbols: Vec, usdc: UsdcBalances) -> InventoryInput { - InventoryInput { symbols, usdc } + InventoryInput { + symbols, + usdc, + by_chain: OnchainByChain::default(), + } } pub(crate) fn render_family( @@ -346,7 +410,8 @@ pub(crate) mod tests { } /// The goldens feed the exporter the dashboard DTO, so the same fixture - /// reaches the builder through this adapter. + /// reaches the builder through this adapter. The DTO has no per-chain + /// rows; those have their own tests. pub(crate) fn input_from_dto(inventory: &st0x_dto::Inventory) -> InventoryInput { let usdc = &inventory.usdc; @@ -375,6 +440,7 @@ pub(crate) mod tests { ethereum_wallet: usdc.inflight_cash.ethereum_wallet, base_wallet: usdc.inflight_cash.base_wallet, }, + by_chain: OnchainByChain::default(), } } @@ -737,6 +803,146 @@ pub(crate) mod tests { assert_eq!(usdc_series(&rendered, "liq_usdc_rebalanceable"), None); } + #[test] + fn per_chain_series_list_each_vault_with_its_own_ratio() { + let mut cash = usdc(float!(2000), float!(1000)); + cash.offchain_gross = Some(Usdc::new(float!(1500))); + let input = InventoryInput { + symbols: Vec::new(), + usdc: cash, + by_chain: OnchainByChain { + equities: vec![ + ChainEquityBalance { + symbol: Symbol::new("tAAPL").unwrap(), + chain: Chain::Base, + available: shares(float!(50)), + }, + ChainEquityBalance { + symbol: Symbol::new("tAAPL").unwrap(), + chain: Chain::Robinhood, + available: shares(float!(9)), + }, + ], + usdc: vec![ + ChainUsdcBalance { + chain: Chain::Base, + available: Usdc::new(float!(2000)), + inflight: Usdc::new(float!(250)), + }, + ChainUsdcBalance { + chain: Chain::Robinhood, + available: Usdc::new(float!(500)), + inflight: Usdc::new(float!(0)), + }, + ], + }, + }; + + let per_chain: BTreeMap = render(&input, None) + .into_iter() + .filter(|((name, _), _)| name.contains("_chain_")) + .collect(); + + assert_eq!( + per_chain, + BTreeMap::from([ + ( + series( + "liq_equity_chain_available", + &[("chain", "base"), ("symbol", "AAPL")] + ), + 50.0 + ), + ( + series( + "liq_equity_chain_available", + &[("chain", "robinhood"), ("symbol", "AAPL")] + ), + 9.0 + ), + ( + series("liq_usdc_chain_available", &[("chain", "base")]), + 2000.0 + ), + ( + series("liq_usdc_chain_inflight", &[("chain", "base")]), + 250.0 + ), + ( + series("liq_usdc_chain_ratio", &[("chain", "base")]), + 4.0 / 7.0 + ), + ( + series("liq_usdc_chain_available", &[("chain", "robinhood")]), + 500.0 + ), + ( + series("liq_usdc_chain_inflight", &[("chain", "robinhood")]), + 0.0 + ), + ( + series("liq_usdc_chain_ratio", &[("chain", "robinhood")]), + 0.25 + ), + ]) + ); + } + + fn one_chain_vault(available: Float, cash: UsdcBalances) -> InventoryInput { + InventoryInput { + symbols: Vec::new(), + usdc: cash, + by_chain: OnchainByChain { + equities: Vec::new(), + usdc: vec![ChainUsdcBalance { + chain: Chain::HyperEvm, + available: Usdc::new(available), + inflight: Usdc::new(float!(0)), + }], + }, + } + } + + fn hyperevm_ratio(input: &InventoryInput) -> Option { + render(input, None) + .get(&series("liq_usdc_chain_ratio", &[("chain", "hyperevm")])) + .copied() + } + + /// The share is undefined, so the bot-only ratio is absent rather than + /// the exporter's 0; the vault's own series stay. + #[test] + fn a_chain_vault_and_broker_both_empty_leave_the_ratio_absent() { + let mut cash = usdc(float!(0), float!(0)); + cash.offchain_gross = Some(Usdc::new(float!(0))); + let input = one_chain_vault(float!(0), cash); + + assert_eq!(hyperevm_ratio(&input), None); + assert_eq!( + render(&input, None) + .get(&series( + "liq_usdc_chain_available", + &[("chain", "hyperevm")] + )) + .copied(), + Some(0.0) + ); + } + + /// Before the gross broker cash is read, the available broker cash can + /// be 0 while the broker holds cash, which would publish a vault holding + /// part of the cash as 1. The ratio waits for the gross reading. + #[test] + fn an_unread_gross_broker_cash_leaves_the_ratio_absent() { + let unread = one_chain_vault(float!(400), usdc(float!(0), float!(0))); + assert_eq!(hyperevm_ratio(&unread), None); + + let mut cash = usdc(float!(0), float!(0)); + cash.offchain_gross = Some(Usdc::new(float!(600))); + let read = one_chain_vault(float!(400), cash); + assert_eq!(hyperevm_ratio(&read), Some(0.4)); + } + /// The view read is the same data the dashboard DTO shows, so the /// builder sees what the exporter saw. #[test] @@ -754,7 +960,16 @@ pub(crate) mod tests { let from_view = InventoryInput::from_view(&view); let from_dashboard = input_from_dto(&view.to_dto()); - assert_eq!(from_view, from_dashboard); + assert_eq!(from_view.symbols, from_dashboard.symbols); + assert_eq!(from_view.usdc, from_dashboard.usdc); + assert_eq!( + from_view.by_chain.usdc, + vec![ChainUsdcBalance { + chain: Chain::Base, + available: Usdc::new(float!(2500.5)), + inflight: Usdc::new(float!(0)), + }] + ); } pub(crate) fn leaked_families() -> &'static LiqFamilies { diff --git a/src/metrics/liquidity/settings.rs b/src/metrics/liquidity/settings.rs index 95d535bc0..57b84e553 100644 --- a/src/metrics/liquidity/settings.rs +++ b/src/metrics/liquidity/settings.rs @@ -12,6 +12,7 @@ use rain_math_float::Float; use tracing::warn; use st0x_config::{BrokerCtx, Ctx, ExecutionThreshold, OperationMode}; +use st0x_evm::Chain; use st0x_finance::{Positive, Symbol}; use super::{ @@ -49,6 +50,7 @@ pub(crate) struct SettingsInput { usdc_target: Option, usdc_deviation: Option, cash_reserved: Option, + usdc_corridors: Vec, /// `None` for a share-count threshold: the series is dollars only. execution_threshold_usd: Option, assets: Vec, @@ -62,6 +64,15 @@ pub(crate) struct SettingsInput { inventory_poll_seconds: u64, } +/// One `[rebalancing.usdc.corridors.]` band, listed whatever the USDC +/// mode: disabling the mode stops new transfers, not the tables. +#[derive(Debug)] +struct CorridorInput { + chain: Chain, + target: Float, + deviation: Float, +} + #[derive(Debug)] struct AssetInput { symbol: Symbol, @@ -139,6 +150,15 @@ impl SettingsInput { usdc_target: usdc_band.map(|band| band.target), usdc_deviation: usdc_band.map(|band| band.deviation), cash_reserved: cash_reserved(ctx), + usdc_corridors: rebalancing + .usdc + .configured() + .map(|usdc| CorridorInput { + chain: usdc.corridor.chain(), + target: usdc.threshold.target, + deviation: usdc.threshold.deviation, + }) + .collect(), execution_threshold_usd, assets, wallet: ctx.wallet_meta.as_ref().map(|meta| WalletInput { @@ -235,6 +255,22 @@ pub(crate) fn settings_samples(input: &SettingsInput) -> Vec { push_sample(&mut samples, metric, vec![], integer_value(value)); } + for corridor in &input.usdc_corridors { + let labels = vec![("chain", corridor.chain.as_str().to_string())]; + push_sample( + &mut samples, + LiqMetric::UsdcCorridorTarget, + labels.clone(), + float_value(corridor.target), + ); + push_sample( + &mut samples, + LiqMetric::UsdcCorridorDeviation, + labels, + float_value(corridor.deviation), + ); + } + for asset in &input.assets { asset_samples(&mut samples, asset); } @@ -313,10 +349,13 @@ mod tests { use alloy::primitives::address; use serde_json::Value; + use st0x_bridge::corridor::{HopKind, UsdcCorridor}; use st0x_config::{ - ChainEquityAsset, EquityHedgePolicy, RebalancingMode, create_test_ctx_with_order_owner, + ChainEquityAsset, EquityHedgePolicy, ImbalanceThreshold, RebalancingMode, UsdcCorridorCtx, + UsdcCorridors, create_test_ctx_with_order_owner, }; use st0x_finance::Usd; + use st0x_float_macro::float; use super::*; use crate::dashboard::settings_from_ctx; @@ -334,6 +373,8 @@ mod tests { usdc_target: settings.usdc_target.map(decimal), usdc_deviation: settings.usdc_deviation.map(decimal), cash_reserved: settings.cash_reserved.map(Usd::inner), + // The dashboard settings carry one band, not each corridor's. + usdc_corridors: Vec::new(), execution_threshold_usd: Float::parse( settings .execution_threshold @@ -718,10 +759,14 @@ mod tests { } assert!(ctx.rebalancing.allocation.targets.contains_key(&primary)); - let from_ctx = render( + // The dashboard settings carry one USDC band, not each corridor's. + let from_ctx: BTreeMap = render( settings_samples(&SettingsInput::from_ctx(&ctx)), LiqFamily::Settings, - ); + ) + .into_iter() + .filter(|((name, _), _)| !name.starts_with("liq_usdc_corridor_")) + .collect(); let from_dashboard = render( settings_samples(&input_from_dto(&settings_from_ctx(&ctx))), LiqFamily::Settings, @@ -738,6 +783,111 @@ mod tests { ); } + fn corridor(chain: Chain, hop: HopKind, target: Float, deviation: Float) -> UsdcCorridorCtx { + UsdcCorridorCtx { + corridor: UsdcCorridor::HubRouted { chain, hop }, + threshold: ImbalanceThreshold { target, deviation }, + } + } + + fn corridor_series(ctx: &Ctx) -> BTreeMap { + render( + settings_samples(&SettingsInput::from_ctx(ctx)), + LiqFamily::Settings, + ) + .into_iter() + .filter(|((name, _), _)| name.starts_with("liq_usdc_corridor_")) + .collect() + } + + /// Each corridor table gets its own band, labelled by chain, while the + /// single USDC band stays the primary chain's. + #[test] + fn every_usdc_corridor_band_is_published_by_chain() { + let mut ctx = create_test_ctx_with_order_owner(address!( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + assert_eq!(ctx.chains.primary().chain, Chain::Base); + ctx.rebalancing.usdc = UsdcCorridors::for_test( + OperationMode::Enabled, + [ + corridor(Chain::HyperEvm, HopKind::Relay, float!(0.25), float!(0.125)), + corridor(Chain::Base, HopKind::Cctp, float!(0.5), float!(0.3)), + ], + ); + + assert_eq!( + corridor_series(&ctx), + BTreeMap::from([ + ( + series("liq_usdc_corridor_target", &[("chain", "base")]), + 0.5 + ), + ( + series("liq_usdc_corridor_deviation", &[("chain", "base")]), + 0.3 + ), + ( + series("liq_usdc_corridor_target", &[("chain", "hyperevm")]), + 0.25 + ), + ( + series("liq_usdc_corridor_deviation", &[("chain", "hyperevm")]), + 0.125 + ), + ]) + ); + let rendered = render( + settings_samples(&SettingsInput::from_ctx(&ctx)), + LiqFamily::Settings, + ); + assert_eq!( + rendered.get(&series("liq_settings_usdc_target", &[])), + Some(&0.5) + ); + } + + /// USDC mode disabled stops new transfers, not the corridor tables: their + /// bands are still published, while the single USDC band is absent. + #[test] + fn usdc_corridor_bands_are_published_while_usdc_mode_is_disabled() { + let mut ctx = create_test_ctx_with_order_owner(address!( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + ctx.rebalancing.usdc = UsdcCorridors::for_test( + OperationMode::Disabled, + [corridor( + Chain::Base, + HopKind::Cctp, + float!(0.5), + float!(0.3), + )], + ); + + assert_eq!( + corridor_series(&ctx), + BTreeMap::from([ + ( + series("liq_usdc_corridor_target", &[("chain", "base")]), + 0.5 + ), + ( + series("liq_usdc_corridor_deviation", &[("chain", "base")]), + 0.3 + ), + ]) + ); + let rendered = render( + settings_samples(&SettingsInput::from_ctx(&ctx)), + LiqFamily::Settings, + ); + assert_eq!(rendered.get(&series("liq_settings_usdc_target", &[])), None); + assert_eq!( + rendered.get(&series("liq_settings_usdc_deviation", &[])), + None + ); + } + #[test] fn health_publishes_the_short_commit_and_the_process_start() { let rendered = render(