Skip to main content

sol_parser_sdk/
stock_prices.rs

1//! Opt-in stock/ETF execution-price notifications. No HTTP or RPC in this module.
2//! Prices describe reported swap amounts, not executable quotes or stock NAV.
3use crate::core::events::{EventMetadata, RawInstructionDexType, TradeParseStatus};
4use crate::grpc::{EventType, EventTypeFilter, TransactionFilter, YellowstoneGrpc};
5use crate::DexEvent;
6use crossbeam_queue::ArrayQueue;
7use serde::{Deserialize, Serialize};
8use solana_sdk::pubkey::Pubkey;
9use std::{collections::HashMap, sync::Arc};
10
11pub const WSOL: Pubkey = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
12pub const USDC: Pubkey = solana_sdk::pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
13pub const DEFAULT_SOL_USDC_POOL: Pubkey =
14    solana_sdk::pubkey!("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE");
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct StockToken {
18    pub mint: Pubkey,
19    pub symbol: String,
20    pub decimals: u8,
21    /// Identity directory, not an assertion about legal issuance or backing.
22    pub catalog_source: String,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct StockPool {
27    pub pool: Pubkey,
28    pub mint: Pubkey,
29    pub quote_mint: Pubkey,
30    pub dex_type: RawInstructionDexType,
31    /// Historical admission evidence, NOT a live fee quote.
32    #[serde(default)]
33    pub catalog_fee_snapshot: Option<StockPoolFeeSnapshot>,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct StockTokenTransferTax {
38    pub basis_points: u16,
39    /// Cap in this mint's raw units. Effective tax depends on transfer size.
40    pub maximum_fee_raw: u64,
41    pub transfer_hook: bool,
42    #[serde(default)]
43    pub transfer_hook_program: Option<String>,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
47pub struct StockPoolFeeSnapshot {
48    pub snapshot_slot: u64,
49    pub epoch: u64,
50    pub unix_timestamp: i64,
51    pub base_swap_fee_bps: f64,
52    /// None for dynamic/unknown rates; the base rate is not the total rate.
53    pub swap_fee_bps: Option<f64>,
54    /// Historical initial active-bin/tick rate, including dynamic fees when known.
55    /// Not a live fee or the average fee for an entire order.
56    #[serde(default)]
57    pub snapshot_start_swap_fee_bps: Option<f64>,
58    pub dynamic_swap_fee: bool,
59    pub stock_transfer_tax: StockTokenTransferTax,
60    pub quote_transfer_tax: StockTokenTransferTax,
61}
62
63/// Empty means disabled; `screened_defaults()` explicitly opts into the catalog.
64#[derive(Clone, Debug, Default, Serialize, Deserialize)]
65pub struct StockTokenCatalog {
66    pub tokens: Vec<StockToken>,
67    pub pools: Vec<StockPool>,
68}
69
70impl StockTokenCatalog {
71    /// Select verified stock-conversion pools from a successful transaction,
72    /// independent of its launch platform, wrapper or other swap legs.
73    /// These are historical identity clues, not a connected path or live quotes.
74    /// Failed transactions and mismatched pool/program/mint identities are excluded.
75    pub fn conversion_legs<'a>(
76        &'a self,
77        route: &'a crate::transaction_route::TransactionRoute,
78    ) -> impl Iterator<Item = &'a crate::transaction_route::RouteSwapLeg> + 'a {
79        use crate::instr::program_ids::*;
80        use crate::transaction_route::SwapProtocol;
81        route.legs.iter().filter(move |leg| {
82            if !route.succeeded {
83                return false;
84            }
85            let dex = match (leg.protocol, leg.program) {
86                (SwapProtocol::RaydiumCpmm, RAYDIUM_CPMM_PROGRAM_ID) => {
87                    RawInstructionDexType::RaydiumCpmm
88                }
89                (SwapProtocol::RaydiumClmm, RAYDIUM_CLMM_PROGRAM_ID) => {
90                    RawInstructionDexType::RaydiumClmm
91                }
92                (SwapProtocol::OrcaWhirlpool, ORCA_WHIRLPOOL_PROGRAM_ID) => {
93                    RawInstructionDexType::OrcaWhirlpool
94                }
95                (SwapProtocol::MeteoraDlmm, METEORA_DLMM_PROGRAM_ID) => {
96                    RawInstructionDexType::MeteoraDlmm
97                }
98                _ => return false,
99            };
100            self.pools.iter().any(|pool| {
101                pool.pool == leg.pool
102                    && pool.dex_type == dex
103                    && ((leg.input_mint == Some(pool.mint)
104                        && leg.output_mint == Some(pool.quote_mint))
105                        || (leg.input_mint == Some(pool.quote_mint)
106                            && leg.output_mint == Some(pool.mint)))
107            })
108        })
109    }
110
111    /// Point-in-time identities screened on 2026-10-20, not live eligibility.
112    pub fn screened_defaults() -> Self {
113        let data: serde_json::Value =
114            serde_json::from_str(include_str!("stock_token_catalog.json"))
115                .expect("bundled stock catalog is tested");
116        let key = |v: &serde_json::Value, field: &str| -> Pubkey {
117            v[field].as_str().unwrap().parse().expect("tested catalog address")
118        };
119        Self {
120            tokens: data["tokens"]
121                .as_array()
122                .unwrap()
123                .iter()
124                .map(|v| StockToken {
125                    mint: key(v, "mint"),
126                    symbol: v["symbol"].as_str().unwrap().into(),
127                    decimals: v["decimals"].as_u64().unwrap() as u8,
128                    catalog_source: v["catalog_source"].as_str().unwrap().into(),
129                })
130                .collect(),
131            pools: data["pools"]
132                .as_array()
133                .unwrap()
134                .iter()
135                .map(|v| StockPool {
136                    pool: key(v, "pool"),
137                    mint: key(v, "mint"),
138                    quote_mint: key(v, "quote_mint"),
139                    dex_type: serde_json::from_value(v["dex_type"].clone()).unwrap(),
140                    catalog_fee_snapshot: serde_json::from_value(v["catalog_fee_snapshot"].clone())
141                        .unwrap(),
142                })
143                .collect(),
144        }
145    }
146
147    /// First screened pool for a mint, ordered by known snapshot-start nominal fees in the
148    /// bundled catalog. Historical preference only; not a live routing quote.
149    pub fn preferred_pool(&self, mint: &Pubkey) -> Option<&StockPool> {
150        self.pools.iter().find(|pool| &pool.mint == mint)
151    }
152}
153
154#[derive(Clone, Debug)]
155pub struct StockPriceConfig {
156    pub catalog: StockTokenCatalog,
157    pub sol_usdc_pool: Pubkey,
158    pub sol_usdc_dex: RawInstructionDexType,
159    /// Local receive-time lifetime for conversion and optional liquidity snapshots.
160    pub max_age_us: i64,
161    /// Also reject cached data more than this many slots behind the trade.
162    pub max_slot_lag: u64,
163}
164
165impl Default for StockPriceConfig {
166    fn default() -> Self {
167        Self {
168            catalog: StockTokenCatalog::default(),
169            sol_usdc_pool: DEFAULT_SOL_USDC_POOL,
170            sol_usdc_dex: RawInstructionDexType::OrcaWhirlpool,
171            max_age_us: 30_000_000,
172            max_slot_lag: 75,
173        }
174    }
175}
176
177/// Optional independently collected TVL/depth, never inferred from CLMM liquidity.
178#[derive(Clone, Debug, Serialize, Deserialize)]
179pub struct StockPoolLiquidity {
180    pub pool: Pubkey,
181    pub slot: u64,
182    pub observed_us: i64,
183    pub tvl_usdc: Option<f64>,
184    pub buy_depth_usdc: Option<f64>,
185    pub sell_depth_usdc: Option<f64>,
186    /// Maximum tolerated price impact in basis points; must be below 10,000.
187    pub depth_max_price_impact_bps: Option<u16>,
188    pub source: String,
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
192pub struct SolUsdcReference {
193    pub pool: Pubkey,
194    pub usdc_per_sol: f64,
195    pub metadata: EventMetadata,
196}
197
198#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
199pub enum StockPriceBasis {
200    /// Ratio of the protocol's reported executed amounts, including its fee semantics.
201    /// Not a fee-adjusted user receipt, pool mid-price, or a quote for another size.
202    ReportedSwapAmounts,
203}
204
205#[derive(Clone, Debug, Serialize, Deserialize)]
206pub struct StockTokenPriceEvent {
207    pub metadata: EventMetadata,
208    pub mint: Pubkey,
209    pub symbol: String,
210    pub pool: Pubkey,
211    pub dex_type: RawInstructionDexType,
212    pub quote_mint: Pubkey,
213    pub price_in_quote: f64,
214    pub price_sol: Option<f64>,
215    pub price_usdc: Option<f64>,
216    pub basis: StockPriceBasis,
217    pub stock_amount_raw: u64,
218    pub quote_amount_raw: u64,
219    pub sol_usdc_reference: Option<SolUsdcReference>,
220    pub liquidity: Option<StockPoolLiquidity>,
221}
222
223/// Stateful, network-free processor; usable with an existing DEX event callback.
224pub struct StockPriceTracker {
225    config: StockPriceConfig,
226    tokens: HashMap<Pubkey, StockToken>,
227    pools: HashMap<Pubkey, TrackedPool>,
228    reference: Option<SolUsdcReference>,
229    liquidity: HashMap<Pubkey, StockPoolLiquidity>,
230    last_position: HashMap<Pubkey, (u64, u64)>,
231}
232
233struct TrackedPool {
234    pool: StockPool,
235    /// Immutable decimal conversion computed once, outside event processing.
236    quote_scale: f64,
237}
238
239fn supported(dex: RawInstructionDexType) -> bool {
240    matches!(
241        dex,
242        RawInstructionDexType::RaydiumCpmm
243            | RawInstructionDexType::RaydiumClmm
244            | RawInstructionDexType::OrcaWhirlpool
245            | RawInstructionDexType::MeteoraDlmm
246    )
247}
248
249impl StockPriceTracker {
250    pub fn new(mut config: StockPriceConfig) -> Result<Self, String> {
251        if config.max_age_us <= 0
252            || !supported(config.sol_usdc_dex)
253            || config.sol_usdc_pool == Pubkey::default()
254        {
255            return Err("invalid price freshness or unsupported reference DEX".into());
256        }
257        let catalog = std::mem::take(&mut config.catalog);
258        let mut tokens = HashMap::with_capacity(catalog.tokens.len());
259        for token in catalog.tokens {
260            if token.mint == Pubkey::default()
261                || token.mint == WSOL
262                || token.mint == USDC
263                || token.decimals > 18
264                || tokens.insert(token.mint, token).is_some()
265            {
266                return Err("invalid or duplicate stock token".into());
267            }
268        }
269        let mut pools = HashMap::with_capacity(catalog.pools.len());
270        for pool in catalog.pools {
271            if !tokens.contains_key(&pool.mint)
272                || ![WSOL, USDC].contains(&pool.quote_mint)
273                || !supported(pool.dex_type)
274                || pool.pool == Pubkey::default()
275                || pool.pool == config.sol_usdc_pool
276                || pools.contains_key(&pool.pool)
277            {
278                return Err("invalid, duplicate or unsupported stock pool".into());
279            }
280            let quote_decimals = if pool.quote_mint == WSOL { 9 } else { 6 };
281            let quote_scale = 10_f64.powi(i32::from(tokens[&pool.mint].decimals) - quote_decimals);
282            pools.insert(pool.pool, TrackedPool { pool, quote_scale });
283        }
284        Ok(Self {
285            config,
286            tokens,
287            pools,
288            reference: None,
289            liquidity: HashMap::new(),
290            last_position: HashMap::new(),
291        })
292    }
293
294    /// Invoke after disconnects, queue loss, or a caller-detected fork rollback.
295    pub fn reset(&mut self) {
296        self.reference = None;
297        self.liquidity.clear();
298        self.last_position.clear();
299    }
300
301    /// Publish externally collected coherent liquidity. No automatic RPC fallback.
302    pub fn update_liquidity(&mut self, snapshot: StockPoolLiquidity) -> Result<(), String> {
303        if !self.pools.contains_key(&snapshot.pool)
304            || snapshot.observed_us <= 0
305            || snapshot.source.trim().is_empty()
306            || snapshot.depth_max_price_impact_bps.is_some_and(|bps| bps >= 10_000)
307            || [snapshot.tvl_usdc, snapshot.buy_depth_usdc, snapshot.sell_depth_usdc]
308                .into_iter()
309                .flatten()
310                .any(|x| !x.is_finite() || x < 0.0)
311            || ((snapshot.buy_depth_usdc.is_some() || snapshot.sell_depth_usdc.is_some())
312                && snapshot.depth_max_price_impact_bps.is_none())
313        {
314            return Err("invalid liquidity snapshot".into());
315        }
316        if let Some(old) = self.liquidity.get(&snapshot.pool) {
317            if snapshot.slot < old.slot || snapshot.observed_us < old.observed_us {
318                return Err("out-of-order liquidity snapshot".into());
319            }
320        }
321        self.liquidity.insert(snapshot.pool, snapshot);
322        Ok(())
323    }
324
325    fn fresh(&self, observed_us: i64, slot: u64, metadata: &EventMetadata, now_us: i64) -> bool {
326        observed_us > 0
327            && observed_us <= now_us
328            && now_us.saturating_sub(observed_us) <= self.config.max_age_us
329            && slot <= metadata.slot
330            && metadata.slot - slot <= self.config.max_slot_lag
331    }
332
333    /// One notification per supported confirmed swap in a configured pool.
334    /// Instruction-only Shred candidates never produce a price. `now_us` must
335    /// use the same wall-clock domain as EventMetadata.grpc_recv_us.
336    pub fn process(&mut self, event: &DexEvent, now_us: i64) -> Option<StockTokenPriceEvent> {
337        if self.pools.is_empty()
338            || event.trade_parse_status() != Some(TradeParseStatus::ExecutionConfirmed)
339        {
340            return None;
341        }
342        let swap = executed_swap(event)?;
343        let metadata = event.metadata();
344        if swap.input_amount == 0
345            || swap.output_amount == 0
346            || !self.fresh(metadata.grpc_recv_us, metadata.slot, metadata, now_us)
347        {
348            return None;
349        }
350        let position = (metadata.slot, metadata.tx_index);
351        if self.last_position.get(&swap.pool).is_some_and(|old| *old > position) {
352            return None;
353        }
354        if swap.pool == self.config.sol_usdc_pool && swap.dex == self.config.sol_usdc_dex {
355            let (sol, usdc) = if swap.input == WSOL && swap.output == USDC {
356                (swap.input_amount, swap.output_amount)
357            } else if swap.input == USDC && swap.output == WSOL {
358                (swap.output_amount, swap.input_amount)
359            } else {
360                return None;
361            };
362            self.reference = Some(SolUsdcReference {
363                pool: swap.pool,
364                usdc_per_sol: usdc as f64 / sol as f64 * 1000.0,
365                metadata: metadata.clone(),
366            });
367            self.last_position.insert(swap.pool, position);
368            return None;
369        }
370        let tracked = self.pools.get(&swap.pool)?;
371        let pool = &tracked.pool;
372        if pool.dex_type != swap.dex {
373            return None;
374        }
375        let token = self.tokens.get(&pool.mint)?;
376        let (stock, quote) = if swap.input == pool.mint && swap.output == pool.quote_mint {
377            (swap.input_amount, swap.output_amount)
378        } else if swap.output == pool.mint && swap.input == pool.quote_mint {
379            (swap.output_amount, swap.input_amount)
380        } else {
381            return None;
382        };
383        let price = quote as f64 / stock as f64 * tracked.quote_scale;
384        if !price.is_finite() || price <= 0.0 {
385            return None;
386        }
387        let reference = self
388            .reference
389            .as_ref()
390            .filter(|r| {
391                // Metadata has no universal CPI position. A reference from the
392                // same transaction could execute after this swap, so exclude it.
393                (r.metadata.slot, r.metadata.tx_index) < position
394                    && self.fresh(r.metadata.grpc_recv_us, r.metadata.slot, metadata, now_us)
395            })
396            .cloned();
397        let (price_sol, price_usdc) = if pool.quote_mint == WSOL {
398            (Some(price), reference.as_ref().map(|r| price * r.usdc_per_sol))
399        } else {
400            (reference.as_ref().map(|r| price / r.usdc_per_sol), Some(price))
401        };
402        let liquidity = self
403            .liquidity
404            .get(&swap.pool)
405            .filter(|l| self.fresh(l.observed_us, l.slot, metadata, now_us))
406            .cloned();
407        self.last_position.insert(swap.pool, position);
408        Some(StockTokenPriceEvent {
409            metadata: metadata.clone(),
410            mint: token.mint,
411            symbol: token.symbol.clone(),
412            pool: pool.pool,
413            dex_type: pool.dex_type,
414            quote_mint: pool.quote_mint,
415            price_in_quote: price,
416            price_sol,
417            price_usdc,
418            basis: StockPriceBasis::ReportedSwapAmounts,
419            stock_amount_raw: stock,
420            quote_amount_raw: quote,
421            sol_usdc_reference: reference,
422            liquidity,
423        })
424    }
425}
426
427struct ExecutedSwap {
428    pool: Pubkey,
429    dex: RawInstructionDexType,
430    input: Pubkey,
431    output: Pubkey,
432    input_amount: u64,
433    output_amount: u64,
434}
435
436fn executed_swap(event: &DexEvent) -> Option<ExecutedSwap> {
437    use RawInstructionDexType::*;
438    let (pool, dex, input, output, input_amount, output_amount) = match event {
439        DexEvent::RaydiumCpmmSwap(e) if e.input_vault_before > 0 && e.output_vault_before > 0 => (
440            e.pool_id,
441            RaydiumCpmm,
442            e.input_token_mint,
443            e.output_token_mint,
444            e.input_amount,
445            e.output_amount,
446        ),
447        DexEvent::RaydiumClmmSwap(e) if e.sqrt_price_x64 > 0 => {
448            let (a, b) =
449                if e.zero_for_one { (e.amount_0, e.amount_1) } else { (e.amount_1, e.amount_0) };
450            (e.pool_state, RaydiumClmm, e.input_mint, e.output_mint, a, b)
451        }
452        DexEvent::OrcaWhirlpoolSwap(e) if e.post_sqrt_price > 0 => {
453            let (a, b) = if e.a_to_b {
454                (e.token_mint_a, e.token_mint_b)
455            } else {
456                (e.token_mint_b, e.token_mint_a)
457            };
458            (e.whirlpool, OrcaWhirlpool, a, b, e.input_amount, e.output_amount)
459        }
460        // Legacy exact-out instructions place max_input in amount_in. Successful
461        // execution alone cannot turn that limit into a price; require a decoded
462        // binary execution event (version 1/2), not instruction/text fallback.
463        DexEvent::MeteoraDlmmSwap(e) if matches!(e.event_version, 1 | 2) => {
464            let (a, b) = if e.swap_for_y {
465                (e.token_x_mint, e.token_y_mint)
466            } else {
467                (e.token_y_mint, e.token_x_mint)
468            };
469            (e.pool, MeteoraDlmm, a, b, e.amount_in, e.amount_out)
470        }
471        _ => return None,
472    };
473    Some(ExecutedSwap { pool, dex, input, output, input_amount, output_amount })
474}
475
476/// Dedicated opt-in price queue. Use a separate YellowstoneGrpc client: like
477/// subscribe_dex_events, subscribing replaces that client's previous subscription.
478pub struct StockPriceSubscription {
479    grpc: YellowstoneGrpc,
480    queue: Arc<ArrayQueue<DexEvent>>,
481    tracker: StockPriceTracker,
482    revision: u64,
483}
484
485impl StockPriceSubscription {
486    pub async fn subscribe(
487        grpc: YellowstoneGrpc,
488        config: StockPriceConfig,
489    ) -> Result<Self, Box<dyn std::error::Error>> {
490        let tracker = StockPriceTracker::new(config).map_err(std::io::Error::other)?;
491        if tracker.pools.is_empty() {
492            return Err("stock price subscription needs configured pools".into());
493        }
494        let mut accounts: Vec<String> = tracker.pools.keys().map(ToString::to_string).collect();
495        accounts.push(tracker.config.sol_usdc_pool.to_string());
496        let filter = EventTypeFilter::include_only(vec![
497            EventType::RaydiumCpmmSwap,
498            EventType::RaydiumClmmSwap,
499            EventType::OrcaWhirlpoolSwap,
500            EventType::MeteoraDlmmSwap,
501        ]);
502        let queue = grpc
503            .subscribe_dex_events(
504                vec![TransactionFilter { account_include: accounts, ..Default::default() }],
505                vec![],
506                Some(filter),
507            )
508            .await?;
509        let revision = grpc.subscription_status().continuity_revision;
510        Ok(Self { grpc, queue, tracker, revision })
511    }
512
513    pub fn update_liquidity(&mut self, snapshot: StockPoolLiquidity) -> Result<(), String> {
514        self.tracker.update_liquidity(snapshot)
515    }
516
517    /// Bounded drain; call again when None. Clears stale caches after stream gaps.
518    pub fn pop(&mut self, now_us: i64) -> Option<StockTokenPriceEvent> {
519        let status = self.grpc.subscription_status();
520        if status.continuity_revision != self.revision || !status.connected {
521            self.revision = status.continuity_revision;
522            self.tracker.reset();
523            for _ in 0..self.queue.len() {
524                self.queue.pop();
525            }
526            return None;
527        }
528        for _ in 0..256 {
529            let event = self.queue.pop()?;
530            if let Some(price) = self.tracker.process(&event, now_us) {
531                if self.grpc.subscription_status().continuity_revision != self.revision {
532                    self.tracker.reset();
533                    return None;
534                }
535                return Some(price);
536            }
537        }
538        None
539    }
540
541    pub async fn stop(&self) {
542        self.grpc.stop().await;
543    }
544}