Skip to main content

algo_sdk/
features.rs

1//! Online microstructure features and chain fee data.
2
3// =============================================================================
4// Online Features (Phase 2B — microstructure features delivered to algos)
5// =============================================================================
6
7/// WASM offset for OnlineFeatures — page-aligned after PoolBooks.
8/// PoolBooks at 0x14000, size ~23KB -> ends well before 0x1A000.
9pub const ONLINE_FEATURES_WASM_OFFSET: u32 = 0x1A000;
10
11/// Online microstructure features delivered by the CC data engine.
12/// Written to WASM memory before each algo callback.
13/// Old algos that never read 0x1A000 are unaffected (backward compatible).
14#[derive(Clone, Copy, Debug)]
15#[repr(C)]
16pub struct OnlineFeatures {
17    /// ABI version (currently 1).
18    pub version: u16,
19    /// Bit flags: bit 0 = vpin_valid.
20    pub flags: u16,
21    pub _pad0: [u8; 4],
22
23    // ── Microprice ───────────────────────────────────────────────────────
24    /// Microprice scaled 1e9.
25    pub microprice_1e9: u64,
26
27    // ── OFI / MLOFI (scaled 1e8) ────────────────────────────────────────
28    /// Order flow imbalance, top-of-book, scaled 1e8.
29    pub ofi_1level_1e8: i64,
30    /// Order flow imbalance, 5 levels, scaled 1e8.
31    pub ofi_5level_1e8: i64,
32    /// Multi-level OFI (10 levels), scaled 1e8.
33    pub mlofi_10_1e8: i64,
34    /// OFI EWMA, scaled 1e6.
35    pub ofi_ewma_1e6: i64,
36
37    // ── Trade flow ───────────────────────────────────────────────────────
38    /// Trade sign imbalance [-1e6, +1e6].
39    pub trade_sign_imbalance_1e6: i64,
40    /// Trades/sec x 1000.
41    pub trade_arrival_rate_1e3: u32,
42    /// VPIN [0, 10000] — only valid when flags bit 0 is set.
43    pub vpin_1e4: u16,
44    pub _pad1: u16,
45
46    // ── Spread state ─────────────────────────────────────────────────────
47    /// 0=tight, 1=normal, 2=wide, 3=crisis.
48    pub spread_regime: u8,
49    pub _pad2a: u8,
50    /// Spread z-score x 1000.
51    pub spread_zscore_1e3: i16,
52
53    // ── Depth analytics ──────────────────────────────────────────────────
54    /// Cancel rate [0, 10000].
55    pub cancel_rate_1e4: u16,
56    /// Depth imbalance [-10000, +10000].
57    pub depth_imbalance_1e4: i16,
58
59    // ── Realized vol ─────────────────────────────────────────────────────
60    /// 1-minute realized vol in basis points.
61    pub rv_1m_bps: u32,
62    /// 5-minute realized vol in basis points.
63    pub rv_5m_bps: u32,
64    /// 1-hour realized vol in basis points.
65    pub rv_1h_bps: u32,
66    pub _pad3: u32,
67
68    // ── Multi-head prediction (populated by Phase 6 sidecar) ─────────────
69    /// Probability of up direction [0, 10000].
70    pub pred_dir_up_1e4: u16,
71    /// Probability of flat direction [0, 10000].
72    pub pred_dir_flat_1e4: u16,
73    /// Probability of down direction [0, 10000].
74    pub pred_dir_down_1e4: u16,
75    /// Probability of normal stress [0, 10000].
76    pub pred_stress_normal_1e4: u16,
77    /// Probability of widening stress [0, 10000].
78    pub pred_stress_widening_1e4: u16,
79    /// Probability of crisis stress [0, 10000].
80    pub pred_stress_crisis_1e4: u16,
81    /// Probability of toxic flow [0, 10000].
82    pub pred_toxic_1e4: u16,
83    /// Age of prediction in ms.
84    pub prediction_age_ms: u16,
85
86    // ── Fill probability (populated by Phase 6 sidecar) ──────────────────
87    /// Fill probability on bid side [0, 10000].
88    pub fill_prob_bid_1e4: u16,
89    /// Fill probability on ask side [0, 10000].
90    pub fill_prob_ask_1e4: u16,
91    /// Queue decay rate [0, 10000].
92    pub queue_decay_rate_1e4: u16,
93    pub _fill_pad: u16,
94
95    // ── Timestamp ────────────────────────────────────────────────────────
96    /// Feature computation timestamp in nanoseconds.
97    pub feature_ts_ns: u64,
98
99    /// Reserved for future expansion.
100    pub _reserved: [u8; 136],
101}
102
103impl Default for OnlineFeatures {
104    fn default() -> Self {
105        // Safety: all-zero is valid for every field, then set version=1
106        let mut f = unsafe { core::mem::zeroed::<Self>() };
107        f.version = 1;
108        f
109    }
110}
111
112impl OnlineFeatures {
113    /// Whether the VPIN field is valid (flags bit 0).
114    #[inline(always)]
115    pub fn vpin_valid(&self) -> bool {
116        self.flags & 1 != 0
117    }
118
119    /// Microprice as f64 (divide by 1e9).
120    #[inline(always)]
121    pub fn microprice_f64(&self) -> f64 {
122        self.microprice_1e9 as f64 / 1_000_000_000.0
123    }
124
125    /// OFI 1-level as f64 (divide by 1e8).
126    #[inline(always)]
127    pub fn ofi_1level_f64(&self) -> f64 {
128        self.ofi_1level_1e8 as f64 / 100_000_000.0
129    }
130
131    /// Trade sign imbalance as f64 in [-1.0, +1.0].
132    #[inline(always)]
133    pub fn trade_sign_imbalance_f64(&self) -> f64 {
134        self.trade_sign_imbalance_1e6 as f64 / 1_000_000.0
135    }
136}
137
138// Compile-time ABI checks for OnlineFeatures
139const _: () = assert!(
140    core::mem::size_of::<OnlineFeatures>() == 256,
141    "OnlineFeatures must be exactly 256 bytes"
142);
143const _: () = assert!(
144    0x1A000 >= 0x14000 + core::mem::size_of::<crate::PoolBooks>(),
145    "ONLINE_FEATURES_WASM_OFFSET overlaps with PoolBooks"
146);
147const _: () = assert!(
148    ONLINE_FEATURES_WASM_OFFSET as usize + core::mem::size_of::<OnlineFeatures>() < 0x1000000,
149    "OnlineFeatures exceeds WASM 16MB memory limit"
150);
151
152// =============================================================================
153// CHAIN FEE TABLE (per-chain gas fee data for cost-aware algorithms)
154// =============================================================================
155
156/// WASM offset for ChainFeeTable — page-aligned after OnlineFeatures.
157/// OnlineFeatures at 0x1A000 (256 bytes) ends at 0x1A100. Headroom to 0x1B000.
158pub const CHAIN_FEE_TABLE_WASM_OFFSET: u32 = 0x1B000;
159
160/// Maximum chains tracked in a ChainFeeTable.
161pub const MAX_CHAINS: usize = 8;
162
163/// Per-chain gas fee snapshot. Interpretation depends on chain_id:
164/// - EVM (chain_id 0-4): base_fee_native=gwei, priority_fee_native=gwei,
165///   estimated_gas_units=gas (150k-300k depending on protocol)
166/// - Solana (chain_id 5): base_fee_native=lamports (5000), priority_fee_native=micro-lamports/CU,
167///   estimated_gas_units=compute units (~200k)
168#[derive(Clone, Copy, Debug)]
169#[repr(C)]
170pub struct ChainFee {
171    /// Chain identifier (0=eth, 1=arb, 2=base, 3=op, 4=polygon, 5=solana).
172    pub chain_id: u8,
173    pub _pad: [u8; 7],
174    /// Base fee in chain-native units.
175    pub base_fee_native: u64,
176    /// Priority/tip fee in chain-native units.
177    pub priority_fee_native: u64,
178    /// Estimated gas/compute units for a standard swap.
179    pub estimated_gas_units: u64,
180    /// Native token price in USD, 1e9-scaled (ETH/MATIC/SOL).
181    pub native_price_1e9: u64,
182    /// Timestamp of last observation (nanoseconds since epoch).
183    pub last_update_ns: u64,
184}
185
186impl Default for ChainFee {
187    fn default() -> Self {
188        Self {
189            chain_id: 255,
190            _pad: [0; 7],
191            base_fee_native: 0,
192            priority_fee_native: 0,
193            estimated_gas_units: 0,
194            native_price_1e9: 0,
195            last_update_ns: 0,
196        }
197    }
198}
199
200impl ChainFee {
201    /// Total gas cost in the smallest native unit (gwei for EVM, lamports for Solana).
202    ///
203    /// - EVM:    (base_gwei + priority_gwei) * gas_units  → total gwei
204    /// - Solana: base_lamports + (priority_micro_lamports * CU / 1_000_000) → total lamports
205    pub fn total_gas_cost_native(&self) -> u64 {
206        if self.chain_id == chain_id::SOLANA {
207            let priority_lamports = (self.priority_fee_native as u128)
208                .saturating_mul(self.estimated_gas_units as u128)
209                / 1_000_000;
210            (self.base_fee_native as u128).saturating_add(priority_lamports) as u64
211        } else {
212            (self.base_fee_native + self.priority_fee_native)
213                .saturating_mul(self.estimated_gas_units)
214        }
215    }
216
217    /// Estimated gas cost in USD (1e9 scaled).
218    ///
219    /// Both EVM and Solana share the same final step once `total_gas_cost_native()`
220    /// returns the correct smallest-unit value:
221    ///   cost_usd_1e9 = total_native * native_price_1e9 / 1e9
222    /// which equals cost_usd = total_native * price / 1e18 (the /1e9 handles
223    /// smallest-unit→whole-coin, the price is already 1e9-scaled).
224    pub fn gas_cost_usd_1e9(&self) -> u64 {
225        let cost = self.total_gas_cost_native();
226        ((cost as u128 * self.native_price_1e9 as u128) / 1_000_000_000) as u64
227    }
228}
229
230/// Per-symbol chain fee table. Written to WASM memory at CHAIN_FEE_TABLE_WASM_OFFSET.
231/// Algos join pool→chain via `venue_chain_id(pool_meta.venue_id)`.
232#[derive(Clone, Copy)]
233#[repr(C)]
234pub struct ChainFeeTable {
235    /// Number of valid entries in `chains[]`.
236    pub chain_ct: u8,
237    pub _pad: [u8; 7],
238    /// Per-chain fee snapshots.
239    pub chains: [ChainFee; MAX_CHAINS],
240}
241
242impl Default for ChainFeeTable {
243    fn default() -> Self {
244        Self {
245            chain_ct: 0,
246            _pad: [0; 7],
247            chains: [ChainFee::default(); MAX_CHAINS],
248        }
249    }
250}
251
252impl ChainFeeTable {
253    /// Look up fee data for a specific chain.
254    pub fn fee_for_chain(&self, chain_id: u8) -> Option<&ChainFee> {
255        for i in 0..self.chain_ct as usize {
256            if i < MAX_CHAINS && self.chains[i].chain_id == chain_id {
257                return Some(&self.chains[i]);
258            }
259        }
260        None
261    }
262}
263
264/// Chain ID constants for use with `ChainFeeTable::fee_for_chain()`.
265pub mod chain_id {
266    pub const ETHEREUM: u8 = 0;
267    pub const ARBITRUM: u8 = 1;
268    pub const BASE: u8 = 2;
269    pub const OPTIMISM: u8 = 3;
270    pub const POLYGON: u8 = 4;
271    pub const SOLANA: u8 = 5;
272}
273
274/// Map venue_id (from PoolMeta/NbboSnapshot) to chain_id.
275pub fn venue_chain_id(venue_id: u8) -> u8 {
276    match venue_id {
277        10 => chain_id::ETHEREUM, // VENUE_DEX_ETH
278        11 => chain_id::ARBITRUM, // VENUE_DEX_ARB
279        12 => chain_id::BASE,     // VENUE_DEX_BASE
280        13 => chain_id::OPTIMISM, // VENUE_DEX_OP
281        14 => chain_id::POLYGON,  // VENUE_DEX_POLY
282        15 => chain_id::SOLANA,   // VENUE_DEX_SOL
283        _ => 255,                  // Unknown / CEX
284    }
285}
286
287// Compile-time ABI checks for ChainFeeTable
288const _: () = assert!(
289    core::mem::size_of::<ChainFee>() == 48,
290    "ChainFee must be exactly 48 bytes"
291);
292const _: () = assert!(
293    core::mem::size_of::<ChainFeeTable>() == 392,
294    "ChainFeeTable must be exactly 392 bytes (8 + 48*8)"
295);
296const _: () = assert!(
297    CHAIN_FEE_TABLE_WASM_OFFSET as usize
298        >= ONLINE_FEATURES_WASM_OFFSET as usize + core::mem::size_of::<OnlineFeatures>(),
299    "ChainFeeTable overlaps OnlineFeatures"
300);