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);