Repository navigation
Expand file tree
/
Copy pathtypes.rs
More file actions
702 lines (658 loc) · 27.8 KB
/
Copy pathtypes.rs
File metadata and controls
702 lines (658 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
use std::collections::HashMap;
use rust_decimal::Decimal;
use uuid::Uuid;
// Re-export shared types from common — single source of truth.
pub use arb_common::types::{ExchangeId, ExitReason, Side};
/// Compact symbol identifier used in all hot-path messages.
/// Index into SymbolRegistry. Avoids String clones in the critical path.
pub type SymbolId = u32;
/// Created once at startup from the intersection of symbols available on both exchanges.
/// Read-only after initialization — no locking needed.
#[derive(Debug)]
pub struct SymbolRegistry {
names: Vec<String>,
id_map: HashMap<String, SymbolId>,
}
impl SymbolRegistry {
pub fn new(symbols: Vec<String>) -> Self {
let id_map = symbols
.iter()
.enumerate()
.map(|(i, s)| (s.clone(), i as SymbolId))
.collect();
Self { names: symbols, id_map }
}
pub fn get_id(&self, name: &str) -> Option<SymbolId> {
self.id_map.get(name).copied()
}
pub fn get_name(&self, id: SymbolId) -> Option<&str> {
self.names.get(id as usize).map(|s| s.as_str())
}
pub fn count(&self) -> usize {
self.names.len()
}
pub fn all_ids(&self) -> impl Iterator<Item = SymbolId> {
0..self.names.len() as SymbolId
}
}
pub use arb_common::constants::MAX_ORDERBOOK_ARRAY_SIZE as MAX_ORDERBOOK_DEPTH;
/// Best bid/ask snapshot sent from a WS connector to the arb engine after each orderbook update.
#[derive(Debug, Clone)]
pub struct BestLevels {
pub exchange: ExchangeId,
pub symbol_id: SymbolId,
/// (price, qty) of best bid, or None if orderbook has no bids.
pub best_bid: Option<(Decimal, Decimal)>,
/// (price, qty) of best ask, or None if orderbook has no asks.
pub best_ask: Option<(Decimal, Decimal)>,
/// Top N bid levels (price, qty), best first (descending price). Unused slots = (0, 0).
pub bid_levels: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
/// Top N ask levels (price, qty), best first (ascending price). Unused slots = (0, 0).
pub ask_levels: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
/// Wall-clock timestamp when this update was received (nanoseconds since epoch).
pub ts_ns: u64,
/// Exchange-side event timestamp (nanoseconds). When the orderbook change occurred
/// on the matching engine. Lighter: `last_updated_at` (µs precision), Extended: `ts` (ms precision).
pub exchange_ts_ns: u64,
/// Cumulative USD liquidity on top 5 bid levels (price × qty summed).
pub bid_depth_usd: f64,
/// Cumulative USD liquidity on top 5 ask levels.
pub ask_depth_usd: f64,
}
/// Public trade print from an exchange's public trade WS channel.
///
/// Carries only the fields we need for ground-truth ME state verification:
/// price, size, aggressor side, and timing. `trade_id=0` if the exchange
/// does not publish one (none of the targets have this today, but keep the
/// column explicit so parquet schema is stable).
///
/// Aggressor-side convention: `side` is the taker side.
/// - Lighter: `is_maker_ask=true` → maker was ask → taker was bid → Buy.
/// - Extended: `S="BUY"` → taker was buyer → Buy.
#[derive(Debug, Clone)]
pub struct PublicTrade {
pub exchange: ExchangeId,
pub symbol_id: SymbolId,
/// Wall-clock timestamp when we received the trade frame (ns since epoch).
pub ts_ns: u64,
/// Exchange-side event timestamp (ns). Lighter `timestamp` (ms) × 1e6,
/// Extended `T` (ms) × 1e6.
pub exchange_ts_ns: u64,
pub price: Decimal,
pub size: Decimal,
pub side: Side,
pub is_liquidation: bool,
pub trade_id: u64,
}
/// Per-(exchange, symbol) position state from WS updates.
/// Updated by exchange WS tasks, read by executor for SL monitoring and dashboard display.
#[derive(Debug, Clone)]
pub struct ExchangePositionState {
pub liq_price: Decimal,
pub unrealized_pnl: Decimal,
pub avg_entry_price: Decimal,
/// Cumulative funding since position opened (positive = received, negative = paid).
pub cum_funding: Decimal,
pub margin: Decimal,
pub updated_at_ms: u64,
}
/// Result of a single order execution (one leg of an arb).
#[derive(Debug, Clone)]
pub struct Fill {
pub exchange: ExchangeId,
pub symbol_id: SymbolId,
pub side: Side,
/// Actual quantity filled. May be zero (no liquidity) or less than requested (partial).
pub filled_qty: Decimal,
/// Volume-weighted average fill price. Meaningful only when filled_qty > 0.
pub avg_price: Decimal,
/// Our client-assigned order ID, used to correlate with the pending_orders DashMap.
pub client_order_id: u64,
pub ts_ns: u64,
/// Actual fee paid (USD). Populated from REST after Extended fill.
pub fee_usd: Decimal,
/// Whether this fill was taker (true) or maker (false). Populated from REST.
pub is_taker: Option<bool>,
}
impl Fill {
pub fn is_empty(&self) -> bool {
self.filled_qty.is_zero()
}
pub fn notional_usd(&self) -> Decimal {
self.filled_qty * self.avg_price
}
}
/// A fill received for an order whose executor task has already timed out.
/// The position exists on the exchange but the bot doesn't track it.
/// Must be closed immediately to avoid unhedged directional risk.
#[derive(Debug, Clone)]
pub struct OrphanFill {
pub fill: Fill,
/// Human-readable symbol name (for logging).
pub symbol_name: String,
}
/// A position leg was closed externally (SL fill, liquidation).
/// The bot must immediately close the surviving leg on the other exchange.
#[derive(Debug, Clone)]
pub struct ExternalLegClose {
pub symbol_id: SymbolId,
pub exchange: ExchangeId,
pub reason: ExternalCloseReason,
/// Client order ID of the SL that filled, or 0 for liquidation.
pub client_order_id: u64,
pub filled_qty: Decimal,
pub fill_price: Decimal,
pub symbol_name: String,
}
#[derive(Debug, Clone, Copy)]
pub enum ExternalCloseReason {
StopLoss,
Liquidation,
/// Closed externally with no specific event match (manual close via exchange
/// UI, ADL, or a fill on an exchange that doesn't distinguish SL/liquidation
/// in its event stream — zero1 today).
ExternalManual,
}
/// Per-leg snapshot of orderbook state at signal time. Captured by the
/// detector and persisted to the `signals` table for post-mortem debugging.
///
/// One `LegSnapshot` per exchange involved in the signal. Currently always
/// 2 legs (lighter + extended), but the design is N-leg ready: signals
/// store `Vec<LegSnapshot>` so adding a 3rd exchange needs no schema change.
#[derive(Debug, Clone)]
pub struct LegSnapshot {
pub exchange_id: ExchangeId,
/// Side this leg trades on this exchange ('buy' fills asks, 'sell' fills bids).
pub side: Side,
pub best_bid: Decimal,
pub best_ask: Decimal,
/// VWAP price the bot expects to fill at for the planned order size.
pub vwap_price: Decimal,
/// Max sizable quantity (base units) at this VWAP price.
pub vwap_qty: Decimal,
/// Top-N bid levels (price, qty), best first (descending price). Unused = (0, 0).
pub bids: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
/// Top-N ask levels (price, qty), best first (ascending price). Unused = (0, 0).
pub asks: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
/// Last exchange-side timestamp seen for this leg (ns). 0 if unavailable.
pub exchange_ts_ns: u64,
/// Local wall-clock recv timestamp of the last top-of-book update on this
/// leg (ns). Same semantic across all exchanges — unlike `exchange_ts_ns`,
/// which for ZERO1 is also local now_ns() because ZERO1 does not report
/// event time. Enables the `Recv→Sig` metric ("how stale was this leg's
/// book at the moment of decision, measured in our clock").
pub recv_ns: u64,
// ─── Prior tick snapshot (state right BEFORE the most recent update) ──
/// Top-N bid levels from the previous tick on this exchange.
/// Lets the audit log distinguish a sudden spike from steady-state.
pub prev_bids: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
pub prev_asks: [(Decimal, Decimal); MAX_ORDERBOOK_DEPTH],
/// Exchange-side timestamp from the previous update on this leg.
/// 0 if this exchange hasn't received an update yet.
pub prev_exchange_ts_ns: u64,
/// Last N public trades seen on this exchange+symbol, oldest→newest, at
/// signal build time. Populated from the detector's per-(ex,sym) ring.
/// Bounded to `RECENT_TRADES_SNAPSHOT_COUNT` entries and
/// `RECENT_TRADES_SNAPSHOT_MAX_AGE_NS` age.
///
/// Purpose: ground-truth reference for the `max_trade_divergence_bps`
/// pre-trade filter (`check_trade_divergence`) AND offline audit data.
/// Filter uses its own tighter window (`TRADE_DIV_FILTER_*`); this
/// snapshot is the wider record written to DB for post-hoc analysis.
pub recent_trades: Vec<PublicTrade>,
}
/// Signal to open a new arb position in either direction.
#[derive(Debug, Clone)]
pub struct OpenSignal {
pub symbol_id: SymbolId,
pub is_long_1: bool,
/// Which exchange pair this signal belongs to.
pub exchange_1: ExchangeId,
pub exchange_2: ExchangeId,
/// Price we trade on exchange_1 (ask if buying, bid if selling).
pub price_1: Decimal,
pub qty_1: Decimal,
/// Best bid on exchange_1 at signal time.
pub bid_1: Decimal,
/// Best ask on exchange_1 at signal time.
pub ask_1: Decimal,
/// Price we trade on exchange_2 (bid if selling, ask if buying).
pub price_2: Decimal,
pub qty_2: Decimal,
pub spread_bps: Decimal,
/// Quantity in base asset units to submit on each leg.
pub order_qty: Decimal,
pub order_qty_usd: Decimal,
/// Local wall-clock time when the spread-detection event fired (ns).
/// Trade-level by construction: a signal is one event between two venues
/// that produces both legs simultaneously — same value for entry_1 and
/// entry_2. Persisted as `trades.signal_at_ms`. Mirror for the close path
/// is `CloseSignal.ts_ns` → `trades.exit_signal_at_ms`.
pub ts_ns: u64,
/// Exchange-side timestamp of the tick that triggered this signal (ns). 0 if unavailable.
pub exchange_ts_ns: u64,
/// Exchange_1 exchange-side timestamp at signal time (ns).
pub ts_ns_1: u64,
/// Exchange_2 exchange-side timestamp at signal time (ns).
pub ts_ns_2: u64,
/// Exchange_1 local wall-clock recv timestamp at signal time (ns).
/// Snapshotted from `SpreadDetector.last_recv_ns` — the moment the bot
/// *received* the leg's most recent top-of-book update. Unlike `ts_ns_*`
/// (exchange-reported), this is uniform across venues including ZERO1.
pub recv_ns_1: u64,
/// Exchange_2 local wall-clock recv timestamp at signal time (ns).
pub recv_ns_2: u64,
/// Per-leg orderbook snapshots for post-mortem debugging.
/// Order matches `[exchange_1 leg, exchange_2 leg]` for current 2-leg
/// design; ready for N legs in the future.
pub legs: Vec<LegSnapshot>,
/// Effective entry threshold (bps, after per-symbol override) at the
/// moment this signal fired. Recorded for the signals table.
pub threshold_bps: Decimal,
/// Round-trip fee deducted from the spread before threshold check
/// (0 if `include_fees_in_entry=false`).
pub fee_deduct_bps: Decimal,
/// Effective strategy snapshot (entry/exit thresholds, exit mode, leverage)
/// at signal time, with per-symbol overrides applied. Persisted on the
/// trade so closed-trade UI shows the values that actually drove the
/// decision for this symbol — not the global config.
pub cfg: crate::storage::types::StrategySnapshot,
}
/// Signal to close an existing active position.
#[derive(Debug, Clone)]
pub struct CloseSignal {
pub trade_id: Uuid,
pub symbol_id: SymbolId,
pub is_long_1: bool,
/// Which exchange pair this position belongs to.
pub exchange_1: ExchangeId,
pub exchange_2: ExchangeId,
pub qty: Decimal,
/// VWAP exit price on exchange_1 (eats through bids if selling, asks if buying).
pub price_1: Decimal,
/// VWAP exit price on exchange_2 (eats through asks if buying, bids if selling).
pub price_2: Decimal,
/// Local wall-clock time when the close-decision event fired (ns).
/// Trade-level by construction: one detector event closes both legs at
/// once — same value for exit_1 and exit_2. Persisted as
/// `trades.exit_signal_at_ms` via `update_trade_exit_signal_at`. Mirror
/// for entry path is `OpenSignal.ts_ns` → `trades.signal_at_ms`.
pub ts_ns: u64,
/// Exchange_1 exchange-side tick timestamp at close signal time (ns).
pub ts_ns_1: u64,
/// Exchange_2 exchange-side tick timestamp at close signal time (ns).
pub ts_ns_2: u64,
/// Exchange_1 local wall-clock recv timestamp at close signal time (ns).
pub recv_ns_1: u64,
/// Exchange_2 local wall-clock recv timestamp at close signal time (ns).
pub recv_ns_2: u64,
/// Why the position is being closed.
pub exit_reason: ExitReason,
/// Spread that fired the close decision (bps). Same convention as entry:
/// for spread-mode it's the captured profit; for pnl-mode it's the
/// current close spread (informational).
pub close_spread_bps: Decimal,
/// Spread when the position was originally opened (bps). Snapshot from
/// the position state. Used in the signals table for at-a-glance comparison.
pub entry_spread_bps: Decimal,
/// Gross PnL in bps at the moment of decision (before fees considered
/// in `include_fees_in_exit`). Informational.
pub gross_pnl_bps: Decimal,
/// Net PnL as fraction of notional at decision time.
pub net_pnl_pct: Decimal,
/// Cumulative funding (base + quote) included in the PnL math.
pub funding_at_exit: Decimal,
/// Wall-clock ms since position opened (now - opened_at).
pub held_for_ms: i64,
/// Per-leg orderbook snapshots for post-mortem debugging.
pub legs: Vec<LegSnapshot>,
}
/// Open position state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionState {
/// Orders placed, waiting for fills.
Filling,
/// Both legs filled, position is live.
Active,
/// Close orders placed, waiting for fills.
Closing,
}
/// An active arb position in either direction.
#[derive(Debug, Clone)]
pub struct Position {
pub id: Uuid,
pub session_seq: i64,
pub symbol_id: SymbolId,
pub is_long_1: bool,
/// Which exchange pair this position was opened on.
pub exchange_1: ExchangeId,
pub exchange_2: ExchangeId,
/// Actual size (min of both fills after leg-risk adjustment).
pub qty: Decimal,
pub entry_price_1: Decimal,
pub entry_price_2: Decimal,
pub entry_spread_bps: Decimal,
/// Signal price on exchange_1 at detection time.
pub signal_price_1: Decimal,
/// Signal price on exchange_2 at detection time.
pub signal_price_2: Decimal,
pub state: PositionState,
/// When the signal was detected locally (unix ns).
pub signal_at_ns: u64,
/// Exchange-side timestamp of the triggering tick (unix ns). 0 if unavailable.
pub exchange_signal_at_ns: u64,
/// Per-exchange timestamps at signal time (ns).
pub ts_ns_1: u64,
pub ts_ns_2: u64,
/// Per-exchange local wall-clock recv timestamps at signal time (ns).
/// Snapshotted from the detector at signal creation. Used to compute the
/// `Recv→Sig` latency per leg, uniform across all exchanges.
pub recv_ns_1: u64,
pub recv_ns_2: u64,
/// Mirror of `signal_at_ns` for the close path. Set atomically (with the
/// 4 fields below) when the close-decision event fires, by
/// `tracker.start_closing_with_signal`. 0 while the position is active or
/// when the close was reactive (external leg close — no signal). Lets the
/// in-memory OrderSnap path show Recv→Sig for close orders during the
/// brief window between place_order and DB commit.
pub exit_signal_at_ns: u64,
/// Per-exchange tick timestamps at CLOSE signal time (ns). Mirror of
/// `ts_ns_1/2` for the close path. 0 until close fires.
pub exit_ts_ns_1: u64,
pub exit_ts_ns_2: u64,
/// Per-exchange local recv timestamps at CLOSE signal time (ns). Mirror
/// of `recv_ns_1/2` for the close path. 0 until close fires.
pub exit_recv_ns_1: u64,
pub exit_recv_ns_2: u64,
/// When exchange_1 order was sent (unix ns).
pub order_at_ns_1: u64,
/// When first exchange_1 fill was confirmed (unix ns).
pub first_fill_at_ns_1: u64,
/// When last exchange_1 fill completed the order (unix ns).
pub last_fill_at_ns_1: u64,
/// When exchange_2 order was sent (unix ns).
pub order_at_ns_2: u64,
/// When first exchange_2 fill was confirmed (unix ns).
pub first_fill_at_ns_2: u64,
/// When last exchange_2 fill completed the hedge (unix ns).
pub last_fill_at_ns_2: u64,
/// When position was fully opened (both legs filled, unix ns).
pub opened_at_ns: u64,
/// Exact order_qty_usd used in RiskManager::try_reserve().
/// Used on close to release the same amount, avoiding residual from price slippage.
pub reserved_usd: Decimal,
}
/// Purpose of an active order (for monitoring).
#[derive(Debug, Clone, Copy)]
pub enum OrderPurpose {
Entry1,
Entry2,
Close1,
Close2,
/// Close on exchange_1 that bypasses the normal paired-leg close flow.
/// Covers two operationally identical scenarios:
/// 1. Retry attempts inside `close_leg`/`close_residual` after the
/// original `exit_1` timed out or got rejected. The matching
/// original `exit_1` row is also present in the orders table.
/// 2. Single-shot emergency close where there is no original `exit_1`:
/// `failed_open` partial-entry unwind, excess-fill cleanup,
/// external SL/liquidation of the opposite leg, etc.
/// Both share this purpose so the UI shows them as `unwind`. The two
/// scenarios are distinguishable by whether an `exit_1` row exists
/// alongside in the same trade.
ExitUnwind1,
ExitUnwind2,
StopLoss1,
StopLoss2,
}
impl std::fmt::Display for OrderPurpose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OrderPurpose::Entry1 => write!(f, "entry_1"),
OrderPurpose::Entry2 => write!(f, "entry_2"),
OrderPurpose::Close1 => write!(f, "exit_1"),
OrderPurpose::Close2 => write!(f, "exit_2"),
OrderPurpose::ExitUnwind1 => write!(f, "exit_unwind_1"),
OrderPurpose::ExitUnwind2 => write!(f, "exit_unwind_2"),
OrderPurpose::StopLoss1 => write!(f, "sl_1"),
OrderPurpose::StopLoss2 => write!(f, "sl_2"),
}
}
}
/// Order type for tracking in DB.
#[derive(Debug, Clone, Copy)]
pub enum OrderType {
Ioc,
Market,
Gtt,
StopMarket,
StopLimit,
}
impl std::fmt::Display for OrderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OrderType::Ioc => write!(f, "ioc"),
OrderType::Market => write!(f, "market"),
OrderType::Gtt => write!(f, "gtt"),
OrderType::StopMarket => write!(f, "stop_market"),
OrderType::StopLimit => write!(f, "stop_limit"),
}
}
}
/// An active order tracked for monitoring snapshots.
#[derive(Debug, Clone)]
pub struct ActiveOrder {
pub order_id: u64,
pub session_seq: u32,
pub symbol_id: SymbolId,
pub exchange: ExchangeId,
pub side: Side,
pub qty: Decimal,
pub limit_price: Decimal,
pub placed_at_ns: u64,
/// Exchange-side timestamp of the tick that triggered this order (ns). 0 if unavailable.
pub exchange_ts_ns: u64,
/// Local wall-clock recv timestamp of the tick that triggered this order
/// (ns). Snapshotted from the detector at signal creation. Uniform across
/// venues (ZERO1 included). 0 if unavailable.
pub recv_ts_ns: u64,
pub purpose: OrderPurpose,
pub order_type: OrderType,
/// Trade ID this order belongs to.
pub trade_id: Uuid,
/// Market price at signal time for this order's leg.
pub signal_price: Decimal,
/// SL trigger price (mark price level that activates the order). Zero for non-SL orders.
pub trigger_price: Decimal,
/// Liquidation price at the time SL was placed. Used by SL monitor to detect drift.
/// Zero for non-SL orders.
pub liq_price_at_placement: Decimal,
/// Exchange-assigned order ID. For Lighter: same as order_id (client_order_id).
/// For Extended: exchange-assigned ID from placement response. 0 if unknown.
pub exchange_order_id: u64,
}
/// Message sent from an execution task back to the arb engine.
#[derive(Debug)]
pub enum PositionUpdate {
/// Exchange_1 leg filled, exchange_2 hedge pending.
Opening {
symbol_id: SymbolId,
is_long_1: bool,
qty_1: Decimal,
price_1: Decimal,
signal_spread_bps: Decimal,
ts_ns: u64,
},
Opened {
position: Position,
},
Closed {
id: Uuid,
symbol_id: SymbolId,
is_long_1: bool,
qty: Decimal,
entry_price_1: Decimal,
entry_price_2: Decimal,
entry_spread_bps: Decimal,
close_fill_1: Fill,
close_fill_2: Fill,
realized_pnl: Decimal,
opened_at_ns: u64,
closed_at_ns: u64,
exit_reason: ExitReason,
/// When close orders were sent (ns, UTC).
close_orders_sent_at_ns: u64,
/// When first close fill arrived per leg (ns, UTC).
close_fill_at_ns_1: u64,
close_fill_at_ns_2: u64,
},
OpenFailed {
trade_id: Option<Uuid>,
symbol_id: SymbolId,
reason: String,
},
CloseFailed {
id: Uuid,
symbol_id: SymbolId,
},
/// One leg of a parallel close filled but the other got zero fills.
AsymmetricCloseFailed {
id: Uuid,
symbol_id: SymbolId,
filled_1: Decimal,
filled_2: Decimal,
},
/// A failed opening attempt was closed — tracks the PnL of the single-leg roundtrip.
OpeningClosed {
trade_id: Uuid,
symbol_id: SymbolId,
is_long_1: bool,
qty: Decimal,
entry_price: Decimal,
pnl: Decimal,
exchange: ExchangeId,
// Kept on the message for future use, but the DB path no longer
// consumes them — fees are derived from `orders` on read.
entry_fee: Decimal,
close_fee: Decimal,
signal_at_ns: u64,
order_at_ns: u64,
closed_at_ns: u64,
},
}
/// Market info fetched from Lighter REST at startup.
#[derive(Debug, Clone)]
pub struct LighterMarketInfo {
pub market_id: u32,
pub base_symbol: String,
/// Decimal places for quantity (e.g. 4 → min size 0.0001).
pub size_decimals: u32,
/// Decimal places for price (e.g. 6 → min tick 0.000001).
pub price_decimals: u32,
/// Max leverage from `min_initial_margin_fraction`: `10000 / imf`.
pub max_leverage: u16,
/// Minimum order size in base units from Lighter's `/api/v1/orderBooks`
/// `min_base_amount` field (e.g. MEGA="1", TSLA="0.02", BTC="0.00001").
/// `None` when Lighter omits the field — treated as "no qty floor".
pub min_base_amount: Option<Decimal>,
/// Minimum order notional in USD from Lighter's `/api/v1/orderBooks`
/// `min_quote_amount` field (≈ $10 on virtually every perp market).
/// `None` when Lighter omits the field — treated as "no rule".
pub min_quote_amount: Option<Decimal>,
}
impl LighterMarketInfo {
/// Convert a Decimal quantity to the raw integer Lighter expects.
pub fn qty_to_raw(&self, qty: Decimal) -> u64 {
let scale = Decimal::from(10u64.pow(self.size_decimals));
(qty * scale).to_u64_saturating()
}
/// Convert a Decimal price to the raw integer Lighter expects.
pub fn price_to_raw(&self, price: Decimal) -> u64 {
let scale = Decimal::from(10u64.pow(self.price_decimals));
(price * scale).to_u64_saturating()
}
}
trait ToU64Saturating {
fn to_u64_saturating(self) -> u64;
}
impl ToU64Saturating for Decimal {
fn to_u64_saturating(self) -> u64 {
use rust_decimal::prelude::ToPrimitive;
self.round().to_u64().unwrap_or(u64::MAX)
}
}
/// Market info fetched from Extended REST at startup.
#[derive(Debug, Clone)]
pub struct ExtendedMarketInfo {
/// E.g. "ETH-USD"
pub market_name: String,
/// Hex-encoded L2 synthetic asset ID (for Stark order hash).
pub synthetic_asset_id: String,
/// Hex-encoded L2 collateral asset ID.
pub collateral_asset_id: String,
/// Resolution for synthetic asset (e.g. 10000 for ETH → multiply qty by this).
pub synthetic_resolution: u64,
/// Resolution for collateral (e.g. 1000000 for USD).
pub collateral_resolution: u64,
/// Number of price decimal places allowed (derived from minPriceChange).
pub price_decimals: u32,
/// Number of qty decimal places allowed (derived from minOrderSizeChange).
pub qty_decimals: u32,
/// Actual qty step size (e.g. 10.0 means qty must be a multiple of 10).
pub qty_step: Decimal,
/// Minimum order size.
pub min_order_size: Decimal,
/// Maximum leverage allowed by the exchange for this market.
pub max_leverage: u16,
/// Max price deviation from mark (fraction, e.g. 0.05 = 5%). Symmetric for BUY/SELL.
/// Used to clamp entry/exit slippage per symbol. From API `limitPriceCap`.
pub limit_price_cap: f64,
}
impl ExtendedMarketInfo {
/// Round qty DOWN to the nearest valid step for this market.
pub fn round_qty_down(&self, qty: Decimal) -> Decimal {
if qty <= Decimal::ZERO { return Decimal::ZERO; }
if self.qty_step.is_zero() || self.qty_step == Decimal::ONE {
qty.trunc_with_scale(self.qty_decimals)
} else {
(qty / self.qty_step).floor() * self.qty_step
}
}
}
// ─── MarketInfo trait impls ──────────────────────────────────────────────────
use crate::exchange::traits::{MarketInfo, decimal_qty_step, round_qty_down_decimals};
impl MarketInfo for LighterMarketInfo {
fn size_decimals(&self) -> u32 { self.size_decimals }
fn price_decimals(&self) -> u32 { self.price_decimals }
fn qty_step(&self) -> Decimal { decimal_qty_step(self.size_decimals) }
fn round_qty_down(&self, qty: Decimal) -> Decimal { round_qty_down_decimals(qty, self.size_decimals) }
fn max_leverage(&self) -> u16 { self.max_leverage }
/// Lighter publishes a per-market `min_base_amount` (qty floor, base
/// units) on `/api/v1/orderBooks`. Returning `Decimal::ZERO` here
/// before 2026-04-18 meant the qty guard in open.rs was a no-op on
/// Lighter — submits that sneaked past the notional floor but
/// tripped the qty floor would reach the exchange and reject,
/// leaving the counter-leg orphaned on HL. Surface the live value
/// so the shared `effective_min_qty_for_leg` can reason about it.
fn min_order_size(&self) -> Decimal {
self.min_base_amount.unwrap_or(Decimal::ZERO)
}
/// Lighter enforces `size × price ≥ min_quote_amount` alongside
/// `size ≥ min_base_amount`. Reuses the shared notional-floor guard
/// that caught Hyperliquid's $10 rule so a live-price mark drift
/// can't trip a one-legged fill on Lighter either.
fn min_notional_usd(&self) -> Option<Decimal> {
self.min_quote_amount
.filter(|v| *v > Decimal::ZERO)
}
}
impl MarketInfo for ExtendedMarketInfo {
fn size_decimals(&self) -> u32 { self.qty_decimals }
fn price_decimals(&self) -> u32 { self.price_decimals }
fn qty_step(&self) -> Decimal { self.qty_step }
fn round_qty_down(&self, qty: Decimal) -> Decimal {
ExtendedMarketInfo::round_qty_down(self, qty)
}
fn max_leverage(&self) -> u16 { self.max_leverage }
fn min_order_size(&self) -> Decimal { self.min_order_size }
}