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 cancel-all-orders time-in-force.
686#[derive(
687    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
688)]
689#[repr(u8)]
690pub enum LighterCancelAllTimeInForce {
691    Immediate = 0,
692    Scheduled = 1,
693    AbortScheduled = 2,
694}
695
696/// Lighter asset margin mode.
697#[derive(
698    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
699)]
700#[repr(u8)]
701pub enum LighterAssetMarginMode {
702    Disabled = 0,
703    Enabled = 1,
704}
705
706/// Lighter asset route type.
707#[derive(
708    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
709)]
710#[repr(u8)]
711pub enum LighterAssetRouteType {
712    Perps = 0,
713    Spot = 1,
714}
715
716/// Lighter position margin mode.
717#[derive(
718    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
719)]
720#[repr(u8)]
721pub enum LighterPositionMarginMode {
722    Cross = 0,
723    Isolated = 1,
724}
725
726/// Lighter isolated-margin update direction.
727#[derive(
728    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
729)]
730#[repr(u8)]
731pub enum LighterMarginUpdateDirection {
732    RemoveFromIsolated = 0,
733    AddToIsolated = 1,
734}
735
736/// Lighter account tier, classified from the venue `account_type` code.
737///
738/// The code `0` is confirmed to be the standard tier. Codes for the higher
739/// tiers are not published in the venue schema, so they are mapped on a
740/// best-effort basis and any unrecognized code is preserved as
741/// [`Self::Unknown`] rather than silently misclassified. This type is a
742/// classification of the raw `account_type` byte, not a wire representation, so
743/// it is not serialized.
744#[derive(Copy, Clone, Debug, PartialEq, Eq)]
745pub enum LighterAccountTier {
746    Standard,
747    Premium,
748    Plus,
749    Builder,
750    Unknown(u8),
751}
752
753impl LighterAccountTier {
754    /// Classifies a venue `account_type` code into a tier.
755    #[must_use]
756    pub const fn from_code(code: u8) -> Self {
757        match code {
758            0 => Self::Standard,
759            1 => Self::Premium,
760            2 => Self::Plus,
761            3 => Self::Builder,
762            other => Self::Unknown(other),
763        }
764    }
765
766    /// Returns the documented REST weighted limit (requests per minute) for the
767    /// tier, or `None` when the tier is unrecognized.
768    ///
769    /// This drives log hints only. The adapter never sets the active quota from
770    /// this value, because the higher limits require registering the caller IP
771    /// with the venue and so are not guaranteed by the tier alone.
772    #[must_use]
773    pub const fn documented_rest_quota_per_min(self) -> Option<u32> {
774        match self {
775            Self::Standard => Some(60),
776            Self::Premium => Some(24_000),
777            Self::Plus => Some(24_000),
778            Self::Builder => Some(240_000),
779            Self::Unknown(_) => None,
780        }
781    }
782}
783
784impl Display for LighterAccountTier {
785    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786        match self {
787            Self::Standard => f.write_str("Standard"),
788            Self::Premium => f.write_str("Premium"),
789            Self::Plus => f.write_str("Plus"),
790            Self::Builder => f.write_str("Builder"),
791            Self::Unknown(code) => write!(f, "Unknown({code})"),
792        }
793    }
794}
795
796// Conversions between `LighterTimeInForce` and Nautilus `TimeInForce` are
797// intentionally not provided here. `GoodTillTime` is ambiguous in isolation:
798// the venue uses it for both true GTD (paired with a positive `order_expiry`
799// timestamp) and venue-default lifetime (paired with `order_expiry = -1`).
800// The mapping must happen at the parse / order-build call site where the
801// `order_expiry` value is also in scope.
802
803/// Converts a Nautilus [`OrderSide`] to the venue's `is_ask` boolean.
804///
805/// Lighter's tx body encodes the side as `is_ask`: `false` for a bid (buy)
806/// and `true` for an ask (sell).
807pub fn is_ask_from_order_side(side: OrderSide) -> bool {
808    match side {
809        OrderSide::Buy => false,
810        OrderSide::Sell => true,
811    }
812}
813
814/// Converts the venue's `is_ask` boolean to a Nautilus [`OrderSide`].
815#[must_use]
816pub fn order_side_from_is_ask(is_ask: bool) -> OrderSide {
817    if is_ask {
818        OrderSide::Sell
819    } else {
820        OrderSide::Buy
821    }
822}
823
824/// Lighter L2 transaction-type discriminants.
825///
826/// These are the `tx_type` values the venue accepts on `sendTx` / `sendTxBatch`.
827/// Numeric values are wire-format and must match the venue spec.
828#[derive(
829    Copy, Clone, Debug, Display, PartialEq, Eq, Hash, AsRefStr, Serialize_repr, Deserialize_repr,
830)]
831#[repr(u8)]
832pub enum LighterTxType {
833    Empty = 0,
834    L1Deposit = 1,
835    L1ChangePubKey = 2,
836    L1CreateMarket = 3,
837    L1UpdateMarket = 4,
838    L1CancelAllOrders = 5,
839    L1Withdraw = 6,
840    L1CreateOrder = 7,
841    ChangePubKey = 8,
842    CreateSubAccount = 9,
843    CreatePublicPool = 10,
844    UpdatePublicPool = 11,
845    Transfer = 12,
846    Withdraw = 13,
847    CreateOrder = 14,
848    CancelOrder = 15,
849    CancelAllOrders = 16,
850    ModifyOrder = 17,
851    MintShares = 18,
852    BurnShares = 19,
853    UpdateLeverage = 20,
854    InternalClaimOrder = 21,
855    InternalCancelOrder = 22,
856    InternalDeleverage = 23,
857    InternalExitPosition = 24,
858    InternalCancelAllOrders = 25,
859    InternalLiquidatePosition = 26,
860    InternalCreateOrder = 27,
861    CreateGroupedOrders = 28,
862    UpdateMargin = 29,
863    L1BurnShares = 30,
864    L1RegisterAsset = 31,
865    L1UpdateAsset = 32,
866    CreateStakingPool = 33,
867    StakeAssets = 35,
868    UnstakeAssets = 36,
869    L1UnstakeAssets = 37,
870    L1SetSystemConfig = 38,
871    ForceBurnShares = 40,
872    UpdateAccountConfig = 41,
873    StrategyTransfer = 43,
874    UpdateMarketConfig = 44,
875    ApproveIntegrator = 45,
876}
877
878#[cfg(test)]
879mod tests {
880    use std::str::FromStr;
881
882    use nautilus_model::{
883        data::{BarSpecification, BarType},
884        identifiers::InstrumentId,
885    };
886    use rstest::rstest;
887    use serde_json;
888
889    use super::*;
890
891    #[rstest]
892    fn test_environment_default_is_mainnet() {
893        assert_eq!(LighterEnvironment::default(), LighterEnvironment::Mainnet);
894    }
895
896    #[rstest]
897    fn test_product_type_serde_uses_wire_values() {
898        assert_eq!(
899            serde_json::to_string(&LighterProductType::Perp).unwrap(),
900            r#""perp""#,
901        );
902        assert_eq!(
903            serde_json::from_str::<LighterProductType>(r#""spot""#).unwrap(),
904            LighterProductType::Spot,
905        );
906    }
907
908    #[rstest]
909    fn test_market_status_serde() {
910        assert_eq!(
911            serde_json::from_str::<LighterMarketStatus>(r#""active""#).unwrap(),
912            LighterMarketStatus::Active,
913        );
914        assert_eq!(
915            serde_json::to_string(&LighterMarketStatus::Inactive).unwrap(),
916            r#""inactive""#,
917        );
918    }
919
920    #[rstest]
921    fn test_string_order_enums_serde() {
922        assert_eq!(
923            serde_json::from_str::<LighterOrderKind>(r#""take-profit-limit""#).unwrap(),
924            LighterOrderKind::TakeProfitLimit,
925        );
926        assert_eq!(
927            serde_json::from_str::<LighterOrderKind>(r#""twap-sub""#).unwrap(),
928            LighterOrderKind::TwapSub,
929        );
930        assert_eq!(
931            serde_json::from_str::<LighterOrderKind>(r#""liquidation""#).unwrap(),
932            LighterOrderKind::Liquidation,
933        );
934        assert_eq!(
935            serde_json::from_str::<LighterOrderTimeInForce>(r#""good-till-time""#).unwrap(),
936            LighterOrderTimeInForce::GoodTillTime,
937        );
938        assert_eq!(
939            serde_json::from_str::<LighterOrderTimeInForce>(r#""Unknown""#).unwrap(),
940            LighterOrderTimeInForce::Unknown,
941        );
942        assert_eq!(
943            serde_json::from_str::<LighterOrderTimeInForce>(r#""unknown""#).unwrap(),
944            LighterOrderTimeInForce::Unknown,
945        );
946        assert_eq!(
947            serde_json::to_string(&LighterOrderTimeInForce::Unknown).unwrap(),
948            r#""Unknown""#,
949        );
950        assert_eq!(
951            serde_json::from_str::<LighterOrderStatus>(r#""canceled-not-enough-liquidity""#)
952                .unwrap(),
953            LighterOrderStatus::CanceledNotEnoughLiquidity,
954        );
955        assert_eq!(
956            serde_json::from_str::<LighterTriggerStatus>(r#""parent-order""#).unwrap(),
957            LighterTriggerStatus::ParentOrder,
958        );
959        assert_eq!(
960            serde_json::from_str::<LighterOrderSide>(r#""sell""#).unwrap(),
961            LighterOrderSide::Sell,
962        );
963    }
964
965    #[rstest]
966    fn test_trade_type_serde() {
967        assert_eq!(
968            serde_json::from_str::<LighterTradeType>(r#""market-settlement""#).unwrap(),
969            LighterTradeType::MarketSettlement,
970        );
971    }
972
973    #[rstest]
974    fn test_order_type_repr_serde() {
975        assert_eq!(
976            serde_json::to_string(&LighterOrderType::Limit).unwrap(),
977            "0",
978        );
979        assert_eq!(serde_json::to_string(&LighterOrderType::Twap).unwrap(), "6",);
980        assert_eq!(
981            serde_json::to_string(&LighterOrderType::TwapSub).unwrap(),
982            "7",
983        );
984        assert_eq!(
985            serde_json::to_string(&LighterOrderType::Liquidation).unwrap(),
986            "8",
987        );
988        let parsed: LighterOrderType = serde_json::from_str("3").unwrap();
989        assert_eq!(parsed, LighterOrderType::StopLossLimit);
990    }
991
992    #[rstest]
993    fn test_numeric_constant_enums_repr_serde() {
994        assert_eq!(
995            serde_json::to_string(&LighterGroupingType::OneTriggersOneCancelsTheOther).unwrap(),
996            "3",
997        );
998        assert_eq!(
999            serde_json::to_string(&LighterCancelAllTimeInForce::AbortScheduled).unwrap(),
1000            "2",
1001        );
1002        assert_eq!(
1003            serde_json::to_string(&LighterPositionMarginMode::Isolated).unwrap(),
1004            "1",
1005        );
1006        assert_eq!(
1007            serde_json::to_string(&LighterMarginUpdateDirection::AddToIsolated).unwrap(),
1008            "1",
1009        );
1010    }
1011
1012    #[rstest]
1013    fn test_time_in_force_repr_serde() {
1014        assert_eq!(
1015            serde_json::to_string(&LighterTimeInForce::ImmediateOrCancel).unwrap(),
1016            "0",
1017        );
1018        assert_eq!(
1019            serde_json::to_string(&LighterTimeInForce::PostOnly).unwrap(),
1020            "2",
1021        );
1022    }
1023
1024    #[rstest]
1025    #[case::one_minute(LighterCandleResolution::OneMinute, "1m")]
1026    #[case::five_minute(LighterCandleResolution::FiveMinute, "5m")]
1027    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, "15m")]
1028    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, "30m")]
1029    #[case::one_hour(LighterCandleResolution::OneHour, "1h")]
1030    #[case::four_hour(LighterCandleResolution::FourHour, "4h")]
1031    #[case::twelve_hour(LighterCandleResolution::TwelveHour, "12h")]
1032    #[case::one_day(LighterCandleResolution::OneDay, "1d")]
1033    #[case::one_week(LighterCandleResolution::OneWeek, "1w")]
1034    fn test_candle_resolution_string_round_trip(
1035        #[case] resolution: LighterCandleResolution,
1036        #[case] expected: &str,
1037    ) {
1038        assert_eq!(resolution.as_str(), expected);
1039        assert_eq!(resolution.to_string(), expected);
1040        assert_eq!(
1041            serde_json::to_string(&resolution).unwrap(),
1042            format!("\"{expected}\""),
1043        );
1044        assert_eq!(
1045            serde_json::from_str::<LighterCandleResolution>(&format!("\"{expected}\"")).unwrap(),
1046            resolution,
1047        );
1048        assert_eq!(
1049            LighterCandleResolution::from_str(expected).unwrap(),
1050            resolution
1051        );
1052        assert!(resolution.interval_millis() > 0);
1053    }
1054
1055    #[rstest]
1056    #[case::one_minute(1, BarAggregation::Minute, LighterCandleResolution::OneMinute)]
1057    #[case::five_minute(5, BarAggregation::Minute, LighterCandleResolution::FiveMinute)]
1058    #[case::fifteen_minute(15, BarAggregation::Minute, LighterCandleResolution::FifteenMinute)]
1059    #[case::thirty_minute(30, BarAggregation::Minute, LighterCandleResolution::ThirtyMinute)]
1060    #[case::one_hour(1, BarAggregation::Hour, LighterCandleResolution::OneHour)]
1061    #[case::four_hour(4, BarAggregation::Hour, LighterCandleResolution::FourHour)]
1062    #[case::twelve_hour(12, BarAggregation::Hour, LighterCandleResolution::TwelveHour)]
1063    #[case::one_day(1, BarAggregation::Day, LighterCandleResolution::OneDay)]
1064    #[case::one_week(1, BarAggregation::Week, LighterCandleResolution::OneWeek)]
1065    fn test_candle_resolution_from_bar_type(
1066        #[case] step: usize,
1067        #[case] aggregation: BarAggregation,
1068        #[case] expected: LighterCandleResolution,
1069    ) {
1070        let bar_type = lighter_bar_type(
1071            step,
1072            aggregation,
1073            PriceType::Last,
1074            AggregationSource::External,
1075        );
1076
1077        assert_eq!(
1078            LighterCandleResolution::try_from(&bar_type).unwrap(),
1079            expected
1080        );
1081    }
1082
1083    #[rstest]
1084    #[case::three_minute(3, BarAggregation::Minute, "minute step")]
1085    #[case::two_hour(2, BarAggregation::Hour, "hour step")]
1086    #[case::two_day(2, BarAggregation::Day, "day step")]
1087    #[case::two_week(2, BarAggregation::Week, "week step")]
1088    #[case::one_second(1, BarAggregation::Second, "aggregation")]
1089    fn test_candle_resolution_rejects_unsupported_bars(
1090        #[case] step: usize,
1091        #[case] aggregation: BarAggregation,
1092        #[case] expected: &str,
1093    ) {
1094        let bar_type = lighter_bar_type(
1095            step,
1096            aggregation,
1097            PriceType::Last,
1098            AggregationSource::External,
1099        );
1100
1101        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1102        assert!(err.to_string().contains(expected));
1103    }
1104
1105    #[rstest]
1106    fn test_candle_resolution_rejects_internal_bars() {
1107        let bar_type = lighter_bar_type(
1108            1,
1109            BarAggregation::Minute,
1110            PriceType::Last,
1111            AggregationSource::Internal,
1112        );
1113
1114        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1115        assert!(err.to_string().contains("EXTERNAL aggregation"));
1116    }
1117
1118    #[rstest]
1119    #[case::one_minute(LighterCandleResolution::OneMinute, 1, BarAggregation::Minute)]
1120    #[case::five_minute(LighterCandleResolution::FiveMinute, 5, BarAggregation::Minute)]
1121    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, 15, BarAggregation::Minute)]
1122    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, 30, BarAggregation::Minute)]
1123    #[case::one_hour(LighterCandleResolution::OneHour, 1, BarAggregation::Hour)]
1124    #[case::four_hour(LighterCandleResolution::FourHour, 4, BarAggregation::Hour)]
1125    #[case::twelve_hour(LighterCandleResolution::TwelveHour, 12, BarAggregation::Hour)]
1126    #[case::one_day(LighterCandleResolution::OneDay, 1, BarAggregation::Day)]
1127    #[case::one_week(LighterCandleResolution::OneWeek, 1, BarAggregation::Week)]
1128    fn test_candle_resolution_to_bar_spec(
1129        #[case] resolution: LighterCandleResolution,
1130        #[case] step: usize,
1131        #[case] aggregation: BarAggregation,
1132    ) {
1133        let spec = resolution.to_bar_spec();
1134        assert_eq!(spec.step.get(), step);
1135        assert_eq!(spec.aggregation, aggregation);
1136        assert_eq!(spec.price_type, PriceType::Last);
1137    }
1138
1139    #[rstest]
1140    #[case::one_minute(LighterCandleResolution::OneMinute, true)]
1141    #[case::five_minute(LighterCandleResolution::FiveMinute, true)]
1142    #[case::fifteen_minute(LighterCandleResolution::FifteenMinute, true)]
1143    #[case::thirty_minute(LighterCandleResolution::ThirtyMinute, true)]
1144    #[case::one_hour(LighterCandleResolution::OneHour, true)]
1145    #[case::four_hour(LighterCandleResolution::FourHour, true)]
1146    #[case::twelve_hour(LighterCandleResolution::TwelveHour, true)]
1147    #[case::one_day(LighterCandleResolution::OneDay, true)]
1148    #[case::one_week(LighterCandleResolution::OneWeek, false)]
1149    fn test_candle_resolution_is_ws_streamable(
1150        #[case] resolution: LighterCandleResolution,
1151        #[case] expected: bool,
1152    ) {
1153        assert_eq!(resolution.is_ws_streamable(), expected);
1154    }
1155
1156    #[rstest]
1157    fn test_candle_resolution_rejects_non_last_price_type() {
1158        let bar_type = lighter_bar_type(
1159            1,
1160            BarAggregation::Minute,
1161            PriceType::Mark,
1162            AggregationSource::External,
1163        );
1164
1165        let err = LighterCandleResolution::try_from(&bar_type).unwrap_err();
1166        assert!(err.to_string().contains("LAST price type"));
1167    }
1168
1169    #[rstest]
1170    #[case::limit(LighterOrderType::Limit, OrderType::Limit)]
1171    #[case::market(LighterOrderType::Market, OrderType::Market)]
1172    #[case::stop_loss(LighterOrderType::StopLoss, OrderType::StopMarket)]
1173    #[case::stop_loss_limit(LighterOrderType::StopLossLimit, OrderType::StopLimit)]
1174    #[case::take_profit(LighterOrderType::TakeProfit, OrderType::MarketIfTouched)]
1175    #[case::take_profit_limit(LighterOrderType::TakeProfitLimit, OrderType::LimitIfTouched)]
1176    fn test_order_type_round_trip(#[case] lighter: LighterOrderType, #[case] nautilus: OrderType) {
1177        assert_eq!(lighter.as_nautilus().unwrap(), nautilus);
1178        assert_eq!(LighterOrderType::try_from(nautilus).unwrap(), lighter);
1179    }
1180
1181    #[rstest]
1182    #[case::twap(LighterOrderType::Twap)]
1183    #[case::twap_sub(LighterOrderType::TwapSub)]
1184    #[case::liquidation(LighterOrderType::Liquidation)]
1185    fn test_order_type_internal_variants_have_no_nautilus_mapping(
1186        #[case] order_type: LighterOrderType,
1187    ) {
1188        let err = order_type.as_nautilus().unwrap_err();
1189        assert!(
1190            err.to_string()
1191                .contains("no Nautilus order-type equivalent")
1192        );
1193    }
1194
1195    #[rstest]
1196    #[case(OrderType::TrailingStopMarket)]
1197    #[case(OrderType::TrailingStopLimit)]
1198    #[case(OrderType::MarketToLimit)]
1199    fn test_order_type_unsupported_nautilus_variants_error(#[case] nautilus: OrderType) {
1200        let err = LighterOrderType::try_from(nautilus).unwrap_err();
1201        assert!(err.to_string().contains("no Lighter order-type equivalent"));
1202    }
1203
1204    #[rstest]
1205    fn test_is_ask_round_trip() {
1206        assert!(!is_ask_from_order_side(OrderSide::Buy));
1207        assert!(is_ask_from_order_side(OrderSide::Sell));
1208        assert_eq!(order_side_from_is_ask(false), OrderSide::Buy);
1209        assert_eq!(order_side_from_is_ask(true), OrderSide::Sell);
1210    }
1211
1212    #[rstest]
1213    fn test_tx_type_repr_serde() {
1214        assert_eq!(
1215            serde_json::to_string(&LighterTxType::CreateOrder).unwrap(),
1216            "14"
1217        );
1218        assert_eq!(
1219            serde_json::to_string(&LighterTxType::CancelAllOrders).unwrap(),
1220            "16",
1221        );
1222        assert_eq!(
1223            serde_json::to_string(&LighterTxType::ApproveIntegrator).unwrap(),
1224            "45",
1225        );
1226        assert_eq!(
1227            serde_json::to_string(&LighterTxType::CreateGroupedOrders).unwrap(),
1228            "28",
1229        );
1230        assert_eq!(serde_json::to_string(&LighterTxType::Empty).unwrap(), "0");
1231        assert_eq!(
1232            serde_json::to_string(&LighterTxType::L1RegisterAsset).unwrap(),
1233            "31",
1234        );
1235        assert_eq!(
1236            serde_json::to_string(&LighterTxType::ForceBurnShares).unwrap(),
1237            "40",
1238        );
1239        assert_eq!(
1240            serde_json::to_string(&LighterTxType::UpdateMarketConfig).unwrap(),
1241            "44",
1242        );
1243    }
1244
1245    fn lighter_bar_type(
1246        step: usize,
1247        aggregation: BarAggregation,
1248        price_type: PriceType,
1249        aggregation_source: AggregationSource,
1250    ) -> BarType {
1251        BarType::new(
1252            InstrumentId::from("BTC-PERP.LIGHTER"),
1253            BarSpecification::new(step, aggregation, price_type),
1254            aggregation_source,
1255        )
1256    }
1257
1258    #[rstest]
1259    #[case(0, LighterAccountTier::Standard)]
1260    #[case(1, LighterAccountTier::Premium)]
1261    #[case(2, LighterAccountTier::Plus)]
1262    #[case(3, LighterAccountTier::Builder)]
1263    #[case(4, LighterAccountTier::Unknown(4))]
1264    #[case(255, LighterAccountTier::Unknown(255))]
1265    fn test_account_tier_from_code(#[case] code: u8, #[case] expected: LighterAccountTier) {
1266        assert_eq!(LighterAccountTier::from_code(code), expected);
1267    }
1268
1269    #[rstest]
1270    #[case(LighterAccountTier::Standard, Some(60))]
1271    #[case(LighterAccountTier::Premium, Some(24_000))]
1272    #[case(LighterAccountTier::Plus, Some(24_000))]
1273    #[case(LighterAccountTier::Builder, Some(240_000))]
1274    #[case(LighterAccountTier::Unknown(9), None)]
1275    fn test_account_tier_documented_rest_quota(
1276        #[case] tier: LighterAccountTier,
1277        #[case] expected: Option<u32>,
1278    ) {
1279        assert_eq!(tier.documented_rest_quota_per_min(), expected);
1280    }
1281
1282    #[rstest]
1283    #[case(LighterAccountTier::Standard, "Standard")]
1284    #[case(LighterAccountTier::Premium, "Premium")]
1285    #[case(LighterAccountTier::Plus, "Plus")]
1286    #[case(LighterAccountTier::Builder, "Builder")]
1287    #[case(LighterAccountTier::Unknown(7), "Unknown(7)")]
1288    fn test_account_tier_display(#[case] tier: LighterAccountTier, #[case] expected: &str) {
1289        assert_eq!(tier.to_string(), expected);
1290    }
1291}