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