Skip to main content

nautilus_lighter/common/
enums.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Lighter venue enums mirrored from REST and WebSocket payloads.
17
18use std::fmt::Display;
19
20use nautilus_model::{
21    data::{BarSpecification, BarType},
22    enums::{AggregationSource, BarAggregation, OrderSide, OrderType, PriceType},
23};
24use serde::{Deserialize, Serialize};
25use serde_repr::{Deserialize_repr, Serialize_repr};
26use strum::{AsRefStr, Display, EnumIter, EnumString};
27
28/// Lighter protocol deployment.
29#[derive(
30    Copy,
31    Clone,
32    Debug,
33    Default,
34    Display,
35    PartialEq,
36    Eq,
37    Hash,
38    AsRefStr,
39    EnumIter,
40    EnumString,
41    Serialize,
42    Deserialize,
43)]
44#[serde(rename_all = "lowercase")]
45#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
46#[cfg_attr(
47    feature = "python",
48    pyo3::pyclass(
49        eq,
50        eq_int,
51        module = "nautilus_trader.adapters.lighter",
52        from_py_object,
53        rename_all = "SCREAMING_SNAKE_CASE",
54    )
55)]
56#[cfg_attr(
57    feature = "python",
58    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.lighter")
59)]
60pub enum LighterDeployment {
61    /// The Lighter deployment.
62    #[default]
63    Lighter,
64    /// The Robinhood Chain deployment of the Lighter protocol.
65    Robinhood,
66}
67
68/// Lighter API environment.
69#[derive(
70    Copy,
71    Clone,
72    Debug,
73    Default,
74    Display,
75    PartialEq,
76    Eq,
77    Hash,
78    AsRefStr,
79    EnumIter,
80    EnumString,
81    Serialize,
82    Deserialize,
83)]
84#[serde(rename_all = "lowercase")]
85#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
86#[cfg_attr(
87    feature = "python",
88    pyo3::pyclass(
89        eq,
90        eq_int,
91        module = "nautilus_trader.adapters.lighter",
92        from_py_object,
93        rename_all = "SCREAMING_SNAKE_CASE",
94    )
95)]
96#[cfg_attr(
97    feature = "python",
98    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.lighter")
99)]
100pub enum LighterEnvironment {
101    /// Mainnet trading environment.
102    #[default]
103    Mainnet,
104    /// Testnet environment.
105    Testnet,
106}
107
108/// Lighter product type. Markets on the venue are either perpetual futures or spot.
109#[derive(
110    Copy,
111    Clone,
112    Debug,
113    Display,
114    PartialEq,
115    Eq,
116    Hash,
117    AsRefStr,
118    EnumIter,
119    EnumString,
120    Serialize,
121    Deserialize,
122)]
123#[serde(rename_all = "lowercase")]
124#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
125pub enum LighterProductType {
126    /// Perpetual futures.
127    Perp,
128    /// Spot markets.
129    Spot,
130}
131
132/// Lighter historical candle resolution.
133#[derive(
134    Copy,
135    Clone,
136    Debug,
137    Display,
138    PartialEq,
139    Eq,
140    Hash,
141    AsRefStr,
142    EnumIter,
143    EnumString,
144    Serialize,
145    Deserialize,
146)]
147#[strum(ascii_case_insensitive)]
148pub enum LighterCandleResolution {
149    /// One-minute candles.
150    #[serde(rename = "1m")]
151    #[strum(serialize = "1m")]
152    OneMinute,
153    /// Five-minute candles.
154    #[serde(rename = "5m")]
155    #[strum(serialize = "5m")]
156    FiveMinute,
157    /// Fifteen-minute candles.
158    #[serde(rename = "15m")]
159    #[strum(serialize = "15m")]
160    FifteenMinute,
161    /// Thirty-minute candles.
162    #[serde(rename = "30m")]
163    #[strum(serialize = "30m")]
164    ThirtyMinute,
165    /// One-hour candles.
166    #[serde(rename = "1h")]
167    #[strum(serialize = "1h")]
168    OneHour,
169    /// Four-hour candles.
170    #[serde(rename = "4h")]
171    #[strum(serialize = "4h")]
172    FourHour,
173    /// Twelve-hour candles.
174    #[serde(rename = "12h")]
175    #[strum(serialize = "12h")]
176    TwelveHour,
177    /// One-day candles.
178    #[serde(rename = "1d")]
179    #[strum(serialize = "1d")]
180    OneDay,
181    /// One-week candles.
182    #[serde(rename = "1w")]
183    #[strum(serialize = "1w")]
184    OneWeek,
185}
186
187impl LighterCandleResolution {
188    /// Returns the REST `resolution` string accepted by Lighter.
189    #[must_use]
190    pub const fn as_str(self) -> &'static str {
191        match self {
192            Self::OneMinute => "1m",
193            Self::FiveMinute => "5m",
194            Self::FifteenMinute => "15m",
195            Self::ThirtyMinute => "30m",
196            Self::OneHour => "1h",
197            Self::FourHour => "4h",
198            Self::TwelveHour => "12h",
199            Self::OneDay => "1d",
200            Self::OneWeek => "1w",
201        }
202    }
203
204    /// Returns the candle interval in milliseconds.
205    #[must_use]
206    pub const fn interval_millis(self) -> i64 {
207        match self {
208            Self::OneMinute => 60_000,
209            Self::FiveMinute => 5 * 60_000,
210            Self::FifteenMinute => 15 * 60_000,
211            Self::ThirtyMinute => 30 * 60_000,
212            Self::OneHour => 60 * 60_000,
213            Self::FourHour => 4 * 60 * 60_000,
214            Self::TwelveHour => 12 * 60 * 60_000,
215            Self::OneDay => 24 * 60 * 60_000,
216            Self::OneWeek => 7 * 24 * 60 * 60_000,
217        }
218    }
219
220    /// Returns the Nautilus [`BarSpecification`] for this candle resolution (`Last` / `External`).
221    #[must_use]
222    pub fn to_bar_spec(self) -> BarSpecification {
223        let (step, aggregation) = match self {
224            Self::OneMinute => (1, BarAggregation::Minute),
225            Self::FiveMinute => (5, BarAggregation::Minute),
226            Self::FifteenMinute => (15, BarAggregation::Minute),
227            Self::ThirtyMinute => (30, BarAggregation::Minute),
228            Self::OneHour => (1, BarAggregation::Hour),
229            Self::FourHour => (4, BarAggregation::Hour),
230            Self::TwelveHour => (12, BarAggregation::Hour),
231            Self::OneDay => (1, BarAggregation::Day),
232            Self::OneWeek => (1, BarAggregation::Week),
233        };
234        BarSpecification::new(step, aggregation, PriceType::Last)
235    }
236
237    /// Returns `true` when this resolution is offered on the candle WebSocket stream.
238    ///
239    /// `1w` is REST-only; the streaming channel only carries `1m`..=`1d`.
240    #[must_use]
241    pub const fn is_ws_streamable(self) -> bool {
242        !matches!(self, Self::OneWeek)
243    }
244}
245
246impl TryFrom<&BarType> for LighterCandleResolution {
247    type Error = anyhow::Error;
248
249    fn try_from(value: &BarType) -> Result<Self, Self::Error> {
250        anyhow::ensure!(
251            value.aggregation_source() == AggregationSource::External,
252            "Lighter candles only support EXTERNAL aggregation",
253        );
254
255        let spec = value.spec();
256        anyhow::ensure!(
257            spec.price_type == PriceType::Last,
258            "Lighter candles only support LAST price type",
259        );
260
261        let step = spec.step.get();
262        match spec.aggregation {
263            BarAggregation::Minute => match step {
264                1 => Ok(Self::OneMinute),
265                5 => Ok(Self::FiveMinute),
266                15 => Ok(Self::FifteenMinute),
267                30 => Ok(Self::ThirtyMinute),
268                _ => anyhow::bail!("unsupported Lighter candle minute step: {step}"),
269            },
270            BarAggregation::Hour => match step {
271                1 => Ok(Self::OneHour),
272                4 => Ok(Self::FourHour),
273                12 => Ok(Self::TwelveHour),
274                _ => anyhow::bail!("unsupported Lighter candle hour step: {step}"),
275            },
276            BarAggregation::Day => match step {
277                1 => Ok(Self::OneDay),
278                _ => anyhow::bail!("unsupported Lighter candle day step: {step}"),
279            },
280            BarAggregation::Week => match step {
281                1 => Ok(Self::OneWeek),
282                _ => anyhow::bail!("unsupported Lighter candle week step: {step}"),
283            },
284            other => anyhow::bail!("unsupported Lighter candle aggregation: {other}"),
285        }
286    }
287}
288
289/// Lighter historical funding resolution.
290#[derive(
291    Copy,
292    Clone,
293    Debug,
294    Default,
295    Display,
296    PartialEq,
297    Eq,
298    Hash,
299    AsRefStr,
300    EnumIter,
301    EnumString,
302    Serialize,
303    Deserialize,
304)]
305#[strum(ascii_case_insensitive)]
306pub enum LighterFundingResolution {
307    /// One-hour funding history.
308    #[default]
309    #[serde(rename = "1h")]
310    #[strum(serialize = "1h")]
311    OneHour,
312    /// One-day funding history.
313    #[serde(rename = "1d")]
314    #[strum(serialize = "1d")]
315    OneDay,
316}
317
318impl LighterFundingResolution {
319    /// Returns the funding interval in minutes.
320    #[must_use]
321    pub const fn interval_minutes(self) -> u16 {
322        match self {
323            Self::OneHour => 60,
324            Self::OneDay => 24 * 60,
325        }
326    }
327
328    /// Returns the funding interval in milliseconds.
329    #[must_use]
330    pub const fn interval_millis(self) -> i64 {
331        match self {
332            Self::OneHour => 60 * 60_000,
333            Self::OneDay => 24 * 60 * 60_000,
334        }
335    }
336}
337
338/// Filter accepted by Lighter market metadata endpoints.
339#[derive(
340    Copy,
341    Clone,
342    Debug,
343    Default,
344    Display,
345    PartialEq,
346    Eq,
347    Hash,
348    AsRefStr,
349    EnumIter,
350    EnumString,
351    Serialize,
352    Deserialize,
353)]
354#[serde(rename_all = "lowercase")]
355#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
356pub enum LighterOrderBookFilter {
357    /// Return all markets.
358    #[default]
359    All,
360    /// Return perpetual markets only.
361    Perp,
362    /// Return spot markets only.
363    Spot,
364}
365
366/// Status for Lighter market metadata.
367#[derive(
368    Copy,
369    Clone,
370    Debug,
371    Display,
372    PartialEq,
373    Eq,
374    Hash,
375    AsRefStr,
376    EnumIter,
377    EnumString,
378    Serialize,
379    Deserialize,
380)]
381#[serde(rename_all = "kebab-case")]
382#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
383pub enum LighterMarketStatus {
384    /// Market is not available for trading.
385    Inactive,
386    /// Market is available for trading.
387    Active,
388}
389
390/// Numeric transaction status returned by Lighter transaction queries.
391#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)]
392#[repr(i64)]
393pub enum LighterTxStatus {
394    Failed = 0,
395    Pending = 1,
396    Executed = 2,
397    PendingFinal = 3,
398}
399
400/// String order type used by REST and WebSocket order payloads.
401#[derive(
402    Copy,
403    Clone,
404    Debug,
405    Display,
406    PartialEq,
407    Eq,
408    Hash,
409    AsRefStr,
410    EnumIter,
411    EnumString,
412    Serialize,
413    Deserialize,
414)]
415#[serde(rename_all = "kebab-case")]
416#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
417pub enum LighterOrderKind {
418    Limit,
419    Market,
420    StopLoss,
421    StopLossLimit,
422    TakeProfit,
423    TakeProfitLimit,
424    Twap,
425    TwapSub,
426    Liquidation,
427}
428
429/// String time-in-force used by REST and WebSocket order payloads.
430#[derive(
431    Copy,
432    Clone,
433    Debug,
434    Display,
435    PartialEq,
436    Eq,
437    Hash,
438    AsRefStr,
439    EnumIter,
440    EnumString,
441    Serialize,
442    Deserialize,
443)]
444#[serde(rename_all = "kebab-case")]
445#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
446pub enum LighterOrderTimeInForce {
447    GoodTillTime,
448    ImmediateOrCancel,
449    PostOnly,
450    #[serde(alias = "unknown")]
451    #[serde(rename = "Unknown")]
452    #[strum(serialize = "Unknown")]
453    Unknown,
454}
455
456/// String order status used by REST and WebSocket order payloads.
457#[derive(
458    Copy,
459    Clone,
460    Debug,
461    Display,
462    PartialEq,
463    Eq,
464    Hash,
465    AsRefStr,
466    EnumIter,
467    EnumString,
468    Serialize,
469    Deserialize,
470)]
471#[serde(rename_all = "kebab-case")]
472#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
473pub enum LighterOrderStatus {
474    InProgress,
475    Pending,
476    Open,
477    Filled,
478    Canceled,
479    CanceledPostOnly,
480    CanceledReduceOnly,
481    CanceledPositionNotAllowed,
482    CanceledMarginNotAllowed,
483    CanceledTooMuchSlippage,
484    CanceledNotEnoughLiquidity,
485    CanceledSelfTrade,
486    CanceledExpired,
487    CanceledOco,
488    CanceledChild,
489    CanceledLiquidation,
490    CanceledInvalidBalance,
491}
492
493impl LighterOrderStatus {
494    /// Short kebab-case label describing a cancellation cause, suitable for
495    /// [`nautilus_model::reports::OrderStatusReport::with_cancel_reason`].
496    ///
497    /// Returns `None` for non-cancelled statuses and for the unqualified
498    /// [`Canceled`](Self::Canceled) / [`CanceledExpired`](Self::CanceledExpired)
499    /// variants, which carry their meaning via the Nautilus order status itself.
500    #[must_use]
501    pub fn as_cancel_reason(self) -> Option<&'static str> {
502        match self {
503            Self::CanceledPostOnly => Some("post-only"),
504            Self::CanceledReduceOnly => Some("reduce-only"),
505            Self::CanceledPositionNotAllowed => Some("position-not-allowed"),
506            Self::CanceledMarginNotAllowed => Some("margin-not-allowed"),
507            Self::CanceledTooMuchSlippage => Some("too-much-slippage"),
508            Self::CanceledNotEnoughLiquidity => Some("not-enough-liquidity"),
509            Self::CanceledSelfTrade => Some("self-trade"),
510            Self::CanceledOco => Some("oco"),
511            Self::CanceledChild => Some("child"),
512            Self::CanceledLiquidation => Some("liquidation"),
513            Self::CanceledInvalidBalance => Some("invalid-balance"),
514            _ => None,
515        }
516    }
517
518    pub(crate) fn is_terminal(self) -> bool {
519        !matches!(self, Self::InProgress | Self::Pending | Self::Open)
520    }
521}
522
523/// Side string used by REST and WebSocket order payloads.
524#[derive(
525    Copy,
526    Clone,
527    Debug,
528    Display,
529    PartialEq,
530    Eq,
531    Hash,
532    AsRefStr,
533    EnumIter,
534    EnumString,
535    Serialize,
536    Deserialize,
537)]
538#[serde(rename_all = "lowercase")]
539#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
540pub enum LighterOrderSide {
541    Buy,
542    Sell,
543}
544
545/// Trigger status used by conditional order payloads.
546#[derive(
547    Copy,
548    Clone,
549    Debug,
550    Display,
551    PartialEq,
552    Eq,
553    Hash,
554    AsRefStr,
555    EnumIter,
556    EnumString,
557    Serialize,
558    Deserialize,
559)]
560#[serde(rename_all = "kebab-case")]
561#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
562pub enum LighterTriggerStatus {
563    Na,
564    Ready,
565    MarkPrice,
566    Twap,
567    ParentOrder,
568}
569
570/// Trade type used by REST and WebSocket trade payloads.
571#[derive(
572    Copy,
573    Clone,
574    Debug,
575    Display,
576    PartialEq,
577    Eq,
578    Hash,
579    AsRefStr,
580    EnumIter,
581    EnumString,
582    Serialize,
583    Deserialize,
584)]
585#[serde(rename_all = "kebab-case")]
586#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
587pub enum LighterTradeType {
588    Trade,
589    Liquidation,
590    Deleverage,
591    MarketSettlement,
592}
593
594/// Lighter order type as encoded in the venue's binary order payload.
595///
596/// Numeric values are part of the wire format and must match the venue spec.
597#[derive(
598    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
599)]
600#[repr(u8)]
601pub enum LighterOrderType {
602    Limit = 0,
603    Market = 1,
604    StopLoss = 2,
605    StopLossLimit = 3,
606    TakeProfit = 4,
607    TakeProfitLimit = 5,
608    Twap = 6,
609    TwapSub = 7,
610    Liquidation = 8,
611}
612
613impl LighterOrderType {
614    /// Returns the Nautilus [`OrderType`] that this Lighter order type maps to.
615    ///
616    /// `StopLoss` / `StopLossLimit` map to Nautilus stop-on-the-loss-side
617    /// triggers (`StopMarket` / `StopLimit`), while `TakeProfit` /
618    /// `TakeProfitLimit` map to "if-touched" triggers
619    /// (`MarketIfTouched` / `LimitIfTouched`) which fire when price reaches
620    /// a target rather than crosses a stop.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if a venue-internal or algorithmic order type does not map to a single
625    /// Nautilus order type.
626    pub fn as_nautilus(self) -> anyhow::Result<OrderType> {
627        match self {
628            Self::Limit => Ok(OrderType::Limit),
629            Self::Market => Ok(OrderType::Market),
630            Self::StopLoss => Ok(OrderType::StopMarket),
631            Self::StopLossLimit => Ok(OrderType::StopLimit),
632            Self::TakeProfit => Ok(OrderType::MarketIfTouched),
633            Self::TakeProfitLimit => Ok(OrderType::LimitIfTouched),
634            Self::Twap | Self::TwapSub | Self::Liquidation => Err(anyhow::anyhow!(
635                "Lighter `{self:?}` has no Nautilus order-type equivalent",
636            )),
637        }
638    }
639}
640
641impl TryFrom<OrderType> for LighterOrderType {
642    type Error = anyhow::Error;
643
644    fn try_from(value: OrderType) -> Result<Self, Self::Error> {
645        match value {
646            OrderType::Limit => Ok(Self::Limit),
647            OrderType::Market => Ok(Self::Market),
648            OrderType::StopMarket => Ok(Self::StopLoss),
649            OrderType::StopLimit => Ok(Self::StopLossLimit),
650            OrderType::MarketIfTouched => Ok(Self::TakeProfit),
651            OrderType::LimitIfTouched => Ok(Self::TakeProfitLimit),
652            other => Err(anyhow::anyhow!(
653                "Nautilus `{other:?}` has no Lighter order-type equivalent",
654            )),
655        }
656    }
657}
658
659/// Lighter time-in-force as encoded in the venue's binary order payload.
660#[derive(
661    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
662)]
663#[repr(u8)]
664pub enum LighterTimeInForce {
665    /// Immediate-or-cancel.
666    ImmediateOrCancel = 0,
667    /// Good-till-time.
668    GoodTillTime = 1,
669    /// Post-only.
670    PostOnly = 2,
671}
672
673/// Lighter grouped-order relationship type.
674#[derive(
675    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
676)]
677#[repr(u8)]
678pub enum LighterGroupingType {
679    None = 0,
680    OneTriggersTheOther = 1,
681    OneCancelsTheOther = 2,
682    OneTriggersOneCancelsTheOther = 3,
683}
684
685/// Lighter's action when an order would self-trade.
686#[derive(
687    Copy,
688    Clone,
689    Debug,
690    Default,
691    Display,
692    PartialEq,
693    Eq,
694    Hash,
695    AsRefStr,
696    EnumString,
697    Serialize_repr,
698    Deserialize_repr,
699)]
700#[repr(u8)]
701#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
702pub enum LighterSelfTradeBehaviorMode {
703    #[default]
704    ExpireMaker = 0,
705    ExpireTaker = 1,
706    CancelBoth = 2,
707    Reduce = 3,
708}
709
710/// Lighter's account scope for self-trade prevention.
711#[derive(
712    Copy,
713    Clone,
714    Debug,
715    Default,
716    Display,
717    PartialEq,
718    Eq,
719    Hash,
720    AsRefStr,
721    EnumString,
722    Serialize_repr,
723    Deserialize_repr,
724)]
725#[repr(u8)]
726#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
727pub enum LighterSelfTradeEqualityMode {
728    #[default]
729    AccountIndex = 0,
730    MasterAccountIndex = 1,
731}
732
733/// Lighter cancel-all-orders time-in-force.
734#[derive(
735    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
736)]
737#[repr(u8)]
738pub enum LighterCancelAllTimeInForce {
739    Immediate = 0,
740    Scheduled = 1,
741    AbortScheduled = 2,
742}
743
744/// Lighter asset margin mode.
745#[derive(
746    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
747)]
748#[repr(u8)]
749pub enum LighterAssetMarginMode {
750    Disabled = 0,
751    Enabled = 1,
752}
753
754/// Lighter asset route type.
755#[derive(
756    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
757)]
758#[repr(u8)]
759pub enum LighterAssetRouteType {
760    Perps = 0,
761    Spot = 1,
762}
763
764/// Lighter position margin mode.
765#[derive(
766    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
767)]
768#[repr(u8)]
769pub enum LighterPositionMarginMode {
770    Cross = 0,
771    Isolated = 1,
772}
773
774/// Lighter isolated-margin update direction.
775#[derive(
776    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
777)]
778#[repr(u8)]
779pub enum LighterMarginUpdateDirection {
780    RemoveFromIsolated = 0,
781    AddToIsolated = 1,
782}
783
784/// Lighter account tier, classified from the venue `account_type` code.
785///
786/// The code `0` is confirmed to be the standard tier. Codes for the higher
787/// tiers are not published in the venue schema, so they are mapped on a
788/// best-effort basis and any unrecognized code is preserved as
789/// [`Self::Unknown`] rather than silently misclassified. This type is a
790/// classification of the raw `account_type` byte, not a wire representation, so
791/// it is not serialized.
792#[derive(Copy, Clone, Debug, PartialEq, Eq)]
793pub enum LighterAccountTier {
794    Standard,
795    Premium,
796    Plus,
797    Builder,
798    Unknown(u8),
799}
800
801impl LighterAccountTier {
802    /// Classifies a venue `account_type` code into a tier.
803    #[must_use]
804    pub const fn from_code(code: u8) -> Self {
805        match code {
806            0 => Self::Standard,
807            1 => Self::Premium,
808            2 => Self::Plus,
809            3 => Self::Builder,
810            other => Self::Unknown(other),
811        }
812    }
813
814    /// Returns the documented REST weighted limit (requests per minute) for the
815    /// tier, or `None` when the tier is unrecognized.
816    ///
817    /// This drives log hints only. The adapter never sets the active quota from
818    /// this value, because the higher limits require registering the caller IP
819    /// with the venue and so are not guaranteed by the tier alone.
820    #[must_use]
821    pub const fn documented_rest_quota_per_min(self) -> Option<u32> {
822        match self {
823            Self::Standard => Some(60),
824            Self::Premium => Some(24_000),
825            Self::Plus => Some(24_000),
826            Self::Builder => Some(240_000),
827            Self::Unknown(_) => None,
828        }
829    }
830}
831
832impl Display for LighterAccountTier {
833    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
834        match self {
835            Self::Standard => f.write_str("Standard"),
836            Self::Premium => f.write_str("Premium"),
837            Self::Plus => f.write_str("Plus"),
838            Self::Builder => f.write_str("Builder"),
839            Self::Unknown(code) => write!(f, "Unknown({code})"),
840        }
841    }
842}
843
844// Conversions between `LighterTimeInForce` and Nautilus `TimeInForce` are
845// intentionally not provided here. `GoodTillTime` is ambiguous in isolation:
846// the venue uses it for both true GTD (paired with a positive `order_expiry`
847// timestamp) and venue-default lifetime (paired with `order_expiry = -1`).
848// The mapping must happen at the parse / order-build call site where the
849// `order_expiry` value is also in scope.
850
851/// Converts a Nautilus [`OrderSide`] to the venue's `is_ask` boolean.
852///
853/// Lighter's tx body encodes the side as `is_ask`: `false` for a bid (buy)
854/// and `true` for an ask (sell).
855pub fn is_ask_from_order_side(side: OrderSide) -> bool {
856    match side {
857        OrderSide::Buy => false,
858        OrderSide::Sell => true,
859    }
860}
861
862/// Converts the venue's `is_ask` boolean to a Nautilus [`OrderSide`].
863#[must_use]
864pub fn order_side_from_is_ask(is_ask: bool) -> OrderSide {
865    if is_ask {
866        OrderSide::Sell
867    } else {
868        OrderSide::Buy
869    }
870}
871
872/// Lighter L2 transaction-type discriminants.
873///
874/// These are the `tx_type` values the venue accepts on `sendTx` / `sendTxBatch`.
875/// Numeric values are wire-format and must match the venue spec.
876#[derive(
877    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
878)]
879#[repr(u8)]
880pub enum LighterTxType {
881    Empty = 0,
882    L1Deposit = 1,
883    L1ChangePubKey = 2,
884    L1CreateMarket = 3,
885    L1UpdateMarket = 4,
886    L1CancelAllOrders = 5,
887    L1Withdraw = 6,
888    L1CreateOrder = 7,
889    ChangePubKey = 8,
890    CreateSubAccount = 9,
891    CreatePublicPool = 10,
892    UpdatePublicPool = 11,
893    Transfer = 12,
894    Withdraw = 13,
895    CreateOrder = 14,
896    CancelOrder = 15,
897    CancelAllOrders = 16,
898    ModifyOrder = 17,
899    MintShares = 18,
900    BurnShares = 19,
901    UpdateLeverage = 20,
902    InternalClaimOrder = 21,
903    InternalCancelOrder = 22,
904    InternalDeleverage = 23,
905    InternalExitPosition = 24,
906    InternalCancelAllOrders = 25,
907    InternalLiquidatePosition = 26,
908    InternalCreateOrder = 27,
909    CreateGroupedOrders = 28,
910    UpdateMargin = 29,
911    L1BurnShares = 30,
912    L1RegisterAsset = 31,
913    L1UpdateAsset = 32,
914    CreateStakingPool = 33,
915    StakeAssets = 35,
916    UnstakeAssets = 36,
917    L1UnstakeAssets = 37,
918    L1SetSystemConfig = 38,
919    ForceBurnShares = 40,
920    UpdateAccountConfig = 41,
921    StrategyTransfer = 43,
922    UpdateMarketConfig = 44,
923    ApproveIntegrator = 45,
924}
925
926#[cfg(test)]
927mod tests {
928    use std::str::FromStr;
929
930    use nautilus_model::{
931        data::{BarSpecification, BarType},
932        identifiers::InstrumentId,
933    };
934    use rstest::rstest;
935    use serde_json;
936
937    use super::*;
938
939    #[rstest]
940    fn test_environment_default_is_mainnet() {
941        assert_eq!(LighterEnvironment::default(), LighterEnvironment::Mainnet);
942    }
943
944    #[rstest]
945    fn test_product_type_serde_uses_wire_values() {
946        assert_eq!(
947            serde_json::to_string(&LighterProductType::Perp).unwrap(),
948            r#""perp""#,
949        );
950        assert_eq!(
951            serde_json::from_str::<LighterProductType>(r#""spot""#).unwrap(),
952            LighterProductType::Spot,
953        );
954    }
955
956    #[rstest]
957    fn test_market_status_serde() {
958        assert_eq!(
959            serde_json::from_str::<LighterMarketStatus>(r#""active""#).unwrap(),
960            LighterMarketStatus::Active,
961        );
962        assert_eq!(
963            serde_json::to_string(&LighterMarketStatus::Inactive).unwrap(),
964            r#""inactive""#,
965        );
966    }
967
968    #[rstest]
969    fn test_string_order_enums_serde() {
970        assert_eq!(
971            serde_json::from_str::<LighterOrderKind>(r#""take-profit-limit""#).unwrap(),
972            LighterOrderKind::TakeProfitLimit,
973        );
974        assert_eq!(
975            serde_json::from_str::<LighterOrderKind>(r#""twap-sub""#).unwrap(),
976            LighterOrderKind::TwapSub,
977        );
978        assert_eq!(
979            serde_json::from_str::<LighterOrderKind>(r#""liquidation""#).unwrap(),
980            LighterOrderKind::Liquidation,
981        );
982        assert_eq!(
983            serde_json::from_str::<LighterOrderTimeInForce>(r#""good-till-time""#).unwrap(),
984            LighterOrderTimeInForce::GoodTillTime,
985        );
986        assert_eq!(
987            serde_json::from_str::<LighterOrderTimeInForce>(r#""Unknown""#).unwrap(),
988            LighterOrderTimeInForce::Unknown,
989        );
990        assert_eq!(
991            serde_json::from_str::<LighterOrderTimeInForce>(r#""unknown""#).unwrap(),
992            LighterOrderTimeInForce::Unknown,
993        );
994        assert_eq!(
995            serde_json::to_string(&LighterOrderTimeInForce::Unknown).unwrap(),
996            r#""Unknown""#,
997        );
998        assert_eq!(
999            serde_json::from_str::<LighterOrderStatus>(r#""canceled-not-enough-liquidity""#)
1000                .unwrap(),
1001            LighterOrderStatus::CanceledNotEnoughLiquidity,
1002        );
1003        assert_eq!(
1004            serde_json::from_str::<LighterTriggerStatus>(r#""parent-order""#).unwrap(),
1005            LighterTriggerStatus::ParentOrder,
1006        );
1007        assert_eq!(
1008            serde_json::from_str::<LighterOrderSide>(r#""sell""#).unwrap(),
1009            LighterOrderSide::Sell,
1010        );
1011    }
1012
1013    #[rstest]
1014    fn test_trade_type_serde() {
1015        assert_eq!(
1016            serde_json::from_str::<LighterTradeType>(r#""market-settlement""#).unwrap(),
1017            LighterTradeType::MarketSettlement,
1018        );
1019    }
1020
1021    #[rstest]
1022    fn test_order_type_repr_serde() {
1023        assert_eq!(
1024            serde_json::to_string(&LighterOrderType::Limit).unwrap(),
1025            "0",
1026        );
1027        assert_eq!(serde_json::to_string(&LighterOrderType::Twap).unwrap(), "6",);
1028        assert_eq!(
1029            serde_json::to_string(&LighterOrderType::TwapSub).unwrap(),
1030            "7",
1031        );
1032        assert_eq!(
1033            serde_json::to_string(&LighterOrderType::Liquidation).unwrap(),
1034            "8",
1035        );
1036        let parsed: LighterOrderType = serde_json::from_str("3").unwrap();
1037        assert_eq!(parsed, LighterOrderType::StopLossLimit);
1038    }
1039
1040    #[rstest]
1041    fn test_numeric_constant_enums_repr_serde() {
1042        assert_eq!(
1043            serde_json::to_string(&LighterGroupingType::OneTriggersOneCancelsTheOther).unwrap(),
1044            "3",
1045        );
1046        assert_eq!(
1047            serde_json::to_string(&LighterCancelAllTimeInForce::AbortScheduled).unwrap(),
1048            "2",
1049        );
1050        assert_eq!(
1051            serde_json::to_string(&LighterPositionMarginMode::Isolated).unwrap(),
1052            "1",
1053        );
1054        assert_eq!(
1055            serde_json::to_string(&LighterMarginUpdateDirection::AddToIsolated).unwrap(),
1056            "1",
1057        );
1058    }
1059
1060    #[rstest]
1061    fn test_time_in_force_repr_serde() {
1062        assert_eq!(
1063            serde_json::to_string(&LighterTimeInForce::ImmediateOrCancel).unwrap(),
1064            "0",
1065        );
1066        assert_eq!(
1067            serde_json::to_string(&LighterTimeInForce::PostOnly).unwrap(),
1068            "2",
1069        );
1070    }
1071
1072    #[rstest]
1073    #[case::one_minute(LighterCandleResolution::OneMinute, "1m")]
1074    #[case::five_minute(LighterCandleResolution::FiveMinute, "5m")]
1075    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, "15m")]
1076    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, "30m")]
1077    #[case::one_hour(LighterCandleResolution::OneHour, "1h")]
1078    #[case::four_hour(LighterCandleResolution::FourHour, "4h")]
1079    #[case::twelve_hour(LighterCandleResolution::TwelveHour, "12h")]
1080    #[case::one_day(LighterCandleResolution::OneDay, "1d")]
1081    #[case::one_week(LighterCandleResolution::OneWeek, "1w")]
1082    fn test_candle_resolution_string_round_trip(
1083        #[case] resolution: LighterCandleResolution,
1084        #[case] expected: &str,
1085    ) {
1086        assert_eq!(resolution.as_str(), expected);
1087        assert_eq!(resolution.to_string(), expected);
1088        assert_eq!(
1089            serde_json::to_string(&resolution).unwrap(),
1090            format!("\"{expected}\""),
1091        );
1092        assert_eq!(
1093            serde_json::from_str::<LighterCandleResolution>(&format!("\"{expected}\"")).unwrap(),
1094            resolution,
1095        );
1096        assert_eq!(
1097            LighterCandleResolution::from_str(expected).unwrap(),
1098            resolution
1099        );
1100        assert!(resolution.interval_millis() > 0);
1101    }
1102
1103    #[rstest]
1104    #[case::one_minute(1, BarAggregation::Minute, LighterCandleResolution::OneMinute)]
1105    #[case::five_minute(5, BarAggregation::Minute, LighterCandleResolution::FiveMinute)]
1106    #[case::fifteen_minute(15, BarAggregation::Minute, LighterCandleResolution::FifteenMinute)]
1107    #[case::thirty_minute(30, BarAggregation::Minute, LighterCandleResolution::ThirtyMinute)]
1108    #[case::one_hour(1, BarAggregation::Hour, LighterCandleResolution::OneHour)]
1109    #[case::four_hour(4, BarAggregation::Hour, LighterCandleResolution::FourHour)]
1110    #[case::twelve_hour(12, BarAggregation::Hour, LighterCandleResolution::TwelveHour)]
1111    #[case::one_day(1, BarAggregation::Day, LighterCandleResolution::OneDay)]
1112    #[case::one_week(1, BarAggregation::Week, LighterCandleResolution::OneWeek)]
1113    fn test_candle_resolution_from_bar_type(
1114        #[case] step: usize,
1115        #[case] aggregation: BarAggregation,
1116        #[case] expected: LighterCandleResolution,
1117    ) {
1118        let bar_type = lighter_bar_type(
1119            step,
1120            aggregation,
1121            PriceType::Last,
1122            AggregationSource::External,
1123        );
1124
1125        assert_eq!(
1126            LighterCandleResolution::try_from(&bar_type).unwrap(),
1127            expected
1128        );
1129    }
1130
1131    #[rstest]
1132    #[case::three_minute(3, BarAggregation::Minute, "minute step")]
1133    #[case::two_hour(2, BarAggregation::Hour, "hour step")]
1134    #[case::two_day(2, BarAggregation::Day, "day step")]
1135    #[case::two_week(2, BarAggregation::Week, "week step")]
1136    #[case::one_second(1, BarAggregation::Second, "aggregation")]
1137    fn test_candle_resolution_rejects_unsupported_bars(
1138        #[case] step: usize,
1139        #[case] aggregation: BarAggregation,
1140        #[case] expected: &str,
1141    ) {
1142        let bar_type = lighter_bar_type(
1143            step,
1144            aggregation,
1145            PriceType::Last,
1146            AggregationSource::External,
1147        );
1148
1149        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1150        assert!(err.to_string().contains(expected));
1151    }
1152
1153    #[rstest]
1154    fn test_candle_resolution_rejects_internal_bars() {
1155        let bar_type = lighter_bar_type(
1156            1,
1157            BarAggregation::Minute,
1158            PriceType::Last,
1159            AggregationSource::Internal,
1160        );
1161
1162        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1163        assert!(err.to_string().contains("EXTERNAL aggregation"));
1164    }
1165
1166    #[rstest]
1167    #[case::one_minute(LighterCandleResolution::OneMinute, 1, BarAggregation::Minute)]
1168    #[case::five_minute(LighterCandleResolution::FiveMinute, 5, BarAggregation::Minute)]
1169    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, 15, BarAggregation::Minute)]
1170    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, 30, BarAggregation::Minute)]
1171    #[case::one_hour(LighterCandleResolution::OneHour, 1, BarAggregation::Hour)]
1172    #[case::four_hour(LighterCandleResolution::FourHour, 4, BarAggregation::Hour)]
1173    #[case::twelve_hour(LighterCandleResolution::TwelveHour, 12, BarAggregation::Hour)]
1174    #[case::one_day(LighterCandleResolution::OneDay, 1, BarAggregation::Day)]
1175    #[case::one_week(LighterCandleResolution::OneWeek, 1, BarAggregation::Week)]
1176    fn test_candle_resolution_to_bar_spec(
1177        #[case] resolution: LighterCandleResolution,
1178        #[case] step: usize,
1179        #[case] aggregation: BarAggregation,
1180    ) {
1181        let spec = resolution.to_bar_spec();
1182        assert_eq!(spec.step.get(), step);
1183        assert_eq!(spec.aggregation, aggregation);
1184        assert_eq!(spec.price_type, PriceType::Last);
1185    }
1186
1187    #[rstest]
1188    #[case::one_minute(LighterCandleResolution::OneMinute, true)]
1189    #[case::five_minute(LighterCandleResolution::FiveMinute, true)]
1190    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, true)]
1191    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, true)]
1192    #[case::one_hour(LighterCandleResolution::OneHour, true)]
1193    #[case::four_hour(LighterCandleResolution::FourHour, true)]
1194    #[case::twelve_hour(LighterCandleResolution::TwelveHour, true)]
1195    #[case::one_day(LighterCandleResolution::OneDay, true)]
1196    #[case::one_week(LighterCandleResolution::OneWeek, false)]
1197    fn test_candle_resolution_is_ws_streamable(
1198        #[case] resolution: LighterCandleResolution,
1199        #[case] expected: bool,
1200    ) {
1201        assert_eq!(resolution.is_ws_streamable(), expected);
1202    }
1203
1204    #[rstest]
1205    fn test_candle_resolution_rejects_non_last_price_type() {
1206        let bar_type = lighter_bar_type(
1207            1,
1208            BarAggregation::Minute,
1209            PriceType::Mark,
1210            AggregationSource::External,
1211        );
1212
1213        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1214        assert!(err.to_string().contains("LAST price type"));
1215    }
1216
1217    #[rstest]
1218    #[case::limit(LighterOrderType::Limit, OrderType::Limit)]
1219    #[case::market(LighterOrderType::Market, OrderType::Market)]
1220    #[case::stop_loss(LighterOrderType::StopLoss, OrderType::StopMarket)]
1221    #[case::stop_loss_limit(LighterOrderType::StopLossLimit, OrderType::StopLimit)]
1222    #[case::take_profit(LighterOrderType::TakeProfit, OrderType::MarketIfTouched)]
1223    #[case::take_profit_limit(LighterOrderType::TakeProfitLimit, OrderType::LimitIfTouched)]
1224    fn test_order_type_round_trip(#[case] lighter: LighterOrderType, #[case] nautilus: OrderType) {
1225        assert_eq!(lighter.as_nautilus().unwrap(), nautilus);
1226        assert_eq!(LighterOrderType::try_from(nautilus).unwrap(), lighter);
1227    }
1228
1229    #[rstest]
1230    #[case::twap(LighterOrderType::Twap)]
1231    #[case::twap_sub(LighterOrderType::TwapSub)]
1232    #[case::liquidation(LighterOrderType::Liquidation)]
1233    fn test_order_type_internal_variants_have_no_nautilus_mapping(
1234        #[case] order_type: LighterOrderType,
1235    ) {
1236        let err = order_type.as_nautilus().unwrap_err();
1237        assert!(
1238            err.to_string()
1239                .contains("no Nautilus order-type equivalent")
1240        );
1241    }
1242
1243    #[rstest]
1244    #[case(OrderType::TrailingStopMarket)]
1245    #[case(OrderType::TrailingStopLimit)]
1246    #[case(OrderType::MarketToLimit)]
1247    fn test_order_type_unsupported_nautilus_variants_error(#[case] nautilus: OrderType) {
1248        let err = LighterOrderType::try_from(nautilus).unwrap_err();
1249        assert!(err.to_string().contains("no Lighter order-type equivalent"));
1250    }
1251
1252    #[rstest]
1253    fn test_is_ask_round_trip() {
1254        assert!(!is_ask_from_order_side(OrderSide::Buy));
1255        assert!(is_ask_from_order_side(OrderSide::Sell));
1256        assert_eq!(order_side_from_is_ask(false), OrderSide::Buy);
1257        assert_eq!(order_side_from_is_ask(true), OrderSide::Sell);
1258    }
1259
1260    #[rstest]
1261    fn test_tx_type_repr_serde() {
1262        assert_eq!(
1263            serde_json::to_string(&LighterTxType::CreateOrder).unwrap(),
1264            "14"
1265        );
1266        assert_eq!(
1267            serde_json::to_string(&LighterTxType::CancelAllOrders).unwrap(),
1268            "16",
1269        );
1270        assert_eq!(
1271            serde_json::to_string(&LighterTxType::ApproveIntegrator).unwrap(),
1272            "45",
1273        );
1274        assert_eq!(
1275            serde_json::to_string(&LighterTxType::CreateGroupedOrders).unwrap(),
1276            "28",
1277        );
1278        assert_eq!(serde_json::to_string(&LighterTxType::Empty).unwrap(), "0");
1279        assert_eq!(
1280            serde_json::to_string(&LighterTxType::L1RegisterAsset).unwrap(),
1281            "31",
1282        );
1283        assert_eq!(
1284            serde_json::to_string(&LighterTxType::ForceBurnShares).unwrap(),
1285            "40",
1286        );
1287        assert_eq!(
1288            serde_json::to_string(&LighterTxType::UpdateMarketConfig).unwrap(),
1289            "44",
1290        );
1291    }
1292
1293    fn lighter_bar_type(
1294        step: usize,
1295        aggregation: BarAggregation,
1296        price_type: PriceType,
1297        aggregation_source: AggregationSource,
1298    ) -> BarType {
1299        BarType::new(
1300            InstrumentId::from("BTC-PERP.LIGHTER"),
1301            BarSpecification::new(step, aggregation, price_type),
1302            aggregation_source,
1303        )
1304    }
1305
1306    #[rstest]
1307    #[case(0, LighterAccountTier::Standard)]
1308    #[case(1, LighterAccountTier::Premium)]
1309    #[case(2, LighterAccountTier::Plus)]
1310    #[case(3, LighterAccountTier::Builder)]
1311    #[case(4, LighterAccountTier::Unknown(4))]
1312    #[case(255, LighterAccountTier::Unknown(255))]
1313    fn test_account_tier_from_code(#[case] code: u8, #[case] expected: LighterAccountTier) {
1314        assert_eq!(LighterAccountTier::from_code(code), expected);
1315    }
1316
1317    #[rstest]
1318    #[case(LighterAccountTier::Standard, Some(60))]
1319    #[case(LighterAccountTier::Premium, Some(24_000))]
1320    #[case(LighterAccountTier::Plus, Some(24_000))]
1321    #[case(LighterAccountTier::Builder, Some(240_000))]
1322    #[case(LighterAccountTier::Unknown(9), None)]
1323    fn test_account_tier_documented_rest_quota(
1324        #[case] tier: LighterAccountTier,
1325        #[case] expected: Option<u32>,
1326    ) {
1327        assert_eq!(tier.documented_rest_quota_per_min(), expected);
1328    }
1329
1330    #[rstest]
1331    #[case(LighterAccountTier::Standard, "Standard")]
1332    #[case(LighterAccountTier::Premium, "Premium")]
1333    #[case(LighterAccountTier::Plus, "Plus")]
1334    #[case(LighterAccountTier::Builder, "Builder")]
1335    #[case(LighterAccountTier::Unknown(7), "Unknown(7)")]
1336    fn test_account_tier_display(#[case] tier: LighterAccountTier, #[case] expected: &str) {
1337        assert_eq!(tier.to_string(), expected);
1338    }
1339}