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