Skip to main content

algo_sdk/
state.rs

1//! Position state, orders, risk limits, symbol metadata.
2
3// =============================================================================
4// OPEN ORDER
5// =============================================================================
6
7/// Order status.
8pub mod Status {
9    pub const PENDING: u8 = 0; // Sent, awaiting ack
10    pub const ACKED: u8 = 1; // Acknowledged by exchange
11    pub const PARTIAL: u8 = 2; // Partially filled
12    pub const DEAD: u8 = 3; // Filled/cancelled/rejected
13}
14
15/// Open order tracked by server.
16#[derive(Debug, Clone, Copy, Default)]
17#[repr(C)]
18pub struct OpenOrder {
19    pub order_id: u64,
20    pub px_1e9: u64,
21    pub qty_1e8: i64,    // Signed: positive=buy, negative=sell
22    pub filled_1e8: i64, // Amount filled
23    pub side: i8,        // 1=buy, -1=sell
24    pub status: u8,
25    pub _pad: [u8; 6],
26}
27
28impl OpenOrder {
29    pub const EMPTY: Self = Self {
30        order_id: 0,
31        px_1e9: 0,
32        qty_1e8: 0,
33        filled_1e8: 0,
34        side: 0,
35        status: 0,
36        _pad: [0; 6],
37    };
38
39    #[inline(always)]
40    pub fn is_live(&self) -> bool {
41        self.status == Status::ACKED || self.status == Status::PARTIAL
42    }
43
44    #[inline(always)]
45    pub fn is_pending(&self) -> bool {
46        self.status == Status::PENDING
47    }
48
49    #[inline(always)]
50    pub fn remaining_1e8(&self) -> i64 {
51        self.qty_1e8.abs() - self.filled_1e8.abs()
52    }
53}
54
55// =============================================================================
56// SYMBOL METADATA (server-injected, read-only)
57// =============================================================================
58
59/// Symbol trading specifications from the exchange.
60/// Injected by runtime — prevents rejects from wrong lot size / tick size.
61/// Fields set to 0 mean "unknown" — helpers return input unchanged.
62#[derive(Debug, Clone, Copy)]
63#[repr(C)]
64pub struct SymbolMeta {
65    /// Minimum price increment (1e9 scaled, e.g. 10_000_000 = $0.01). 0 = unknown.
66    pub tick_size_1e9: u64,
67    /// Minimum qty increment (1e8 scaled, e.g. 1_000_000 = 0.01 units). 0 = unknown.
68    pub lot_size_1e8: u64,
69    /// Minimum order quantity (1e8 scaled). 0 = unknown.
70    pub min_qty_1e8: u64,
71    /// Minimum order notional value (1e9 scaled, e.g. 10_000_000_000 = $10). 0 = no minimum.
72    pub min_notional_1e9: u64,
73    /// Price decimal precision (e.g. 2 = $100.00).
74    pub price_precision: u8,
75    /// Quantity decimal precision (e.g. 8 = 0.00000001).
76    pub qty_precision: u8,
77    pub _pad: [u8; 6],
78}
79
80impl Default for SymbolMeta {
81    fn default() -> Self {
82        Self {
83            tick_size_1e9: 0,
84            lot_size_1e8: 0,
85            min_qty_1e8: 0,
86            min_notional_1e9: 0,
87            price_precision: 0,
88            qty_precision: 0,
89            _pad: [0; 6],
90        }
91    }
92}
93
94impl SymbolMeta {
95    pub const EMPTY: Self = Self {
96        tick_size_1e9: 0,
97        lot_size_1e8: 0,
98        min_qty_1e8: 0,
99        min_notional_1e9: 0,
100        price_precision: 0,
101        qty_precision: 0,
102        _pad: [0; 6],
103    };
104
105    /// Round price DOWN to nearest tick. Returns raw if tick_size == 0 (unknown).
106    #[inline(always)]
107    pub fn round_px(&self, px_1e9: u64) -> u64 {
108        if self.tick_size_1e9 == 0 {
109            return px_1e9;
110        }
111        (px_1e9 / self.tick_size_1e9) * self.tick_size_1e9
112    }
113
114    /// Round qty DOWN to nearest lot. Returns raw if lot_size == 0 (unknown).
115    #[inline(always)]
116    pub fn round_qty(&self, qty_1e8: i64) -> i64 {
117        if self.lot_size_1e8 == 0 {
118            return qty_1e8;
119        }
120        let lot = self.lot_size_1e8 as i64;
121        (qty_1e8 / lot) * lot
122    }
123
124    /// Check min notional. Returns true if unknown (0) — skip check, don't false-reject.
125    #[inline(always)]
126    pub fn check_notional(&self, qty_1e8: i64, px_1e9: u64) -> bool {
127        if self.min_notional_1e9 == 0 {
128            return true;
129        }
130        let notional = (qty_1e8.unsigned_abs() as u128 * px_1e9 as u128 / 100_000_000) as u64;
131        notional >= self.min_notional_1e9
132    }
133
134    /// Check min quantity. Returns true if unknown (0).
135    #[inline(always)]
136    pub fn check_min_qty(&self, qty_1e8: i64) -> bool {
137        if self.min_qty_1e8 == 0 {
138            return true;
139        }
140        qty_1e8.unsigned_abs() >= self.min_qty_1e8
141    }
142}
143
144// =============================================================================
145// RISK SNAPSHOT (server-injected, read-only)
146// =============================================================================
147
148/// Current risk limits — read-only view for algos.
149/// Allows pre-checking orders before placing (avoids wasting actions on rejects).
150#[derive(Debug, Clone, Copy)]
151#[repr(C)]
152pub struct RiskSnapshot {
153    /// Max absolute net position (1e8 scaled).
154    pub max_position_1e8: i64,
155    /// Daily loss limit (1e9 scaled). Algo paused if session PnL < -max_daily_loss. 0 = disabled.
156    pub max_daily_loss_1e9: i64,
157    /// Max single order notional (1e9 scaled).
158    pub max_order_notional_1e9: u64,
159    /// Max orders per second.
160    pub max_order_rate: u32,
161    /// 1 = reduce-only mode (can only close position).
162    pub reduce_only: u8,
163    pub _pad: [u8; 3],
164}
165
166impl Default for RiskSnapshot {
167    fn default() -> Self {
168        Self {
169            max_position_1e8: 0,
170            max_daily_loss_1e9: 0,
171            max_order_notional_1e9: 0,
172            max_order_rate: 0,
173            reduce_only: 0,
174            _pad: [0; 3],
175        }
176    }
177}
178
179impl RiskSnapshot {
180    pub const EMPTY: Self = Self {
181        max_position_1e8: 0,
182        max_daily_loss_1e9: 0,
183        max_order_notional_1e9: 0,
184        max_order_rate: 0,
185        reduce_only: 0,
186        _pad: [0; 3],
187    };
188
189    /// Check if adding `delta_1e8` to `current_position_1e8` would exceed position limit.
190    /// Returns true if order is safe. Returns true if limit is 0 (unknown/unlimited).
191    #[inline(always)]
192    pub fn check_position(&self, current_position_1e8: i64, delta_1e8: i64) -> bool {
193        if self.max_position_1e8 == 0 {
194            return true;
195        }
196        let projected = current_position_1e8.saturating_add(delta_1e8);
197        projected.abs() <= self.max_position_1e8
198    }
199}
200
201// =============================================================================
202// ALGO STATE - Position + Orders (server-managed)
203// =============================================================================
204
205/// Maximum open orders per algo.
206pub const MAX_ORDERS: usize = 32;
207
208/// Algo state: position, orders, session stats, symbol metadata, and risk limits.
209/// All managed by server — read-only from algo's perspective.
210///
211/// New fields (v0.2+) are appended after `_pad` for ABI backward compatibility.
212/// Old WASM binaries compiled with v0.1.x read only up to `_pad` and ignore the rest.
213#[derive(Clone, Copy)]
214#[repr(C)]
215pub struct AlgoState {
216    // --- Position (v0.1) ---
217    pub position_1e8: i64,       // Net position (positive=long)
218    pub avg_entry_1e9: u64,      // Average entry price
219    pub realized_pnl_1e9: i64,   // Realized PnL (lifetime)
220    pub unrealized_pnl_1e9: i64, // Unrealized PnL (mark-to-market)
221    // --- Orders (v0.1) ---
222    pub orders: [OpenOrder; MAX_ORDERS],
223    pub order_ct: u8,
224    pub _pad: [u8; 7],
225    // --- Session stats (v0.2) ---
226    pub session_pnl_1e9: i64, // Session realized PnL (resets at UTC midnight)
227    pub total_fill_count: u64, // Lifetime fill count (never resets)
228    // --- Symbol metadata (v0.2) ---
229    pub symbol: SymbolMeta, // Tick size, lot size, min notional
230    // --- Risk limits (v0.2) ---
231    pub risk: RiskSnapshot, // Max position, daily loss limit, etc.
232    // --- Mesh identity (v0.3) ---
233    /// This algo instance's mesh_id. 0 = not a mesh algo.
234    /// Set by the runtime on deploy — the algo reads this to know its own identity.
235    pub mesh_id: u64,
236    /// Number of active peer mesh_ids in `mesh_peers` (max 16).
237    pub mesh_peer_count: u8,
238    pub _mesh_pad: [u8; 7],
239    /// Mesh_ids of all peer instances of this algo (max 16).
240    /// Populated by CC on deploy and updated on topology changes.
241    pub mesh_peers: [u64; 16],
242    // --- Symbol identity (v0.4) ---
243    /// Which symbol triggered this callback.
244    pub symbol_id: u16,
245    pub _sym_pad: [u8; 6],
246    /// Symbol name (null-padded, e.g., b"BTC-USD\0...").
247    /// For multi-symbol deployments, check this to know which pair you're seeing.
248    pub symbol_name: [u8; 16],
249}
250
251impl Default for AlgoState {
252    fn default() -> Self {
253        Self {
254            position_1e8: 0,
255            avg_entry_1e9: 0,
256            realized_pnl_1e9: 0,
257            unrealized_pnl_1e9: 0,
258            orders: [OpenOrder::EMPTY; MAX_ORDERS],
259            order_ct: 0,
260            _pad: [0; 7],
261            session_pnl_1e9: 0,
262            total_fill_count: 0,
263            symbol: SymbolMeta::EMPTY,
264            risk: RiskSnapshot::EMPTY,
265            mesh_id: 0,
266            mesh_peer_count: 0,
267            _mesh_pad: [0; 7],
268            mesh_peers: [0; 16],
269            symbol_id: 0,
270            _sym_pad: [0; 6],
271            symbol_name: [0; 16],
272        }
273    }
274}
275
276impl AlgoState {
277    /// Get the symbol name as a string (e.g., "BTC-USD").
278    pub fn symbol_name(&self) -> &str {
279        let end = self.symbol_name.iter().position(|&b| b == 0).unwrap_or(16);
280        core::str::from_utf8(&self.symbol_name[..end]).unwrap_or("")
281    }
282}
283
284
285/// PnL snapshot in fixed-point units (1e9 = $1.00).
286#[derive(Debug, Clone, Copy, Default)]
287pub struct PnlSnapshot {
288    pub realized_1e9: i64,
289    pub unrealized_1e9: i64,
290    pub total_1e9: i64,
291}
292
293impl AlgoState {
294    #[inline(always)]
295    pub fn is_flat(&self) -> bool {
296        self.position_1e8 == 0
297    }
298
299    #[inline(always)]
300    pub fn is_long(&self) -> bool {
301        self.position_1e8 > 0
302    }
303
304    #[inline(always)]
305    pub fn is_short(&self) -> bool {
306        self.position_1e8 < 0
307    }
308
309    #[inline(always)]
310    pub fn has_orders(&self) -> bool {
311        self.order_ct > 0
312    }
313
314    #[inline(always)]
315    pub fn live_order_count(&self) -> usize {
316        let mut ct = 0;
317        for i in 0..self.order_ct as usize {
318            if self.orders[i].is_live() {
319                ct += 1;
320            }
321        }
322        ct
323    }
324
325    #[inline(always)]
326    pub fn find_order(&self, order_id: u64) -> Option<&OpenOrder> {
327        for i in 0..self.order_ct as usize {
328            if self.orders[i].order_id == order_id {
329                return Some(&self.orders[i]);
330            }
331        }
332        None
333    }
334
335    #[inline(always)]
336    pub fn open_buy_qty_1e8(&self) -> i64 {
337        let mut sum = 0i64;
338        for i in 0..self.order_ct as usize {
339            let o = &self.orders[i];
340            if o.is_live() && o.side > 0 {
341                sum += o.remaining_1e8();
342            }
343        }
344        sum
345    }
346
347    #[inline(always)]
348    pub fn open_sell_qty_1e8(&self) -> i64 {
349        let mut sum = 0i64;
350        for i in 0..self.order_ct as usize {
351            let o = &self.orders[i];
352            if o.is_live() && o.side < 0 {
353                sum += o.remaining_1e8();
354            }
355        }
356        sum
357    }
358
359    #[inline(always)]
360    pub fn total_pnl_1e9(&self) -> i64 {
361        self.realized_pnl_1e9 + self.unrealized_pnl_1e9
362    }
363
364    /// Ergonomic PnL accessor for strategies that want a single call.
365    #[inline(always)]
366    pub fn get_pnl(&self) -> PnlSnapshot {
367        PnlSnapshot {
368            realized_1e9: self.realized_pnl_1e9,
369            unrealized_1e9: self.unrealized_pnl_1e9,
370            total_1e9: self.total_pnl_1e9(),
371        }
372    }
373
374    /// Realized PnL as USD float.
375    #[inline(always)]
376    pub fn realized_pnl_usd(&self) -> f64 {
377        self.realized_pnl_1e9 as f64 / 1e9
378    }
379
380    /// Unrealized PnL as USD float.
381    #[inline(always)]
382    pub fn unrealized_pnl_usd(&self) -> f64 {
383        self.unrealized_pnl_1e9 as f64 / 1e9
384    }
385
386    /// Total PnL as USD float.
387    #[inline(always)]
388    pub fn total_pnl_usd(&self) -> f64 {
389        self.total_pnl_1e9() as f64 / 1e9
390    }
391
392    /// Session realized PnL as USD float. Resets at UTC midnight.
393    #[inline(always)]
394    pub fn session_pnl_usd(&self) -> f64 {
395        self.session_pnl_1e9 as f64 / 1e9
396    }
397}