tesser_core/
lib.rs

1//! Fundamental data types shared across the entire workspace.
2
3use std::cmp::Reverse;
4use std::collections::{BTreeMap, HashMap};
5use std::fmt::Write;
6use std::str::FromStr;
7
8use chrono::{DateTime, Duration, Utc};
9use crc32fast::Hasher;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// Alias for price precision.
15pub type Price = Decimal;
16/// Alias for quantity precision.
17pub type Quantity = Decimal;
18/// Alias used for human-readable market symbols (e.g., `BTCUSDT`).
19pub type Symbol = String;
20
21/// Unique identifier assigned to orders (exchange or client provided).
22pub type OrderId = String;
23
24/// Enumerates the supported financial instrument families.
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum InstrumentKind {
28    Spot,
29    LinearPerpetual,
30    InversePerpetual,
31}
32
33/// Immutable metadata describing a tradable market.
34#[derive(Clone, Debug, Deserialize, Serialize)]
35pub struct Instrument {
36    pub symbol: Symbol,
37    pub base: Symbol,
38    pub quote: Symbol,
39    pub kind: InstrumentKind,
40    pub settlement_currency: Symbol,
41    pub tick_size: Price,
42    pub lot_size: Quantity,
43}
44
45/// Represents a currency balance and its current conversion rate to the reporting currency.
46#[derive(Clone, Debug, Deserialize, Serialize)]
47pub struct Cash {
48    pub currency: Symbol,
49    pub quantity: Quantity,
50    pub conversion_rate: Price,
51}
52
53impl Cash {
54    /// Convert this balance into the reporting currency using the latest conversion rate.
55    #[must_use]
56    pub fn value(&self) -> Price {
57        self.quantity * self.conversion_rate
58    }
59}
60
61/// Multi-currency ledger keyed by currency symbol.
62#[derive(Clone, Debug, Default, Deserialize, Serialize)]
63pub struct CashBook(pub HashMap<Symbol, Cash>);
64
65impl CashBook {
66    #[must_use]
67    pub fn new() -> Self {
68        Self(HashMap::new())
69    }
70
71    pub fn upsert(&mut self, cash: Cash) {
72        self.0.insert(cash.currency.clone(), cash);
73    }
74
75    pub fn adjust(&mut self, currency: &str, delta: Quantity) -> Quantity {
76        let entry = self.0.entry(currency.to_string()).or_insert(Cash {
77            currency: currency.to_string(),
78            quantity: Decimal::ZERO,
79            conversion_rate: Decimal::ZERO,
80        });
81        entry.quantity += delta;
82        entry.quantity
83    }
84
85    pub fn update_conversion_rate(&mut self, currency: &str, rate: Price) {
86        let entry = self.0.entry(currency.to_string()).or_insert(Cash {
87            currency: currency.to_string(),
88            quantity: Decimal::ZERO,
89            conversion_rate: Decimal::ZERO,
90        });
91        entry.conversion_rate = rate;
92    }
93
94    #[must_use]
95    pub fn total_value(&self) -> Price {
96        self.0.values().map(Cash::value).sum()
97    }
98
99    #[must_use]
100    pub fn get(&self, currency: &str) -> Option<&Cash> {
101        self.0.get(currency)
102    }
103
104    #[must_use]
105    pub fn get_mut(&mut self, currency: &str) -> Option<&mut Cash> {
106        self.0.get_mut(currency)
107    }
108
109    pub fn iter(&self) -> impl Iterator<Item = (&Symbol, &Cash)> {
110        self.0.iter()
111    }
112}
113
114/// Execution hints for algorithmic order placement.
115#[derive(Clone, Debug, Deserialize, Serialize)]
116pub enum ExecutionHint {
117    /// Time-Weighted Average Price execution over specified duration.
118    Twap { duration: Duration },
119    /// Volume-Weighted Average Price execution.
120    Vwap {
121        duration: Duration,
122        #[serde(default)]
123        participation_rate: Option<Decimal>,
124    },
125    /// Iceberg order (simulated in software).
126    IcebergSimulated {
127        display_size: Quantity,
128        #[serde(default)]
129        limit_offset_bps: Option<Decimal>,
130    },
131    /// Pegged-to-best style trading that refreshes a passive order at the top of book.
132    PeggedBest {
133        offset_bps: Decimal,
134        #[serde(default)]
135        clip_size: Option<Quantity>,
136        #[serde(default)]
137        refresh_secs: Option<u64>,
138        #[serde(default)]
139        min_chase_distance: Option<Price>,
140    },
141    /// Sits on the sidelines until a target price is reached, then fires aggressively.
142    Sniper {
143        trigger_price: Price,
144        #[serde(default)]
145        timeout: Option<Duration>,
146    },
147}
148
149/// The side of an order or position.
150#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub enum Side {
152    /// Buy the instrument.
153    Buy,
154    /// Sell the instrument.
155    Sell,
156}
157
158impl Side {
159    /// Returns the opposite side (buy <-> sell).
160    #[must_use]
161    pub fn inverse(self) -> Self {
162        match self {
163            Self::Buy => Self::Sell,
164            Self::Sell => Self::Buy,
165        }
166    }
167
168    /// Convert to `i8` representation used by certain exchanges.
169    #[must_use]
170    pub fn as_i8(self) -> i8 {
171        match self {
172            Self::Buy => 1,
173            Self::Sell => -1,
174        }
175    }
176}
177
178/// Order execution style.
179#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
180pub enum OrderType {
181    /// Execute immediately at best available price.
182    Market,
183    /// Execute at the provided limit price.
184    Limit,
185    /// A conditional market order triggered by a price movement.
186    StopMarket,
187}
188
189/// Optional time-in-force constraints.
190#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
191pub enum TimeInForce {
192    GoodTilCanceled,
193    ImmediateOrCancel,
194    FillOrKill,
195}
196
197/// Interval granularity used when aggregating ticks into candles.
198#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
199pub enum Interval {
200    OneSecond,
201    OneMinute,
202    FiveMinutes,
203    FifteenMinutes,
204    OneHour,
205    FourHours,
206    OneDay,
207}
208
209impl Interval {
210    /// Convert the interval into a chrono `Duration`.
211    #[must_use]
212    pub fn as_duration(self) -> Duration {
213        match self {
214            Self::OneSecond => Duration::seconds(1),
215            Self::OneMinute => Duration::minutes(1),
216            Self::FiveMinutes => Duration::minutes(5),
217            Self::FifteenMinutes => Duration::minutes(15),
218            Self::OneHour => Duration::hours(1),
219            Self::FourHours => Duration::hours(4),
220            Self::OneDay => Duration::days(1),
221        }
222    }
223
224    /// Convert to Bybit interval identifiers.
225    #[must_use]
226    pub fn to_bybit(self) -> &'static str {
227        match self {
228            Self::OneSecond => "1",
229            Self::OneMinute => "1",
230            Self::FiveMinutes => "5",
231            Self::FifteenMinutes => "15",
232            Self::OneHour => "60",
233            Self::FourHours => "240",
234            Self::OneDay => "D",
235        }
236    }
237
238    /// Convert the interval to Binance-compatible identifiers.
239    pub fn to_binance(self) -> &'static str {
240        match self {
241            Self::OneSecond => "1s",
242            Self::OneMinute => "1m",
243            Self::FiveMinutes => "5m",
244            Self::FifteenMinutes => "15m",
245            Self::OneHour => "1h",
246            Self::FourHours => "4h",
247            Self::OneDay => "1d",
248        }
249    }
250}
251
252impl FromStr for Interval {
253    type Err = String;
254
255    fn from_str(value: &str) -> Result<Self, Self::Err> {
256        match value.to_lowercase().as_str() {
257            "1s" | "1sec" | "1second" | "1" => Ok(Self::OneSecond),
258            "1m" | "1min" | "1minute" => Ok(Self::OneMinute),
259            "5m" | "5min" | "5minutes" => Ok(Self::FiveMinutes),
260            "15m" | "15min" | "15minutes" => Ok(Self::FifteenMinutes),
261            "1h" | "60m" | "1hour" | "60" => Ok(Self::OneHour),
262            "4h" | "240m" | "4hours" | "240" => Ok(Self::FourHours),
263            "1d" | "day" | "d" => Ok(Self::OneDay),
264            other => Err(format!("unsupported interval '{other}'")),
265        }
266    }
267}
268
269/// Base market data structure representing the smallest, most recent trade.
270#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
271pub struct Tick {
272    pub symbol: Symbol,
273    pub price: Price,
274    pub size: Quantity,
275    pub side: Side,
276    pub exchange_timestamp: DateTime<Utc>,
277    pub received_at: DateTime<Utc>,
278}
279
280/// Aggregated OHLCV bar data.
281#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
282pub struct Candle {
283    pub symbol: Symbol,
284    pub interval: Interval,
285    pub open: Price,
286    pub high: Price,
287    pub low: Price,
288    pub close: Price,
289    pub volume: Quantity,
290    pub timestamp: DateTime<Utc>,
291}
292
293/// Represents a single level in the order book.
294#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
295pub struct OrderBookLevel {
296    pub price: Price,
297    pub size: Quantity,
298}
299
300/// Snapshot of the order book depth.
301#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
302pub struct OrderBook {
303    pub symbol: Symbol,
304    pub bids: Vec<OrderBookLevel>,
305    pub asks: Vec<OrderBookLevel>,
306    pub timestamp: DateTime<Utc>,
307    #[serde(default)]
308    pub exchange_checksum: Option<u32>,
309    #[serde(default)]
310    pub local_checksum: Option<u32>,
311}
312
313impl OrderBook {
314    /// Returns the best bid if available.
315    #[must_use]
316    pub fn best_bid(&self) -> Option<&OrderBookLevel> {
317        self.bids.first()
318    }
319
320    /// Returns the best ask if available.
321    #[must_use]
322    pub fn best_ask(&self) -> Option<&OrderBookLevel> {
323        self.asks.first()
324    }
325
326    /// Calculates bid/ask imbalance for the top `depth` levels.
327    #[must_use]
328    pub fn imbalance(&self, depth: usize) -> Option<Decimal> {
329        let depth = depth.max(1);
330        let bid_vol: Decimal = self.bids.iter().take(depth).map(|level| level.size).sum();
331        let ask_vol: Decimal = self.asks.iter().take(depth).map(|level| level.size).sum();
332        let denom = bid_vol + ask_vol;
333        if denom.is_zero() {
334            None
335        } else {
336            Some((bid_vol - ask_vol) / denom)
337        }
338    }
339
340    /// Compute a checksum for the current order book using up to `depth` levels (or full depth when `None`).
341    #[must_use]
342    pub fn computed_checksum(&self, depth: Option<usize>) -> u32 {
343        let mut lob = LocalOrderBook::new();
344        let bids = self
345            .bids
346            .iter()
347            .map(|level| (level.price, level.size))
348            .collect::<Vec<_>>();
349        let asks = self
350            .asks
351            .iter()
352            .map(|level| (level.price, level.size))
353            .collect::<Vec<_>>();
354        lob.load_snapshot(&bids, &asks);
355        let depth = depth.unwrap_or_else(|| bids.len().max(asks.len()).max(1));
356        lob.checksum(depth)
357    }
358}
359
360/// Incremental order book update.
361#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
362pub struct DepthUpdate {
363    pub symbol: Symbol,
364    pub bids: Vec<OrderBookLevel>,
365    pub asks: Vec<OrderBookLevel>,
366    pub timestamp: DateTime<Utc>,
367}
368
369/// Local view of an order book backed by sorted price levels.
370#[derive(Clone, Debug, Default, Deserialize, Serialize)]
371pub struct LocalOrderBook {
372    bids: BTreeMap<Reverse<Price>, Quantity>,
373    asks: BTreeMap<Price, Quantity>,
374}
375
376impl LocalOrderBook {
377    /// Create an empty order book.
378    #[must_use]
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// Reset the book with explicit bid/ask snapshots.
384    pub fn load_snapshot(&mut self, bids: &[(Price, Quantity)], asks: &[(Price, Quantity)]) {
385        self.bids.clear();
386        self.asks.clear();
387        for &(price, qty) in bids {
388            self.add_order(Side::Buy, price, qty);
389        }
390        for &(price, qty) in asks {
391            self.add_order(Side::Sell, price, qty);
392        }
393    }
394
395    /// Insert or update a price level by accumulating quantity.
396    pub fn add_order(&mut self, side: Side, price: Price, quantity: Quantity) {
397        if quantity <= Decimal::ZERO {
398            return;
399        }
400        match side {
401            Side::Buy => {
402                let key = Reverse(price);
403                let entry = self.bids.entry(key).or_insert(Decimal::ZERO);
404                *entry += quantity;
405            }
406            Side::Sell => {
407                let entry = self.asks.entry(price).or_insert(Decimal::ZERO);
408                *entry += quantity;
409            }
410        }
411    }
412
413    /// Overwrite a price level with the provided absolute quantity (removing when zero).
414    pub fn apply_delta(&mut self, side: Side, price: Price, quantity: Quantity) {
415        if quantity <= Decimal::ZERO {
416            self.clear_level(side, price);
417            return;
418        }
419        match side {
420            Side::Buy => {
421                self.bids.insert(Reverse(price), quantity);
422            }
423            Side::Sell => {
424                self.asks.insert(price, quantity);
425            }
426        }
427    }
428
429    /// Apply a batch of bid/ask deltas atomically.
430    pub fn apply_deltas(&mut self, bids: &[(Price, Quantity)], asks: &[(Price, Quantity)]) {
431        for &(price, qty) in bids {
432            self.apply_delta(Side::Buy, price, qty);
433        }
434        for &(price, qty) in asks {
435            self.apply_delta(Side::Sell, price, qty);
436        }
437    }
438
439    /// Remove quantity from a price level, deleting the level when depleted.
440    pub fn remove_order(&mut self, side: Side, price: Price, quantity: Quantity) {
441        if quantity <= Decimal::ZERO {
442            return;
443        }
444        match side {
445            Side::Buy => {
446                let key = Reverse(price);
447                if let Some(level) = self.bids.get_mut(&key) {
448                    *level -= quantity;
449                    if *level <= Decimal::ZERO {
450                        self.bids.remove(&key);
451                    }
452                }
453            }
454            Side::Sell => {
455                if let Some(level) = self.asks.get_mut(&price) {
456                    *level -= quantity;
457                    if *level <= Decimal::ZERO {
458                        self.asks.remove(&price);
459                    }
460                }
461            }
462        }
463    }
464
465    /// Remove an entire price level regardless of resting quantity.
466    pub fn clear_level(&mut self, side: Side, price: Price) {
467        match side {
468            Side::Buy => {
469                self.bids.remove(&Reverse(price));
470            }
471            Side::Sell => {
472                self.asks.remove(&price);
473            }
474        }
475    }
476
477    /// Best bid price/quantity currently stored.
478    #[must_use]
479    pub fn best_bid(&self) -> Option<(Price, Quantity)> {
480        self.bids.iter().next().map(|(price, qty)| (price.0, *qty))
481    }
482
483    /// Best ask price/quantity currently stored.
484    #[must_use]
485    pub fn best_ask(&self) -> Option<(Price, Quantity)> {
486        self.asks.iter().next().map(|(price, qty)| (*price, *qty))
487    }
488
489    /// Iterate bids in descending price order.
490    pub fn bids(&self) -> impl Iterator<Item = (Price, Quantity)> + '_ {
491        self.bids.iter().map(|(price, qty)| (price.0, *qty))
492    }
493
494    /// Iterate asks in ascending price order.
495    pub fn asks(&self) -> impl Iterator<Item = (Price, Quantity)> + '_ {
496        self.asks.iter().map(|(price, qty)| (*price, *qty))
497    }
498
499    /// Consume liquidity from the opposite side of an aggressive order.
500    pub fn take_liquidity(
501        &mut self,
502        aggressive_side: Side,
503        mut quantity: Quantity,
504    ) -> Vec<(Price, Quantity)> {
505        let mut fills = Vec::new();
506        while quantity > Decimal::ZERO {
507            let (price, available) = match aggressive_side {
508                Side::Buy => match self.best_ask() {
509                    Some(level) => level,
510                    None => break,
511                },
512                Side::Sell => match self.best_bid() {
513                    Some(level) => level,
514                    None => break,
515                },
516            };
517            let traded = quantity.min(available);
518            let contra_side = aggressive_side.inverse();
519            self.remove_order(contra_side, price, traded);
520            fills.push((price, traded));
521            quantity -= traded;
522        }
523        fills
524    }
525
526    /// Returns true when either side of the book currently holds levels.
527    #[must_use]
528    pub fn is_empty(&self) -> bool {
529        self.bids.is_empty() && self.asks.is_empty()
530    }
531
532    /// Compute a CRC32 checksum for the top N levels (Bybit/Binance compatibility).
533    #[must_use]
534    pub fn checksum(&self, depth: usize) -> u32 {
535        if depth == 0 {
536            return 0;
537        }
538        let mut buffer = String::new();
539        let mut first = true;
540        for (price, size) in self.bids().take(depth) {
541            if !first {
542                buffer.push(':');
543            }
544            first = false;
545            write!(buffer, "{}:{}", price.normalize(), size.normalize()).ok();
546        }
547        for (price, size) in self.asks().take(depth) {
548            if !first {
549                buffer.push(':');
550            }
551            first = false;
552            write!(buffer, "{}:{}", price.normalize(), size.normalize()).ok();
553        }
554        let mut hasher = Hasher::new();
555        hasher.update(buffer.as_bytes());
556        hasher.finalize()
557    }
558
559    /// Helper for generating owned bid levels up to the desired depth.
560    pub fn bid_levels(&self, depth: usize) -> Vec<(Price, Quantity)> {
561        self.bids().take(depth).collect()
562    }
563
564    /// Helper for generating owned ask levels up to the desired depth.
565    pub fn ask_levels(&self, depth: usize) -> Vec<(Price, Quantity)> {
566        self.asks().take(depth).collect()
567    }
568}
569
570/// Desired order placement parameters.
571#[derive(Clone, Debug, Deserialize, Serialize)]
572pub struct OrderRequest {
573    pub symbol: Symbol,
574    pub side: Side,
575    pub order_type: OrderType,
576    pub quantity: Quantity,
577    pub price: Option<Price>,
578    pub trigger_price: Option<Price>,
579    pub time_in_force: Option<TimeInForce>,
580    pub client_order_id: Option<String>,
581    pub take_profit: Option<Price>,
582    pub stop_loss: Option<Price>,
583    pub display_quantity: Option<Quantity>,
584}
585
586/// Order amendment intent allowing connectors to update existing orders in-place.
587#[derive(Clone, Debug, Deserialize, Serialize)]
588pub struct OrderUpdateRequest {
589    pub order_id: OrderId,
590    pub symbol: Symbol,
591    pub side: Side,
592    pub new_price: Option<Price>,
593    pub new_quantity: Option<Quantity>,
594}
595
596/// High-level order status maintained inside the framework.
597#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
598pub enum OrderStatus {
599    PendingNew,
600    Accepted,
601    PartiallyFilled,
602    Filled,
603    Canceled,
604    Rejected,
605}
606
607/// Order representation that aggregates exchange state.
608#[derive(Clone, Debug, Deserialize, Serialize)]
609pub struct Order {
610    pub id: OrderId,
611    pub request: OrderRequest,
612    pub status: OrderStatus,
613    pub filled_quantity: Quantity,
614    pub avg_fill_price: Option<Price>,
615    pub created_at: DateTime<Utc>,
616    pub updated_at: DateTime<Utc>,
617}
618
619/// Execution information emitted whenever an order is filled.
620#[derive(Clone, Debug, Deserialize, Serialize)]
621pub struct Fill {
622    pub order_id: OrderId,
623    pub symbol: Symbol,
624    pub side: Side,
625    pub fill_price: Price,
626    pub fill_quantity: Quantity,
627    pub fee: Option<Price>,
628    pub timestamp: DateTime<Utc>,
629}
630
631/// Trade is an immutable record derived from a fill.
632#[derive(Clone, Debug, Deserialize, Serialize)]
633pub struct Trade {
634    pub id: Uuid,
635    pub fill: Fill,
636    pub realized_pnl: Price,
637}
638
639/// Snapshot of a portfolio position.
640#[derive(Clone, Debug, Deserialize, Serialize)]
641pub struct Position {
642    pub symbol: Symbol,
643    pub side: Option<Side>,
644    pub quantity: Quantity,
645    pub entry_price: Option<Price>,
646    pub unrealized_pnl: Price,
647    pub updated_at: DateTime<Utc>,
648}
649
650impl Position {
651    /// Update the mark price to refresh unrealized PnL.
652    pub fn mark_price(&mut self, price: Price) {
653        if let (Some(entry), Some(side)) = (self.entry_price, self.side) {
654            let delta = match side {
655                Side::Buy => price - entry,
656                Side::Sell => entry - price,
657            };
658            self.unrealized_pnl = delta * self.quantity;
659        }
660        self.updated_at = Utc::now();
661    }
662}
663
664/// Simple representation of an account balance by currency.
665#[derive(Clone, Debug, Deserialize, Serialize)]
666pub struct AccountBalance {
667    pub currency: String,
668    pub total: Price,
669    pub available: Price,
670    pub updated_at: DateTime<Utc>,
671}
672
673/// High-level intent generated by strategies.
674#[derive(Clone, Debug, Deserialize, Serialize)]
675pub struct Signal {
676    pub id: Uuid,
677    pub symbol: Symbol,
678    pub kind: SignalKind,
679    pub confidence: f64,
680    pub generated_at: DateTime<Utc>,
681    pub note: Option<String>,
682    pub stop_loss: Option<Price>,
683    pub take_profit: Option<Price>,
684    pub execution_hint: Option<ExecutionHint>,
685}
686
687/// The type of action a signal instructs the execution layer to take.
688#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
689pub enum SignalKind {
690    EnterLong,
691    ExitLong,
692    EnterShort,
693    ExitShort,
694    Flatten,
695}
696
697impl SignalKind {
698    /// Returns the Side for this signal kind.
699    #[must_use]
700    pub fn side(self) -> Side {
701        match self {
702            Self::EnterLong | Self::ExitShort => Side::Buy,
703            Self::EnterShort | Self::ExitLong => Side::Sell,
704            Self::Flatten => {
705                // For flatten, we need position context to determine side
706                // This is a simplification - in practice, the execution engine
707                // would determine the correct side based on current position
708                Side::Sell
709            }
710        }
711    }
712}
713
714impl Signal {
715    /// Convenience constructor to build a signal with a random identifier.
716    #[must_use]
717    pub fn new(symbol: impl Into<Symbol>, kind: SignalKind, confidence: f64) -> Self {
718        Self {
719            id: Uuid::new_v4(),
720            symbol: symbol.into(),
721            kind,
722            confidence,
723            generated_at: Utc::now(),
724            note: None,
725            stop_loss: None,
726            take_profit: None,
727            execution_hint: None,
728        }
729    }
730
731    /// Add an execution hint to the signal.
732    #[must_use]
733    pub fn with_hint(mut self, hint: ExecutionHint) -> Self {
734        self.execution_hint = Some(hint);
735        self
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use rust_decimal::prelude::FromPrimitive;
743
744    #[test]
745    fn interval_duration_matches_definition() {
746        assert_eq!(Interval::OneMinute.as_duration(), Duration::minutes(1));
747        assert_eq!(Interval::FourHours.as_duration(), Duration::hours(4));
748    }
749
750    #[test]
751    fn position_mark_price_updates_unrealized_pnl() {
752        let mut position = Position {
753            symbol: "BTCUSDT".to_string(),
754            side: Some(Side::Buy),
755            quantity: Decimal::from_f64(0.5).unwrap(),
756            entry_price: Some(Decimal::from(60_000)),
757            unrealized_pnl: Decimal::ZERO,
758            updated_at: Utc::now(),
759        };
760        position.mark_price(Decimal::from(60_500));
761        assert_eq!(position.unrealized_pnl, Decimal::from(250));
762    }
763
764    #[test]
765    fn local_order_book_tracks_best_levels() {
766        let mut lob = LocalOrderBook::new();
767        lob.add_order(Side::Buy, Decimal::from(10), Decimal::from(2));
768        lob.add_order(Side::Buy, Decimal::from(11), Decimal::from(1));
769        lob.add_order(
770            Side::Sell,
771            Decimal::from(12),
772            Decimal::from_f64(1.5).unwrap(),
773        );
774        lob.add_order(Side::Sell, Decimal::from(13), Decimal::from(3));
775
776        assert_eq!(lob.best_bid(), Some((Decimal::from(11), Decimal::from(1))));
777        assert_eq!(
778            lob.best_ask(),
779            Some((Decimal::from(12), Decimal::from_f64(1.5).unwrap()))
780        );
781
782        let fills = lob.take_liquidity(Side::Buy, Decimal::from(2));
783        assert_eq!(
784            fills,
785            vec![
786                (Decimal::from(12), Decimal::from_f64(1.5).unwrap()),
787                (Decimal::from(13), Decimal::from_f64(0.5).unwrap())
788            ]
789        );
790        assert_eq!(
791            lob.best_ask(),
792            Some((Decimal::from(13), Decimal::from_f64(2.5).unwrap()))
793        );
794    }
795
796    #[test]
797    fn local_order_book_apply_delta_overwrites_level() {
798        let mut lob = LocalOrderBook::new();
799        lob.apply_delta(Side::Buy, Decimal::from(100), Decimal::from(1));
800        lob.apply_delta(Side::Buy, Decimal::from(100), Decimal::from(3));
801        assert_eq!(lob.best_bid(), Some((Decimal::from(100), Decimal::from(3))));
802
803        lob.apply_delta(Side::Buy, Decimal::from(100), Decimal::ZERO);
804        assert!(lob.best_bid().is_none());
805    }
806
807    #[test]
808    fn local_order_book_checksum_reflects_depth() {
809        let mut lob = LocalOrderBook::new();
810        lob.apply_delta(Side::Buy, Decimal::from(10), Decimal::from(1));
811        lob.apply_delta(Side::Buy, Decimal::from(9), Decimal::from(2));
812        lob.apply_delta(Side::Sell, Decimal::from(11), Decimal::from(1));
813        lob.apply_delta(Side::Sell, Decimal::from(12), Decimal::from(2));
814
815        let checksum_full = lob.checksum(2);
816        let checksum_partial = lob.checksum(1);
817        assert_ne!(checksum_full, checksum_partial);
818    }
819}