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