Skip to main content

nautilus_lighter/websocket/
handler.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//! Inner WebSocket feed handler running on a dedicated tokio task.
17
18use std::{
19    collections::VecDeque,
20    fmt::Debug,
21    sync::{
22        Arc,
23        atomic::{AtomicBool, Ordering},
24    },
25};
26
27use ahash::{AHashMap, AHashSet};
28use nautilus_core::{AtomicTime, nanos::UnixNanos, time::get_atomic_clock_realtime};
29use nautilus_model::{identifiers::AccountId, instruments::InstrumentAny};
30use nautilus_network::{
31    RECONNECTED,
32    error::SendError,
33    retry::{RetryManager, create_websocket_retry_manager},
34    websocket::{SubscriptionState, WebSocketClient},
35};
36use rust_decimal::Decimal;
37use tokio_tungstenite::tungstenite::Message;
38use ustr::Ustr;
39
40use super::{
41    account_state::LighterAccountStateReconciler,
42    error::LighterWsError,
43    messages::{
44        AccountStream, ExecutionReport, LighterAsset, LighterPosition, LighterUserStats,
45        LighterWsCandle, LighterWsChannel, LighterWsChannelKind, LighterWsFrame,
46        LighterWsOrderBook, LighterWsRequest, NautilusWsMessage, SendTxRejectionSource,
47    },
48    parse::{
49        parse_ws_bar, parse_ws_funding_rate_update, parse_ws_index_price_update,
50        parse_ws_mark_price_update, parse_ws_order_book_deltas, parse_ws_order_book_depth10,
51        parse_ws_position_status_report, parse_ws_quote_tick, parse_ws_spot_index_price_update,
52        parse_ws_trade_tick,
53    },
54};
55use crate::{
56    common::{
57        consts::{
58            LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED, LIGHTER_ERROR_CODE_TX_RANGE,
59            LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL, SUBSCRIBE_INFLIGHT_MAX,
60        },
61        enums::LighterCandleResolution,
62        rate_limit::LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY,
63    },
64    http::models::{LighterOrder, LighterPriceLevel, LighterTrade},
65};
66
67// Lighter control-frame `type` field values that fall outside the typed
68// `LighterWsFrame` variants. These are protocol-level frames the handler
69// inspects via the dual-pass parse fallback in `handle_control_text`.
70const CTRL_TYPE_CONNECTED: &str = "connected";
71const CTRL_TYPE_SUBSCRIBED: &str = "subscribed";
72const CTRL_TYPE_UNSUBSCRIBED: &str = "unsubscribed";
73const CTRL_TYPE_PING: &str = "ping";
74const CTRL_TYPE_PONG: &str = "pong";
75const CTRL_TYPE_ERROR: &str = "error";
76const CTRL_TYPE_SEND_TX: &str = "jsonapi/sendtx";
77
78/// Commands sent from the outer client to the inner feed handler.
79#[expect(
80    clippy::large_enum_variant,
81    reason = "commands are ephemeral and immediately consumed"
82)]
83pub enum HandlerCommand {
84    /// Hand the live `WebSocketClient` to the handler after the outer client
85    /// completes the network connect.
86    SetClient(WebSocketClient),
87    /// Drain the queue and shut the handler down.
88    Disconnect,
89    /// Subscribe to a channel. `auth` carries an optional auth token for the
90    /// account-scoped channels.
91    Subscribe {
92        channel: LighterWsChannel,
93        auth: Option<String>,
94    },
95    /// Unsubscribe from a channel.
96    Unsubscribe { channel: LighterWsChannel },
97    /// Resubscribe to the venue `order_book` stream after a continuity gap.
98    ResubscribeOrderBook { market_index: i16 },
99    /// Replace the handler's instrument cache (used on initial connect).
100    InitializeInstruments(Vec<(i16, InstrumentAny)>),
101    /// Insert or replace a single instrument by `market_index`.
102    UpdateInstrument {
103        market_index: i16,
104        instrument: InstrumentAny,
105    },
106    /// Toggle whether `update/order_book` frames for `market_index` should
107    /// be emitted as [`NautilusWsMessage::Deltas`].
108    SetBookDeltasSub { market_index: i16, subscribed: bool },
109    /// Toggle whether `update/order_book` frames for `market_index` should
110    /// also be emitted as a [`NautilusWsMessage::Depth10`] snapshot.
111    SetDepth10Sub { market_index: i16, subscribed: bool },
112    /// Provide the execution context (`AccountId` and venue `account_index`)
113    /// the handler stamps onto reports parsed from `account_*` frames.
114    /// Without this context the handler cannot construct typed reports and
115    /// account frames are forwarded as raw JSON instead.
116    SetExecutionContext {
117        account_id: AccountId,
118        account_index: i64,
119    },
120    /// Dispatch a signed L2 transaction over the WebSocket.
121    ///
122    /// The handler serializes a [`LighterWsRequest::SendTx`] frame; the venue
123    /// confirms acceptance via the `account_*` streams (and reports any
124    /// rejection via a control-plane `error` frame).
125    SendTx {
126        tx_type: u8,
127        tx_info: Box<serde_json::value::RawValue>,
128        response_tx: tokio::sync::oneshot::Sender<Result<(), LighterWsError>>,
129    },
130}
131
132impl Debug for HandlerCommand {
133    /// Custom `Debug` that redacts the `auth` field of `Subscribe`. The
134    /// command is routed in-process via mpsc and may be printed by error
135    /// paths or trace logging; deriving `Debug` would otherwise leak the
136    /// venue bearer token verbatim.
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            Self::SetClient(_) => f.write_str("SetClient(<WebSocketClient>)"),
140            Self::Disconnect => f.write_str("Disconnect"),
141            Self::Subscribe { channel, auth } => f
142                .debug_struct(stringify!(Subscribe))
143                .field("channel", channel)
144                .field("authed", &auth.is_some())
145                .finish(),
146            Self::Unsubscribe { channel } => f
147                .debug_struct(stringify!(Unsubscribe))
148                .field("channel", channel)
149                .finish(),
150            Self::ResubscribeOrderBook { market_index } => f
151                .debug_struct(stringify!(ResubscribeOrderBook))
152                .field("market_index", market_index)
153                .finish(),
154            Self::InitializeInstruments(instruments) => f
155                .debug_tuple(stringify!(InitializeInstruments))
156                .field(&instruments.len())
157                .finish(),
158            Self::UpdateInstrument { market_index, .. } => f
159                .debug_struct(stringify!(UpdateInstrument))
160                .field("market_index", market_index)
161                .finish(),
162            Self::SetBookDeltasSub {
163                market_index,
164                subscribed,
165            } => f
166                .debug_struct(stringify!(SetBookDeltasSub))
167                .field("market_index", market_index)
168                .field("subscribed", subscribed)
169                .finish(),
170            Self::SetDepth10Sub {
171                market_index,
172                subscribed,
173            } => f
174                .debug_struct(stringify!(SetDepth10Sub))
175                .field("market_index", market_index)
176                .field("subscribed", subscribed)
177                .finish(),
178            Self::SetExecutionContext {
179                account_id,
180                account_index,
181            } => f
182                .debug_struct(stringify!(SetExecutionContext))
183                .field("account_id", account_id)
184                .field("account_index", account_index)
185                .finish(),
186            Self::SendTx { tx_type, .. } => f
187                .debug_struct(stringify!(SendTx))
188                .field("tx_type", tx_type)
189                .field("tx_info", &"<redacted>")
190                .finish(),
191        }
192    }
193}
194
195/// Inner feed handler. Owns the [`WebSocketClient`] exclusively and routes
196/// raw frames into the venue-message channel.
197///
198/// Subscribe dispatch is gated by a closed-loop inflight count: the venue caps
199/// unacknowledged client messages per connection, and acknowledgement latency
200/// is multi-second, so queued subscribes are held back until acks free a slot.
201pub(super) struct FeedHandler {
202    clock: &'static AtomicTime,
203    signal: Arc<AtomicBool>,
204    inner: Option<WebSocketClient>,
205    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
206    cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
207    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
208    out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
209    subscriptions: SubscriptionState,
210    retry_manager: RetryManager<LighterWsError>,
211    pending_messages: VecDeque<NautilusWsMessage>,
212    pending_subs: VecDeque<(LighterWsChannel, Option<String>)>,
213    inflight_subs: AHashSet<Ustr>,
214    instruments: AHashMap<i16, InstrumentAny>,
215    book_delta_subs: AHashSet<i16>,
216    book_depth_10_subs: AHashSet<i16>,
217    book_snapshots_seen: AHashSet<i16>,
218    book_states: AHashMap<i16, CachedOrderBook>,
219    last_candles: AHashMap<(i16, LighterCandleResolution), LighterWsCandle>,
220    exec_account: Option<(AccountId, i64)>,
221    account_state_reconciler: LighterAccountStateReconciler,
222}
223
224#[derive(Debug, Clone)]
225struct CachedOrderBook {
226    book: LighterWsOrderBook,
227    timestamp: u64,
228}
229
230impl FeedHandler {
231    pub(super) fn new(
232        signal: Arc<AtomicBool>,
233        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
234        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
235        out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
236        subscriptions: SubscriptionState,
237    ) -> Self {
238        Self {
239            clock: get_atomic_clock_realtime(),
240            signal,
241            inner: None,
242            cmd_rx,
243            cmd_tx: None,
244            raw_rx,
245            out_tx,
246            subscriptions,
247            retry_manager: create_websocket_retry_manager(),
248            pending_messages: VecDeque::new(),
249            pending_subs: VecDeque::new(),
250            inflight_subs: AHashSet::new(),
251            instruments: AHashMap::new(),
252            book_delta_subs: AHashSet::new(),
253            book_depth_10_subs: AHashSet::new(),
254            book_snapshots_seen: AHashSet::new(),
255            book_states: AHashMap::new(),
256            last_candles: AHashMap::new(),
257            exec_account: None,
258            account_state_reconciler: LighterAccountStateReconciler::new(),
259        }
260    }
261
262    pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
263        self.out_tx
264            .send(msg)
265            .map_err(|e| format!("Failed to send message: {e}"))
266    }
267
268    pub(super) fn set_command_sender(
269        &mut self,
270        cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
271    ) {
272        self.cmd_tx = Some(cmd_tx);
273    }
274
275    pub(super) fn is_stopped(&self) -> bool {
276        self.signal.load(Ordering::Relaxed)
277    }
278
279    async fn send_with_retry(&self, payload: String) -> Result<(), LighterWsError> {
280        if let Some(client) = &self.inner {
281            self.retry_manager
282                .execute_with_retry(
283                    "websocket_send",
284                    || {
285                        let payload = payload.clone();
286                        async move {
287                            client
288                                .send_text(
289                                    payload,
290                                    Some(LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY.as_slice()),
291                                )
292                                .await
293                                .map_err(LighterWsError::Transport)
294                        }
295                    },
296                    should_retry_lighter_ws_error,
297                    create_lighter_ws_timeout_error,
298                )
299                .await
300        } else {
301            Err(LighterWsError::Client(
302                "no active WebSocket client".to_string(),
303            ))
304        }
305    }
306
307    // Single-shot: sendTx payloads carry a signed nonce; transport-layer
308    // retry could double-submit if the original landed and only the ack was lost.
309    async fn send_once(&self, payload: String) -> Result<(), LighterWsError> {
310        if let Some(client) = &self.inner {
311            client
312                .send_text(payload, None)
313                .await
314                .map_err(LighterWsError::Transport)
315        } else {
316            Err(LighterWsError::Client(
317                "no active WebSocket client".to_string(),
318            ))
319        }
320    }
321
322    // False when the send fails: no ack will arrive, so the caller frees the slot
323    async fn dispatch_subscribe(&self, channel: LighterWsChannel, auth: Option<String>) -> bool {
324        let topic = channel.topic_key();
325        self.subscriptions.mark_subscribe(&topic);
326
327        let authed = auth.is_some();
328        let request = match auth {
329            Some(token) => LighterWsRequest::subscribe_auth(channel.subscription_channel(), token),
330            None => LighterWsRequest::subscribe(channel.subscription_channel()),
331        };
332
333        match serde_json::to_string(&request) {
334            Ok(payload) => {
335                // Avoid logging the serialized payload for authenticated channels;
336                // it embeds a live Lighter L2 bearer token.
337                log::debug!("Sending Lighter subscribe: topic={topic} authed={authed}");
338                if let Err(e) = self.send_with_retry(payload).await {
339                    log::error!("Error subscribing to {topic}: {e}");
340                    self.subscriptions.mark_failure(&topic);
341                    false
342                } else {
343                    true
344                }
345            }
346            Err(e) => {
347                log::error!("Error serializing subscription for {topic}: {e}");
348                self.subscriptions.mark_failure(&topic);
349                false
350            }
351        }
352    }
353
354    async fn dispatch_send_tx(
355        &self,
356        tx_type: u8,
357        tx_info: Box<serde_json::value::RawValue>,
358    ) -> Result<(), LighterWsError> {
359        let request = LighterWsRequest::SendTx {
360            data: super::messages::LighterWsSendTx { tx_type, tx_info },
361        };
362
363        match serde_json::to_string(&request) {
364            Ok(payload) => {
365                log::debug!(
366                    "Sending Lighter sendTx: tx_type={tx_type} ({} bytes)",
367                    payload.len(),
368                );
369                log::debug!("Lighter sendTx payload: {payload}");
370                if let Err(e) = self.send_once(payload).await {
371                    log::error!("Error dispatching Lighter sendTx (tx_type={tx_type}): {e}");
372                    Err(e)
373                } else {
374                    Ok(())
375                }
376            }
377            Err(e) => {
378                log::error!("Error serializing Lighter sendTx (tx_type={tx_type}): {e}");
379                Err(LighterWsError::Client(format!(
380                    "failed to serialize Lighter sendTx: {e}"
381                )))
382            }
383        }
384    }
385
386    async fn dispatch_unsubscribe(&self, channel: LighterWsChannel) {
387        let topic = channel.topic_key();
388        self.subscriptions.mark_unsubscribe(&topic);
389
390        let request = LighterWsRequest::unsubscribe(channel.subscription_channel());
391        match serde_json::to_string(&request) {
392            Ok(payload) => {
393                log::debug!("Sending Lighter unsubscribe payload: {payload}");
394                if let Err(e) = self.send_with_retry(payload).await {
395                    log::error!("Error unsubscribing from {topic}: {e}");
396                }
397            }
398            Err(e) => {
399                log::error!("Error serializing unsubscription for {topic}: {e}");
400            }
401        }
402    }
403
404    pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
405        if let Some(msg) = self.pending_messages.pop_front() {
406            return Some(msg);
407        }
408
409        loop {
410            // Pump each iteration: the select below drains acks that free slots
411            self.pump_pending_subscribes().await;
412
413            tokio::select! {
414                Some(cmd) = self.cmd_rx.recv() => {
415                    match cmd {
416                        HandlerCommand::SetClient(client) => {
417                            log::debug!("Setting WebSocket client in Lighter handler");
418                            self.inner = Some(client);
419                        }
420                        HandlerCommand::Disconnect => {
421                            log::debug!("Lighter handler received disconnect");
422                            if let Some(ref client) = self.inner {
423                                client.disconnect().await;
424                            }
425                            self.signal.store(true, Ordering::SeqCst);
426                            return None;
427                        }
428                        HandlerCommand::Subscribe { channel, auth } => {
429                            self.pending_subs.push_back((channel, auth));
430                        }
431                        HandlerCommand::Unsubscribe { channel } => {
432                            // Drop a queued-but-unsent subscribe for this channel so a freed
433                            // slot cannot resubscribe after the caller unsubscribed.
434                            let topic = channel.topic_key();
435                            self.pending_subs.retain(|(queued, _)| queued.topic_key() != topic);
436
437                            if let LighterWsChannel::OrderBook(market_index) = &channel {
438                                self.book_snapshots_seen.remove(market_index);
439                                self.book_states.remove(market_index);
440                            }
441                            self.dispatch_unsubscribe(channel).await;
442                        }
443                        HandlerCommand::ResubscribeOrderBook { market_index } => {
444                            self.resubscribe_order_book_stream(market_index).await;
445                        }
446                        HandlerCommand::InitializeInstruments(instruments) => {
447                            self.instruments.clear();
448                            for (market_index, inst) in instruments {
449                                self.instruments.insert(market_index, inst);
450                            }
451                        }
452                        HandlerCommand::UpdateInstrument { market_index, instrument } => {
453                            self.instruments.insert(market_index, instrument);
454                        }
455                        HandlerCommand::SetBookDeltasSub { market_index, subscribed } => {
456                            if subscribed {
457                                let inserted = self.book_delta_subs.insert(market_index);
458                                if inserted
459                                    && let Some(first) = self
460                                        .emit_cached_order_book_deltas_snapshot(market_index)
461                                {
462                                    return Some(first);
463                                }
464                            } else {
465                                self.book_delta_subs.remove(&market_index);
466                            }
467                        }
468                        HandlerCommand::SetDepth10Sub { market_index, subscribed } => {
469                            if subscribed {
470                                let inserted = self.book_depth_10_subs.insert(market_index);
471                                if inserted
472                                    && let Some(first) =
473                                        self.emit_cached_order_book_depth10_snapshot(market_index)
474                                {
475                                    return Some(first);
476                                }
477                            } else {
478                                self.book_depth_10_subs.remove(&market_index);
479                            }
480                        }
481                        HandlerCommand::SetExecutionContext { account_id, account_index } => {
482                            self.exec_account = Some((account_id, account_index));
483                        }
484                        HandlerCommand::SendTx {
485                            tx_type,
486                            tx_info,
487                            response_tx,
488                        } => {
489                            let result = self.dispatch_send_tx(tx_type, tx_info).await;
490                            if response_tx.send(result).is_err() {
491                                log::debug!("Lighter sendTx result receiver dropped");
492                            }
493                        }
494                    }
495                }
496                Some(raw_msg) = self.raw_rx.recv() => {
497                    match raw_msg {
498                        Message::Text(text) => {
499                            if text == RECONNECTED {
500                                log::debug!("Received Lighter WebSocket RECONNECTED sentinel");
501                                self.book_snapshots_seen.clear();
502                                self.book_states.clear();
503                                // Resubscribe replays a fresh `subscribed/candle`; pre-disconnect cache is stale.
504                                self.last_candles.clear();
505                                // The new socket starts with zero inflight and restore_subscriptions
506                                // re-queues every topic, so drop stale gate state to avoid leaking slots.
507                                self.pending_subs.clear();
508                                self.inflight_subs.clear();
509                                self.account_state_reconciler.reset();
510                                return Some(NautilusWsMessage::Reconnected);
511                            }
512
513                            let ts_init = self.clock.get_time_ns();
514
515                            if let Ok(frame) = serde_json::from_str::<LighterWsFrame>(&text) {
516                                let messages = self.handle_frame(frame, ts_init);
517                                if let Some(first) = self.dispatch_results(messages) {
518                                    return Some(first);
519                                }
520                            } else {
521                                let (matched, msg) = self.handle_control_text(&text);
522                                if let Some(first) = msg {
523                                    return Some(first);
524                                }
525
526                                if !matched {
527                                    // Neither a typed frame nor a recognized
528                                    // control type. Surface raw so venue
529                                    // errors (bad signature, margin rejection,
530                                    // ...) don't drop silently.
531                                    if let Ok(value) =
532                                        serde_json::from_str::<serde_json::Value>(&text)
533                                    {
534                                        log::warn!("Lighter WS unparsed frame: {value}");
535                                        return Some(NautilusWsMessage::Raw(value));
536                                    }
537                                    log::warn!("Lighter WS non-JSON text: {text}");
538                                }
539                            }
540                        }
541                        Message::Ping(data) => {
542                            if let Some(ref client) = self.inner
543                                && let Err(e) = client.send_pong(data.to_vec()).await {
544                                log::error!("Error sending Lighter pong: {e}");
545                            }
546                        }
547                        Message::Close(frame) => {
548                            log::debug!("Received Lighter WebSocket close frame: {frame:?}");
549                            return None;
550                        }
551                        _ => {}
552                    }
553                }
554                else => {
555                    log::debug!("Lighter handler shutting down: stream ended or command channel closed");
556                    return None;
557                }
558            }
559        }
560    }
561
562    fn dispatch_results(
563        &mut self,
564        mut messages: Vec<NautilusWsMessage>,
565    ) -> Option<NautilusWsMessage> {
566        if messages.is_empty() {
567            return None;
568        }
569        let first = messages.remove(0);
570        for extra in messages {
571            self.pending_messages.push_back(extra);
572        }
573        Some(first)
574    }
575
576    // Non-blocking count check, not a permit await: this task also drains the
577    // acks that free slots, so awaiting a permit here would deadlock.
578    async fn pump_pending_subscribes(&mut self) {
579        while self.inflight_subs.len() < SUBSCRIBE_INFLIGHT_MAX {
580            let Some((channel, auth)) = self.pending_subs.pop_front() else {
581                break;
582            };
583            let topic = Ustr::from(channel.topic_key().as_str());
584            self.inflight_subs.insert(topic);
585            if !self.dispatch_subscribe(channel, auth).await {
586                self.inflight_subs.remove(&topic);
587            }
588        }
589    }
590
591    // True only on the first ack for a topic; handle_frame calls this per frame,
592    // so later frames for an already-confirmed topic are no-ops.
593    fn release_subscribe_inflight(&mut self, topic: &str) -> bool {
594        self.inflight_subs.remove(&Ustr::from(topic))
595    }
596
597    /// Returns `(matched, msg)` where `matched=true` means the frame's
598    /// `type` was recognized as a known control type (whether or not a
599    /// message is emitted); `matched=false` lets the caller surface the
600    /// raw frame so venue errors with unknown shapes aren't lost.
601    fn handle_control_text(&mut self, text: &str) -> (bool, Option<NautilusWsMessage>) {
602        let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
603            return (false, None);
604        };
605        let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
606
607        match kind {
608            CTRL_TYPE_CONNECTED => {
609                log::debug!("Lighter WebSocket handshake complete");
610                (true, None)
611            }
612            CTRL_TYPE_PING | CTRL_TYPE_PONG => (true, None),
613            CTRL_TYPE_SEND_TX => {
614                let raw_code = value.get("code").and_then(|v| v.as_u64());
615                match raw_code {
616                    Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) => {
617                        log_integrator_not_approved();
618                        (
619                            true,
620                            Some(send_tx_rejected_from_value(
621                                &value,
622                                SendTxRejectionSource::Ack,
623                            )),
624                        )
625                    }
626                    Some(code) if code != 200 => {
627                        log::error!("Lighter sendTx rejected: {value}");
628                        (
629                            true,
630                            Some(send_tx_rejected_from_value(
631                                &value,
632                                SendTxRejectionSource::Ack,
633                            )),
634                        )
635                    }
636                    _ => {
637                        log::debug!("Lighter WebSocket sendTx ack: {value}");
638                        let tx_hash = value
639                            .get("tx_hash")
640                            .and_then(|v| v.as_str())
641                            .map(str::to_string);
642                        (
643                            true,
644                            Some(NautilusWsMessage::SendTxAck { tx_hash, code: 200 }),
645                        )
646                    }
647                }
648            }
649            CTRL_TYPE_SUBSCRIBED | CTRL_TYPE_UNSUBSCRIBED => {
650                if let Some(topic) = value.get("channel").and_then(|v| v.as_str()) {
651                    if kind == CTRL_TYPE_SUBSCRIBED {
652                        self.subscriptions.confirm_subscribe(topic);
653                        self.release_subscribe_inflight(topic);
654                    } else {
655                        let was_pending_unsubscribe = self
656                            .subscriptions
657                            .pending_unsubscribe_topics()
658                            .iter()
659                            .any(|pending| pending == topic);
660                        self.subscriptions.confirm_unsubscribe(topic);
661
662                        if was_pending_unsubscribe {
663                            // Only matched unsubscribe ACKs should reset stream state
664                            if let Some(market_index) = order_book_market_index_from_topic(topic) {
665                                self.clear_cached_order_book(market_index);
666                            }
667
668                            if let Some(key) = candle_market_and_resolution_from_topic(topic) {
669                                self.last_candles.remove(&key);
670                            }
671                        }
672                    }
673                }
674                (true, None)
675            }
676            CTRL_TYPE_ERROR => {
677                let code = value.get("code").and_then(|v| v.as_u64());
678                if code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
679                    log_integrator_not_approved();
680                } else {
681                    log::warn!("Lighter WebSocket error frame: {value}");
682                }
683
684                if is_sendtx_error_code(code) {
685                    (
686                        true,
687                        Some(send_tx_rejected_from_value(
688                            &value,
689                            SendTxRejectionSource::BareError,
690                        )),
691                    )
692                } else {
693                    (true, None)
694                }
695            }
696            _ => {
697                if let Some(error) = value.get("error") {
698                    let nested_code = error.get("code").and_then(|v| v.as_u64());
699                    if nested_code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
700                        log_integrator_not_approved();
701                    } else {
702                        log::warn!("Lighter WebSocket error frame: {value}");
703                    }
704                    let rejected = is_sendtx_error_code(nested_code).then(|| {
705                        send_tx_rejected_from_nested_error(error, SendTxRejectionSource::BareError)
706                    });
707                    return (true, rejected);
708                }
709                (false, None)
710            }
711        }
712    }
713
714    fn handle_frame(
715        &mut self,
716        frame: LighterWsFrame,
717        ts_init: UnixNanos,
718    ) -> Vec<NautilusWsMessage> {
719        let topic = frame_topic(&frame);
720        self.subscriptions.confirm_subscribe(&topic);
721        if !self.inflight_subs.is_empty() {
722            self.release_subscribe_inflight(&topic);
723        }
724
725        match frame {
726            LighterWsFrame::OrderBookSnapshot {
727                channel,
728                order_book,
729                timestamp,
730                ..
731            } => self.handle_order_book(channel, &order_book, timestamp, true, ts_init),
732            LighterWsFrame::OrderBook {
733                channel,
734                order_book,
735                timestamp,
736                ..
737            } => self.handle_order_book(channel, &order_book, timestamp, false, ts_init),
738            LighterWsFrame::TickerSnapshot {
739                channel,
740                ticker,
741                timestamp,
742                ..
743            }
744            | LighterWsFrame::Ticker {
745                channel,
746                ticker,
747                timestamp,
748                ..
749            } => self.handle_ticker(channel, &ticker, timestamp, ts_init),
750            LighterWsFrame::TradeSnapshot {
751                trades,
752                liquidation_trades,
753                ..
754            }
755            | LighterWsFrame::Trade {
756                trades,
757                liquidation_trades,
758                ..
759            } => self.handle_trades(&trades, &liquidation_trades, ts_init),
760            LighterWsFrame::AccountOrders { ref orders, .. }
761            | LighterWsFrame::AccountAllOrders { ref orders, .. } => {
762                if self.exec_account.is_none() {
763                    return raw_message(&frame);
764                }
765                let mut msgs = self.handle_account_orders(orders, ts_init);
766                msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
767                    AccountStream::Orders,
768                ));
769                msgs
770            }
771            // Lighter publishes the historical fill replay as `subscribed/`
772            // rather than `update/`. When typed execution routing is active
773            // we drop the snapshot to avoid re-emitting prior executions as
774            // fresh fill reports on every reconnect (HTTP reconciliation
775            // owns historical state recovery). When no execution context is
776            // set we still forward as `Raw` to preserve the prior contract
777            // for unauthenticated subscribers.
778            LighterWsFrame::AccountAllTradesSnapshot { .. } => {
779                if self.exec_account.is_none() {
780                    return raw_message(&frame);
781                }
782                log::debug!(
783                    "Skipping Lighter account_all_trades snapshot frame; \
784                     reconcile historical fills via HTTP",
785                );
786                vec![NautilusWsMessage::AccountStreamFirstFrame(
787                    AccountStream::Trades,
788                )]
789            }
790            LighterWsFrame::AccountAllTrades { ref trades, .. } => {
791                if self.exec_account.is_none() {
792                    return raw_message(&frame);
793                }
794                let flat: Vec<LighterTrade> = trades.values().flatten().cloned().collect();
795                let mut msgs = self.handle_account_trades(&flat, ts_init);
796                msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
797                    AccountStream::Trades,
798                ));
799                msgs
800            }
801            LighterWsFrame::AccountAllPositions { ref positions, .. } => {
802                if self.exec_account.is_none() {
803                    return raw_message(&frame);
804                }
805                let mut msgs = self.handle_account_positions(positions, ts_init);
806                msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
807                    AccountStream::Positions,
808                ));
809                msgs
810            }
811            LighterWsFrame::AccountAllAssets {
812                ref assets,
813                timestamp,
814                ..
815            } => {
816                if self.exec_account.is_none() {
817                    return raw_message(&frame);
818                }
819                let mut msgs = self.handle_account_assets(assets, timestamp, ts_init);
820                msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
821                    AccountStream::Assets,
822                ));
823                msgs
824            }
825            LighterWsFrame::UserStats {
826                ref stats,
827                timestamp,
828                ..
829            } => {
830                if self.exec_account.is_none() {
831                    return raw_message(&frame);
832                }
833                let mut msgs = self.handle_user_stats(stats, timestamp, ts_init);
834                msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
835                    AccountStream::UserStats,
836                ));
837                msgs
838            }
839            LighterWsFrame::MarketStats {
840                ref market_stats,
841                timestamp,
842                ..
843            } => self.handle_market_stats(market_stats, timestamp, ts_init),
844            LighterWsFrame::SpotMarketStats {
845                ref spot_market_stats,
846                timestamp,
847                ..
848            } => self.handle_spot_market_stats(spot_market_stats, timestamp, ts_init),
849            LighterWsFrame::CandleSnapshot {
850                channel,
851                ref candles,
852                ..
853            }
854            | LighterWsFrame::Candle {
855                channel,
856                ref candles,
857                ..
858            } => self.handle_candles(channel, candles, ts_init),
859            LighterWsFrame::Height { .. } => raw_message(&frame),
860        }
861    }
862
863    fn handle_order_book(
864        &mut self,
865        channel: Ustr,
866        book: &super::messages::LighterWsOrderBook,
867        timestamp: u64,
868        is_snapshot: bool,
869        ts_init: UnixNanos,
870    ) -> Vec<NautilusWsMessage> {
871        let market_index = match market_index_from_topic(channel.as_str()) {
872            Some(index) => index,
873            None => {
874                log::debug!("Lighter order_book frame missing market index in channel '{channel}'");
875                return Vec::new();
876            }
877        };
878
879        if !self.instruments.contains_key(&market_index) {
880            log::debug!("No instrument cached for Lighter market_index={market_index}");
881            return Vec::new();
882        }
883
884        if !self.book_delta_subs.contains(&market_index)
885            && !self.book_depth_10_subs.contains(&market_index)
886        {
887            return Vec::new();
888        }
889
890        // The venue tags the initial book as `subscribed/order_book` and follows
891        // up with `update/order_book` for incrementals. An incremental cannot
892        // seed the book because it only carries changed levels.
893        if !is_snapshot && !self.book_snapshots_seen.contains(&market_index) {
894            log::warn!(
895                "Dropping Lighter order_book update before snapshot for market_index={market_index}",
896            );
897            return Vec::new();
898        }
899
900        if is_snapshot {
901            self.book_snapshots_seen.insert(market_index);
902            self.book_states.insert(
903                market_index,
904                CachedOrderBook {
905                    book: book.clone(),
906                    timestamp,
907                },
908            );
909        } else if let Some(cached_nonce) = self
910            .book_states
911            .get(&market_index)
912            .map(|state| state.book.nonce)
913        {
914            if book.begin_nonce != cached_nonce {
915                log::warn!(
916                    "Dropping Lighter order_book update with nonce gap for \
917                     market_index={market_index}: begin_nonce={}, cached_nonce={cached_nonce}",
918                    book.begin_nonce,
919                );
920                self.clear_cached_order_book(market_index);
921                self.queue_order_book_resync(market_index);
922                return Vec::new();
923            }
924
925            if let Some(state) = self.book_states.get_mut(&market_index) {
926                apply_order_book_update(&mut state.book, book);
927                state.timestamp = timestamp;
928            }
929        } else {
930            log::warn!(
931                "Dropping Lighter order_book update without cached state for \
932                 market_index={market_index}",
933            );
934            self.clear_cached_order_book(market_index);
935            self.queue_order_book_resync(market_index);
936            return Vec::new();
937        }
938
939        self.order_book_messages(market_index, book, timestamp, is_snapshot, ts_init)
940    }
941
942    fn clear_cached_order_book(&mut self, market_index: i16) {
943        self.book_snapshots_seen.remove(&market_index);
944        self.book_states.remove(&market_index);
945    }
946
947    fn queue_order_book_resync(&self, market_index: i16) {
948        if !self.order_book_stream_is_referenced(market_index) {
949            log::debug!(
950                "Skipping Lighter order_book resync: subscription cancelled, \
951                 market_index={market_index}",
952            );
953            return;
954        }
955
956        let Some(cmd_tx) = &self.cmd_tx else {
957            log::error!(
958                "Cannot resync Lighter order_book stream without command sender: \
959                 market_index={market_index}",
960            );
961            return;
962        };
963
964        if let Err(e) = cmd_tx.send(HandlerCommand::ResubscribeOrderBook { market_index }) {
965            log::error!("Failed to queue Lighter order_book resync: {e}");
966        }
967    }
968
969    async fn resubscribe_order_book_stream(&self, market_index: i16) {
970        if !self.order_book_stream_is_referenced(market_index) {
971            log::debug!(
972                "Skipping Lighter order_book resync: subscription cancelled before venue \
973                 unsubscribe, market_index={market_index}",
974            );
975            return;
976        }
977
978        let channel = LighterWsChannel::OrderBook(market_index);
979        self.dispatch_unsubscribe(channel.clone()).await;
980
981        if !self.order_book_stream_is_referenced(market_index) {
982            log::debug!(
983                "Skipping Lighter order_book resubscribe: subscription cancelled after venue \
984                 unsubscribe, market_index={market_index}",
985            );
986            return;
987        }
988
989        self.dispatch_subscribe(channel.clone(), None).await;
990
991        if !self.order_book_stream_is_referenced(market_index) {
992            log::debug!(
993                "Cancelling Lighter order_book resync subscribe after user unsubscribe: \
994                 market_index={market_index}",
995            );
996            self.dispatch_unsubscribe(channel).await;
997        }
998    }
999
1000    fn order_book_stream_is_referenced(&self, market_index: i16) -> bool {
1001        let channel = LighterWsChannel::OrderBook(market_index);
1002        self.subscriptions.get_reference_count(&channel.topic_key()) > 0
1003            && (self.book_delta_subs.contains(&market_index)
1004                || self.book_depth_10_subs.contains(&market_index))
1005    }
1006
1007    fn emit_cached_order_book_deltas_snapshot(
1008        &self,
1009        market_index: i16,
1010    ) -> Option<NautilusWsMessage> {
1011        let cached = self.book_states.get(&market_index)?.clone();
1012        let instrument = self.instruments.get(&market_index)?;
1013        let ts_init = self.clock.get_time_ns();
1014        match parse_ws_order_book_deltas(&cached.book, instrument, cached.timestamp, true, ts_init)
1015        {
1016            Ok(deltas) => Some(NautilusWsMessage::Deltas(deltas)),
1017            Err(e) => {
1018                log::error!("Error parsing cached Lighter order_book deltas: {e}");
1019                None
1020            }
1021        }
1022    }
1023
1024    fn emit_cached_order_book_depth10_snapshot(
1025        &self,
1026        market_index: i16,
1027    ) -> Option<NautilusWsMessage> {
1028        let cached = self.book_states.get(&market_index)?.clone();
1029        let instrument = self.instruments.get(&market_index)?;
1030        let ts_init = self.clock.get_time_ns();
1031        match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1032            Ok(depth) => Some(NautilusWsMessage::Depth10(Box::new(depth))),
1033            Err(e) => {
1034                log::error!("Error parsing cached Lighter order_book depth10: {e}");
1035                None
1036            }
1037        }
1038    }
1039
1040    fn order_book_messages(
1041        &self,
1042        market_index: i16,
1043        book: &LighterWsOrderBook,
1044        timestamp: u64,
1045        is_snapshot: bool,
1046        ts_init: UnixNanos,
1047    ) -> Vec<NautilusWsMessage> {
1048        let Some(instrument) = self.instruments.get(&market_index) else {
1049            log::debug!("No instrument cached for Lighter market_index={market_index}");
1050            return Vec::new();
1051        };
1052
1053        let mut messages = Vec::new();
1054
1055        if self.book_delta_subs.contains(&market_index) {
1056            match parse_ws_order_book_deltas(book, instrument, timestamp, is_snapshot, ts_init) {
1057                Ok(deltas) => messages.push(NautilusWsMessage::Deltas(deltas)),
1058                Err(e) => log::error!("Error parsing Lighter order_book deltas: {e}"),
1059            }
1060        }
1061
1062        if self.book_depth_10_subs.contains(&market_index)
1063            && let Some(cached) = self.book_states.get(&market_index)
1064        {
1065            match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1066                Ok(depth) => messages.push(NautilusWsMessage::Depth10(Box::new(depth))),
1067                Err(e) => log::error!("Error parsing Lighter order_book depth10: {e}"),
1068            }
1069        }
1070
1071        messages
1072    }
1073
1074    fn handle_ticker(
1075        &self,
1076        channel: Ustr,
1077        ticker: &super::messages::LighterTicker,
1078        timestamp: u64,
1079        ts_init: UnixNanos,
1080    ) -> Vec<NautilusWsMessage> {
1081        // Resolve the instrument from the channel's `ticker:N` market index,
1082        // not the ticker payload's `s` symbol. Lighter sends the unsuffixed
1083        // venue symbol (e.g. "ETH"), while cached `raw_symbol` carries the
1084        // -PERP / -SPOT suffix, so a symbol-name match never resolves and
1085        // would conflate spot vs perp listings of the same asset.
1086        let Some(market_index) = market_index_from_topic(channel.as_str()) else {
1087            log::debug!("Lighter ticker frame missing market index in channel '{channel}'");
1088            return Vec::new();
1089        };
1090
1091        let Some(instrument) = self.instruments.get(&market_index) else {
1092            log::debug!("No instrument cached for Lighter ticker market_index={market_index}");
1093            return Vec::new();
1094        };
1095
1096        match parse_ws_quote_tick(ticker, instrument, timestamp, ts_init) {
1097            Ok(Some(quote)) => vec![NautilusWsMessage::Quote(quote)],
1098            Ok(None) => {
1099                log::debug!(
1100                    "Skipping Lighter ticker for market_index={market_index}: one-sided book",
1101                );
1102                Vec::new()
1103            }
1104            Err(e) => {
1105                log::error!("Error parsing Lighter ticker frame: {e}");
1106                Vec::new()
1107            }
1108        }
1109    }
1110
1111    fn handle_trades(
1112        &self,
1113        trades: &[crate::http::models::LighterTrade],
1114        liquidation_trades: &[crate::http::models::LighterTrade],
1115        ts_init: UnixNanos,
1116    ) -> Vec<NautilusWsMessage> {
1117        // Lighter splits trade prints across `trades` and `liquidation_trades`
1118        // on the same `update/trade` frame; both share the LighterTrade model
1119        // and are public executions, so emit them together.
1120        let Some(market_index) = trades
1121            .first()
1122            .or_else(|| liquidation_trades.first())
1123            .map(|t| t.market_id)
1124        else {
1125            return Vec::new();
1126        };
1127
1128        let Some(instrument) = self.instruments.get(&market_index) else {
1129            log::debug!("No instrument cached for Lighter trade market_index={market_index}");
1130            return Vec::new();
1131        };
1132
1133        let mut ticks = Vec::with_capacity(trades.len() + liquidation_trades.len());
1134        for trade in trades.iter().chain(liquidation_trades.iter()) {
1135            match parse_ws_trade_tick(trade, instrument, ts_init) {
1136                Ok(tick) => ticks.push(tick),
1137                Err(e) => log::error!("Error parsing Lighter trade tick: {e}"),
1138            }
1139        }
1140
1141        if ticks.is_empty() {
1142            Vec::new()
1143        } else {
1144            vec![NautilusWsMessage::Trades(ticks)]
1145        }
1146    }
1147
1148    fn handle_market_stats(
1149        &self,
1150        payload: &super::messages::LighterMarketStatsPayload,
1151        timestamp: u64,
1152        ts_init: UnixNanos,
1153    ) -> Vec<NautilusWsMessage> {
1154        match payload {
1155            super::messages::LighterMarketStatsPayload::All(stats) => stats
1156                .values()
1157                .flat_map(|stats| self.handle_one_market_stats(stats, timestamp, ts_init))
1158                .collect(),
1159            super::messages::LighterMarketStatsPayload::One(stats) => {
1160                self.handle_one_market_stats(stats, timestamp, ts_init)
1161            }
1162        }
1163    }
1164
1165    fn handle_one_market_stats(
1166        &self,
1167        stats: &super::messages::LighterMarketStats,
1168        timestamp: u64,
1169        ts_init: UnixNanos,
1170    ) -> Vec<NautilusWsMessage> {
1171        let Some(instrument) = self.instruments.get(&stats.market_id) else {
1172            log::debug!(
1173                "No instrument cached for Lighter market_stats market_id={}",
1174                stats.market_id,
1175            );
1176            return Vec::new();
1177        };
1178
1179        let mut messages = Vec::with_capacity(3);
1180
1181        match parse_ws_mark_price_update(stats, instrument, timestamp, ts_init) {
1182            Ok(mark_price) => messages.push(NautilusWsMessage::MarkPrice(mark_price)),
1183            Err(e) => log::error!("Error parsing Lighter mark price: {e}"),
1184        }
1185
1186        match parse_ws_index_price_update(stats, instrument, timestamp, ts_init) {
1187            Ok(index_price) => messages.push(NautilusWsMessage::IndexPrice(index_price)),
1188            Err(e) => log::error!("Error parsing Lighter index price: {e}"),
1189        }
1190
1191        match parse_ws_funding_rate_update(stats, instrument, timestamp, ts_init) {
1192            Ok(funding_rate) => messages.push(NautilusWsMessage::FundingRate(funding_rate)),
1193            Err(e) => log::error!("Error parsing Lighter funding rate: {e}"),
1194        }
1195
1196        messages
1197    }
1198
1199    fn handle_spot_market_stats(
1200        &self,
1201        payload: &super::messages::LighterSpotMarketStatsPayload,
1202        timestamp: u64,
1203        ts_init: UnixNanos,
1204    ) -> Vec<NautilusWsMessage> {
1205        match payload {
1206            super::messages::LighterSpotMarketStatsPayload::All(stats) => stats
1207                .values()
1208                .filter_map(|stats| self.handle_one_spot_market_stats(stats, timestamp, ts_init))
1209                .collect(),
1210            super::messages::LighterSpotMarketStatsPayload::One(stats) => self
1211                .handle_one_spot_market_stats(stats, timestamp, ts_init)
1212                .into_iter()
1213                .collect(),
1214        }
1215    }
1216
1217    fn handle_one_spot_market_stats(
1218        &self,
1219        stats: &super::messages::LighterSpotMarketStats,
1220        timestamp: u64,
1221        ts_init: UnixNanos,
1222    ) -> Option<NautilusWsMessage> {
1223        let Some(instrument) = self.instruments.get(&stats.market_id) else {
1224            log::debug!(
1225                "No instrument cached for Lighter spot_market_stats market_id={}",
1226                stats.market_id,
1227            );
1228            return None;
1229        };
1230
1231        match parse_ws_spot_index_price_update(stats, instrument, timestamp, ts_init) {
1232            Ok(index_price) => Some(NautilusWsMessage::IndexPrice(index_price)),
1233            Err(e) => {
1234                log::error!("Error parsing Lighter spot index price: {e}");
1235                None
1236            }
1237        }
1238    }
1239
1240    fn handle_candles(
1241        &mut self,
1242        channel: Ustr,
1243        candles: &[LighterWsCandle],
1244        ts_init: UnixNanos,
1245    ) -> Vec<NautilusWsMessage> {
1246        let Some((market_index, resolution)) =
1247            candle_market_and_resolution_from_topic(channel.as_str())
1248        else {
1249            log::warn!("Lighter candle frame with unparsable channel `{channel}`");
1250            return Vec::new();
1251        };
1252
1253        let Some(instrument) = self.instruments.get(&market_index) else {
1254            log::debug!("No instrument cached for Lighter candle market_index={market_index}");
1255            return Vec::new();
1256        };
1257
1258        let key = (market_index, resolution);
1259        let mut emitted = Vec::new();
1260
1261        for candle in candles {
1262            let previous = self.last_candles.get(&key).cloned();
1263            match previous {
1264                None => {}
1265                Some(prev) if candle.t > prev.t => {
1266                    // `t` advanced; previous candle is closed.
1267                    match parse_ws_bar(instrument, &prev, resolution, ts_init) {
1268                        Ok(bar) => emitted.push(NautilusWsMessage::Bar(bar)),
1269                        Err(e) => log::error!("Error parsing Lighter candle bar: {e}"),
1270                    }
1271                }
1272                Some(prev) if candle.t < prev.t => continue,
1273                Some(_) => {}
1274            }
1275            self.last_candles.insert(key, candle.clone());
1276        }
1277
1278        emitted
1279    }
1280
1281    fn handle_account_orders(
1282        &self,
1283        orders_by_market: &AHashMap<Ustr, Vec<LighterOrder>>,
1284        _ts_init: UnixNanos,
1285    ) -> Vec<NautilusWsMessage> {
1286        if self.exec_account.is_none() {
1287            log::debug!("Lighter account_orders frame skipped: no execution context set");
1288            return Vec::new();
1289        }
1290
1291        let mut reports = Vec::new();
1292
1293        for orders in orders_by_market.values() {
1294            for order in orders {
1295                if !self.instruments.contains_key(&order.market_index) {
1296                    log::debug!(
1297                        "No instrument cached for Lighter order market_index={}",
1298                        order.market_index,
1299                    );
1300                    continue;
1301                }
1302
1303                reports.push(ExecutionReport::Order(order.clone()));
1304            }
1305        }
1306
1307        if reports.is_empty() {
1308            Vec::new()
1309        } else {
1310            vec![NautilusWsMessage::ExecutionReports(reports)]
1311        }
1312    }
1313
1314    fn handle_account_trades(
1315        &self,
1316        trades: &[LighterTrade],
1317        _ts_init: UnixNanos,
1318    ) -> Vec<NautilusWsMessage> {
1319        let Some((_account_id, account_index)) = self.exec_account else {
1320            log::debug!("Lighter account_trades frame skipped: no execution context set");
1321            return Vec::new();
1322        };
1323
1324        let mut reports = Vec::new();
1325
1326        for trade in trades {
1327            if !self.instruments.contains_key(&trade.market_id) {
1328                log::debug!(
1329                    "No instrument cached for Lighter account trade market_id={}",
1330                    trade.market_id,
1331                );
1332                continue;
1333            }
1334
1335            // The venue shares the channel with crossed pairs the account is
1336            // not part of; gate the cheap account check at the handler so
1337            // unrelated traffic never reaches the execution consumer.
1338            if trade.bid_account_id != account_index && trade.ask_account_id != account_index {
1339                continue;
1340            }
1341
1342            reports.push(ExecutionReport::Fill(trade.clone()));
1343        }
1344
1345        if reports.is_empty() {
1346            Vec::new()
1347        } else {
1348            vec![NautilusWsMessage::ExecutionReports(reports)]
1349        }
1350    }
1351
1352    fn handle_account_positions(
1353        &self,
1354        positions: &AHashMap<Ustr, LighterPosition>,
1355        ts_init: UnixNanos,
1356    ) -> Vec<NautilusWsMessage> {
1357        let Some((account_id, _)) = self.exec_account else {
1358            log::debug!("Lighter account_positions frame skipped: no execution context set");
1359            return Vec::new();
1360        };
1361
1362        // The position frame carries no top-level event timestamp, so stamp
1363        // ts_event with the wall-clock time the handler observed the frame.
1364        let ts_event = ts_init;
1365
1366        let mut reports = Vec::new();
1367        let mut skipped_market_ids = Vec::new();
1368
1369        for position in positions.values() {
1370            let Some(instrument) = self.instruments.get(&position.market_id) else {
1371                log::debug!(
1372                    "No instrument cached for Lighter position market_id={}",
1373                    position.market_id,
1374                );
1375                skipped_market_ids.push(position.market_id);
1376                continue;
1377            };
1378
1379            match parse_ws_position_status_report(
1380                position, instrument, account_id, ts_event, ts_init,
1381            ) {
1382                Ok(report) => reports.push(report),
1383                Err(e) => {
1384                    skipped_market_ids.push(position.market_id);
1385                    log::error!("Error parsing Lighter position status report: {e}");
1386                }
1387            }
1388        }
1389
1390        // Emit even when empty: signals the last position closed.
1391        vec![NautilusWsMessage::PositionSnapshot {
1392            reports,
1393            skipped_market_ids,
1394        }]
1395    }
1396
1397    fn handle_account_assets(
1398        &self,
1399        assets: &AHashMap<Ustr, LighterAsset>,
1400        timestamp_ms: u64,
1401        ts_init: UnixNanos,
1402    ) -> Vec<NautilusWsMessage> {
1403        let Some((account_id, _)) = self.exec_account else {
1404            log::debug!("Lighter account_assets frame skipped: no execution context set");
1405            return Vec::new();
1406        };
1407
1408        let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1409            Ok(ts) => ts,
1410            Err(e) => {
1411                log::error!("Invalid Lighter account_assets timestamp {timestamp_ms}: {e}");
1412                return Vec::new();
1413            }
1414        };
1415
1416        self.account_state_reconciler.update_assets(assets);
1417        self.emit_unified_account_state(account_id, ts_event, ts_init)
1418    }
1419
1420    fn handle_user_stats(
1421        &self,
1422        stats: &LighterUserStats,
1423        timestamp_ms: u64,
1424        ts_init: UnixNanos,
1425    ) -> Vec<NautilusWsMessage> {
1426        let Some((account_id, _)) = self.exec_account else {
1427            log::debug!("Lighter user_stats frame skipped: no execution context set");
1428            return Vec::new();
1429        };
1430
1431        let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1432            Ok(ts) => ts,
1433            Err(e) => {
1434                log::error!("Invalid Lighter user_stats timestamp {timestamp_ms}: {e}");
1435                return Vec::new();
1436            }
1437        };
1438
1439        self.account_state_reconciler.update_user_stats(stats);
1440        self.emit_unified_account_state(account_id, ts_event, ts_init)
1441    }
1442
1443    /// Asks the reconciler for a merged [`AccountState`] and wraps it as a
1444    /// `NautilusWsMessage`. Returns an empty vec when the second stream
1445    /// hasn't arrived yet; the AccountStreamFirstFrame marker still gets
1446    /// pushed by the caller so the startup gate progresses.
1447    fn emit_unified_account_state(
1448        &self,
1449        account_id: AccountId,
1450        ts_event: UnixNanos,
1451        ts_init: UnixNanos,
1452    ) -> Vec<NautilusWsMessage> {
1453        match self
1454            .account_state_reconciler
1455            .build_state(account_id, ts_event, ts_init)
1456        {
1457            Some(Ok(state)) => vec![NautilusWsMessage::AccountState(Box::new(state))],
1458            Some(Err(e)) => {
1459                log::error!("Error building unified Lighter account state: {e}");
1460                Vec::new()
1461            }
1462            None => Vec::new(),
1463        }
1464    }
1465}
1466
1467fn raw_message(frame: &LighterWsFrame) -> Vec<NautilusWsMessage> {
1468    let value = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null);
1469    vec![NautilusWsMessage::Raw(value)]
1470}
1471
1472fn apply_order_book_update(state: &mut LighterWsOrderBook, update: &LighterWsOrderBook) {
1473    apply_book_side_update(&mut state.bids, &update.bids, true);
1474    apply_book_side_update(&mut state.asks, &update.asks, false);
1475
1476    state.code = update.code;
1477    state.offset = update.offset;
1478    state.nonce = update.nonce;
1479    state.last_updated_at = update.last_updated_at;
1480    state.begin_nonce = update.begin_nonce;
1481}
1482
1483fn apply_book_side_update(
1484    levels: &mut Vec<LighterPriceLevel>,
1485    updates: &[LighterPriceLevel],
1486    bids: bool,
1487) {
1488    for update in updates {
1489        if update.price == Decimal::ZERO {
1490            continue;
1491        }
1492
1493        match find_book_level(levels, update.price, bids) {
1494            Ok(index) if update.size == Decimal::ZERO => {
1495                levels.remove(index);
1496            }
1497            Ok(index) => {
1498                levels[index] = update.clone();
1499            }
1500            Err(_) if update.size == Decimal::ZERO => {}
1501            Err(index) => {
1502                levels.insert(index, update.clone());
1503            }
1504        }
1505    }
1506}
1507
1508fn find_book_level(
1509    levels: &[LighterPriceLevel],
1510    price: Decimal,
1511    bids: bool,
1512) -> Result<usize, usize> {
1513    levels.binary_search_by(|level| {
1514        if bids {
1515            price.cmp(&level.price)
1516        } else {
1517            level.price.cmp(&price)
1518        }
1519    })
1520}
1521
1522// Codes outside the transaction range (e.g. 30003 "Already Subscribed")
1523// would falsely reject a live order; see `LIGHTER_ERROR_CODE_TX_RANGE`.
1524fn is_sendtx_error_code(code: Option<u64>) -> bool {
1525    code.is_some_and(|c| LIGHTER_ERROR_CODE_TX_RANGE.contains(&c))
1526}
1527
1528// SendTxRejected from a top-level `{code, message}` frame: non-200 sendTx
1529// ACK or `{"type":"error",...}`.
1530fn send_tx_rejected_from_value(
1531    value: &serde_json::Value,
1532    source: SendTxRejectionSource,
1533) -> NautilusWsMessage {
1534    let code = value.get("code").and_then(|v| v.as_i64());
1535    let message = value
1536        .get("message")
1537        .and_then(|v| v.as_str())
1538        .unwrap_or("")
1539        .to_string();
1540    let tx_hash = value
1541        .get("tx_hash")
1542        .and_then(|v| v.as_str())
1543        .map(str::to_string);
1544    NautilusWsMessage::SendTxRejected {
1545        source,
1546        code,
1547        message,
1548        tx_hash,
1549    }
1550}
1551
1552// SendTxRejected from a nested `{"error":{"code":N,"message":...}}` frame;
1553// these frames never carry a `tx_hash`.
1554fn send_tx_rejected_from_nested_error(
1555    error: &serde_json::Value,
1556    source: SendTxRejectionSource,
1557) -> NautilusWsMessage {
1558    let code = error.get("code").and_then(|v| v.as_i64());
1559    let message = error
1560        .get("message")
1561        .and_then(|v| v.as_str())
1562        .unwrap_or("")
1563        .to_string();
1564    NautilusWsMessage::SendTxRejected {
1565        source,
1566        code,
1567        message,
1568        tx_hash: None,
1569    }
1570}
1571
1572fn log_integrator_not_approved() {
1573    log::error!(
1574        "Lighter venue rejected with code {LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED} \
1575         'integrator is not approved'.\n\
1576         Tagged orders require Nautilus integrator approval. \
1577         See: {LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL}",
1578    );
1579}
1580
1581fn frame_topic(frame: &LighterWsFrame) -> String {
1582    match frame {
1583        LighterWsFrame::OrderBookSnapshot { channel, .. }
1584        | LighterWsFrame::OrderBook { channel, .. }
1585        | LighterWsFrame::TickerSnapshot { channel, .. }
1586        | LighterWsFrame::Ticker { channel, .. }
1587        | LighterWsFrame::MarketStats { channel, .. }
1588        | LighterWsFrame::SpotMarketStats { channel, .. }
1589        | LighterWsFrame::TradeSnapshot { channel, .. }
1590        | LighterWsFrame::Trade { channel, .. }
1591        | LighterWsFrame::AccountOrders { channel, .. }
1592        | LighterWsFrame::AccountAllOrders { channel, .. }
1593        | LighterWsFrame::AccountAllTradesSnapshot { channel, .. }
1594        | LighterWsFrame::AccountAllTrades { channel, .. }
1595        | LighterWsFrame::AccountAllPositions { channel, .. }
1596        | LighterWsFrame::AccountAllAssets { channel, .. }
1597        | LighterWsFrame::UserStats { channel, .. }
1598        | LighterWsFrame::Height { channel, .. }
1599        | LighterWsFrame::CandleSnapshot { channel, .. }
1600        | LighterWsFrame::Candle { channel, .. } => channel.as_str().to_string(),
1601    }
1602}
1603
1604fn market_index_from_topic(topic: &str) -> Option<i16> {
1605    let (_, rest) = topic.split_once(':')?;
1606    rest.parse::<i16>().ok()
1607}
1608
1609fn candle_market_and_resolution_from_topic(topic: &str) -> Option<(i16, LighterCandleResolution)> {
1610    let (channel, rest) = topic.split_once(':')?;
1611    if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::Candle) {
1612        return None;
1613    }
1614    let (market, res) = rest.split_once(':')?;
1615    let market_index = market.parse::<i16>().ok()?;
1616    let resolution = res.parse::<LighterCandleResolution>().ok()?;
1617    Some((market_index, resolution))
1618}
1619
1620fn order_book_market_index_from_topic(topic: &str) -> Option<i16> {
1621    let (channel, rest) = topic.split_once(':')?;
1622    if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::OrderBook) {
1623        return None;
1624    }
1625    rest.parse::<i16>().ok()
1626}
1627
1628pub(crate) fn should_retry_lighter_ws_error(error: &LighterWsError) -> bool {
1629    match error {
1630        LighterWsError::Network(_) => true,
1631        // Closed and BrokenPipe are terminal on this client; only Timeout
1632        // (wait_for_active) can recover if the connection comes up.
1633        LighterWsError::Transport(send_error) => match send_error {
1634            SendError::Timeout => true,
1635            SendError::Closed | SendError::BrokenPipe(_) => false,
1636        },
1637        LighterWsError::Authentication(_)
1638        | LighterWsError::Parse(_)
1639        | LighterWsError::Client(_) => false,
1640    }
1641}
1642
1643pub(crate) fn create_lighter_ws_timeout_error(_msg: String) -> LighterWsError {
1644    // Structured variant so the classifier retries; the retry manager
1645    // already logs the textual timeout context.
1646    LighterWsError::Transport(SendError::Timeout)
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use std::time::Duration;
1652
1653    use nautilus_model::{
1654        enums::AccountType,
1655        identifiers::{InstrumentId, Symbol, Venue},
1656        instruments::{CryptoPerpetual, CurrencyPair},
1657        types::{Currency, Money, Price, Quantity},
1658    };
1659    use rstest::rstest;
1660    use rust_decimal::Decimal;
1661    use serde_json::json;
1662
1663    use super::*;
1664    use crate::{
1665        common::enums::{LighterCandleResolution, LighterTxType},
1666        websocket::messages::{LighterMarketSelection, LighterWsCandle, LighterWsChannel},
1667    };
1668
1669    const WS_ACCOUNT_ORDERS_UPDATE: &str =
1670        include_str!("../../test_data/ws_account_orders_update.json");
1671    const WS_ACCOUNT_ALL_TRADES_UPDATE: &str =
1672        include_str!("../../test_data/ws_account_all_trades_update.json");
1673    const WS_ACCOUNT_ALL_POSITIONS_UPDATE: &str =
1674        include_str!("../../test_data/ws_account_all_positions_update.json");
1675    const WS_ACCOUNT_ALL_ASSETS_UPDATE: &str =
1676        include_str!("../../test_data/ws_account_all_assets_update.json");
1677    const WS_USER_STATS_UPDATE: &str = include_str!("../../test_data/ws_user_stats_update.json");
1678    const WS_ACCOUNT_ALL_ASSETS_WITH_POSITION: &str =
1679        include_str!("../../test_data/ws_account_all_assets_with_position.json");
1680    const WS_USER_STATS_WITH_POSITION: &str =
1681        include_str!("../../test_data/ws_user_stats_with_position.json");
1682    const WS_MARKET_STATS_UPDATE_SINGLE: &str =
1683        include_str!("../../test_data/ws_market_stats_update_single.json");
1684    const WS_MARKET_STATS_UPDATE_ALL: &str =
1685        include_str!("../../test_data/ws_market_stats_update_all.json");
1686    const WS_SPOT_MARKET_STATS_UPDATE_SINGLE: &str =
1687        include_str!("../../test_data/ws_spot_market_stats_update_single.json");
1688    const WS_SPOT_MARKET_STATS_UPDATE_ALL: &str =
1689        include_str!("../../test_data/ws_spot_market_stats_update_all.json");
1690
1691    fn stub_eth_perp_instrument() -> InstrumentAny {
1692        let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
1693        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1694            instrument_id,
1695            Symbol::new("ETH-PERP"),
1696            Currency::from("ETH"),
1697            Currency::from("USDC"),
1698            Currency::from("USDC"),
1699            false,
1700            2,
1701            4,
1702            Price::from("0.01"),
1703            Quantity::from("0.0001"),
1704            None,
1705            None,
1706            None,
1707            None,
1708            None,
1709            None,
1710            None,
1711            None,
1712            None,
1713            None,
1714            None,
1715            None,
1716            None,
1717            None,
1718            UnixNanos::default(),
1719            UnixNanos::default(),
1720        ))
1721    }
1722
1723    fn stub_eth_spot_instrument() -> InstrumentAny {
1724        let instrument_id = InstrumentId::new(Symbol::new("ETH-SPOT"), Venue::new("LIGHTER"));
1725        InstrumentAny::CurrencyPair(CurrencyPair::new(
1726            instrument_id,
1727            Symbol::new("ETH-SPOT"),
1728            Currency::from("ETH"),
1729            Currency::from("USDC"),
1730            2,
1731            4,
1732            Price::from("0.01"),
1733            Quantity::from("0.0001"),
1734            None,
1735            None,
1736            None,
1737            None,
1738            None,
1739            None,
1740            None,
1741            None,
1742            None,
1743            None,
1744            None,
1745            None,
1746            None,
1747            None,
1748            UnixNanos::default(),
1749            UnixNanos::default(),
1750        ))
1751    }
1752
1753    fn make_handler_with_account() -> FeedHandler {
1754        let signal = Arc::new(AtomicBool::new(false));
1755        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1756        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
1757        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
1758        let mut handler =
1759            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
1760        handler.instruments.insert(0, stub_eth_perp_instrument());
1761        handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
1762        handler
1763    }
1764
1765    /// Strip the trailing `AccountStreamFirstFrame` marker from a handler
1766    /// emission so account-stream tests can assert on the typed payload
1767    /// alone. The marker contract is pinned in
1768    /// `handle_frame_emits_account_stream_first_frame_marker_per_variant`.
1769    fn strip_account_marker(mut msgs: Vec<NautilusWsMessage>) -> Vec<NautilusWsMessage> {
1770        if matches!(
1771            msgs.last(),
1772            Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
1773        ) {
1774            msgs.pop();
1775        }
1776        msgs
1777    }
1778
1779    #[rstest]
1780    fn handle_frame_routes_account_orders_to_execution_reports() {
1781        let mut handler = make_handler_with_account();
1782        let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
1783
1784        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1785
1786        assert_eq!(messages.len(), 1);
1787        match &messages[0] {
1788            NautilusWsMessage::ExecutionReports(reports) => {
1789                assert_eq!(reports.len(), 1);
1790                match &reports[0] {
1791                    super::ExecutionReport::Order(order) => {
1792                        assert_eq!(order.order_id, "281476929510110");
1793                        assert_eq!(order.client_order_id, "42");
1794                    }
1795                    other => panic!("expected order report, was {other:?}"),
1796                }
1797            }
1798            other => panic!("expected execution reports, was {other:?}"),
1799        }
1800    }
1801
1802    #[rstest]
1803    fn handle_frame_routes_account_trades_to_execution_reports() {
1804        let mut handler = make_handler_with_account();
1805        let frame: super::LighterWsFrame =
1806            serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
1807
1808        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1809
1810        assert_eq!(messages.len(), 1);
1811        match &messages[0] {
1812            NautilusWsMessage::ExecutionReports(reports) => {
1813                assert_eq!(reports.len(), 1);
1814                match &reports[0] {
1815                    super::ExecutionReport::Fill(fill) => {
1816                        // Bid side is the user's account in this fixture
1817                        // (see handle_frame_routes_account_trades_to_execution_reports
1818                        // fixture: bid_account_id == 1234). The raw payload
1819                        // carries the bid order id as the venue id; the
1820                        // execution loop derives `venue_order_id` from it.
1821                        assert_eq!(fill.bid_id_str.as_deref(), Some("562947905631053"),);
1822                    }
1823                    other => panic!("expected fill report, was {other:?}"),
1824                }
1825            }
1826            other => panic!("expected execution reports, was {other:?}"),
1827        }
1828    }
1829
1830    #[rstest]
1831    fn handle_frame_routes_account_positions_to_position_snapshot() {
1832        let mut handler = make_handler_with_account();
1833        let frame: super::LighterWsFrame =
1834            serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1835
1836        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1837
1838        assert_eq!(messages.len(), 1);
1839        match &messages[0] {
1840            NautilusWsMessage::PositionSnapshot {
1841                reports,
1842                skipped_market_ids,
1843            } => {
1844                assert!(skipped_market_ids.is_empty());
1845                assert_eq!(reports.len(), 1);
1846                assert_eq!(reports[0].quantity, Quantity::from("1.5000"));
1847            }
1848            other => panic!("expected position snapshot, was {other:?}"),
1849        }
1850    }
1851
1852    #[rstest]
1853    fn handle_frame_routes_empty_account_positions_to_empty_snapshot() {
1854        // Empty frame must still emit so the cache clears (last position closed).
1855        let mut handler = make_handler_with_account();
1856        let frame_json = serde_json::json!({
1857            "type": "update/account_all_positions",
1858            "channel": "account_all_positions:1234",
1859            "positions": {},
1860            "shares": [],
1861            "last_funding_round": null,
1862            "last_funding_discount": null,
1863        });
1864        let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1865
1866        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1867
1868        assert_eq!(messages.len(), 1);
1869        match &messages[0] {
1870            NautilusWsMessage::PositionSnapshot {
1871                reports,
1872                skipped_market_ids,
1873            } => {
1874                assert!(skipped_market_ids.is_empty());
1875                assert!(reports.is_empty());
1876            }
1877            other => panic!("expected empty position snapshot, was {other:?}"),
1878        }
1879    }
1880
1881    #[rstest]
1882    fn handle_frame_marks_account_positions_incomplete_when_position_instrument_uncached() {
1883        let mut handler = make_handler_with_account();
1884        let mut frame_json: serde_json::Value =
1885            serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1886        frame_json["positions"]["0"]["market_id"] = json!(999);
1887        let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1888
1889        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1890
1891        assert_eq!(messages.len(), 1);
1892        match &messages[0] {
1893            NautilusWsMessage::PositionSnapshot {
1894                reports,
1895                skipped_market_ids,
1896            } => {
1897                assert_eq!(skipped_market_ids, &[999]);
1898                assert!(reports.is_empty());
1899            }
1900            other => panic!("expected incomplete position snapshot, was {other:?}"),
1901        }
1902    }
1903
1904    #[rstest]
1905    fn handle_frame_marks_account_positions_incomplete_when_position_parse_fails() {
1906        let mut handler = make_handler_with_account();
1907        let mut frame_json: serde_json::Value =
1908            serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1909        frame_json["positions"]["0"]["position"] = json!("-1.5000");
1910        let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1911
1912        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1913
1914        assert_eq!(messages.len(), 1);
1915        match &messages[0] {
1916            NautilusWsMessage::PositionSnapshot {
1917                reports,
1918                skipped_market_ids,
1919            } => {
1920                assert_eq!(skipped_market_ids, &[0]);
1921                assert!(reports.is_empty());
1922            }
1923            other => panic!("expected incomplete position snapshot, was {other:?}"),
1924        }
1925    }
1926
1927    #[rstest]
1928    fn handle_frame_routes_subscribed_account_all_positions_alias() {
1929        // The `subscribed/` initial snapshot must route through the same
1930        // `AccountAllPositions` variant as `update/`, otherwise the
1931        // initial position cache push (e.g. on resubscribe) is silently
1932        // dropped to Raw and the cache stays empty until the first
1933        // `update/` frame.
1934        let mut handler = make_handler_with_account();
1935        let frame_json = serde_json::json!({
1936            "type": "subscribed/account_all_positions",
1937            "channel": "account_all_positions:1234",
1938            "positions": {
1939                "0": {
1940                    "allocated_margin": "0.000000",
1941                    "avg_entry_price": "0.111230",
1942                    "initial_margin_fraction": "10.00",
1943                    "liquidation_price": "0.100598",
1944                    "margin_mode": 0,
1945                    "market_id": 0,
1946                    "open_order_count": 0,
1947                    "pending_order_count": 0,
1948                    "position": "100",
1949                    "position_tied_order_count": 0,
1950                    "position_value": "11.123000",
1951                    "realized_pnl": "0.000000",
1952                    "sign": 1,
1953                    "symbol": "ETH",
1954                    "total_discount": "0.000000",
1955                    "total_funding_paid_out": "0.000000",
1956                    "unrealized_pnl": "0.000000"
1957                }
1958            },
1959        });
1960        let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1961
1962        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1963
1964        assert_eq!(messages.len(), 1);
1965        match &messages[0] {
1966            NautilusWsMessage::PositionSnapshot {
1967                reports,
1968                skipped_market_ids,
1969            } => {
1970                assert!(skipped_market_ids.is_empty());
1971                assert_eq!(reports.len(), 1);
1972                assert_eq!(reports[0].quantity, Quantity::from("100"));
1973            }
1974            other => panic!("expected position snapshot, was {other:?}"),
1975        }
1976    }
1977
1978    #[rstest]
1979    #[case::connected(serde_json::json!({"type": "connected", "session_id": "x"}), true, false)]
1980    #[case::ping(serde_json::json!({"type": "ping"}), true, false)]
1981    #[case::pong(serde_json::json!({"type": "pong"}), true, false)]
1982    #[case::send_tx_ack(
1983        serde_json::json!({"type": "jsonapi/sendtx", "code": 200, "tx_hash": "abc"}),
1984        true,
1985        true,
1986    )]
1987    #[case::error_frame(
1988        serde_json::json!({"type": "error", "code": 21727, "message": "invalid client order index"}),
1989        true,
1990        true,
1991    )]
1992    #[case::error_frame_integrator_not_approved(
1993        serde_json::json!({"type": "error", "code": 21149, "message": "integrator is not approved"}),
1994        true,
1995        true,
1996    )]
1997    #[case::send_tx_ack_integrator_not_approved(
1998        serde_json::json!({"type": "jsonapi/sendtx", "code": 21149, "message": "integrator is not approved"}),
1999        true,
2000        true,
2001    )]
2002    #[case::wrapped_error_integrator_not_approved(
2003        serde_json::json!({"error": {"code": 21149, "message": "integrator is not approved"}}),
2004        true,
2005        true,
2006    )]
2007    #[case::subscription_error_frame(
2008        serde_json::json!({"type": "error", "code": 30003, "message": "Already Subscribed to : ticker:3"}),
2009        true,
2010        false,
2011    )]
2012    #[case::wrapped_subscription_error(
2013        serde_json::json!({"error": {"code": 30003, "message": "Already Subscribed to : ticker:3"}}),
2014        true,
2015        false,
2016    )]
2017    #[case::codeless_error_frame(
2018        serde_json::json!({"type": "error", "message": "unclassifiable"}),
2019        true,
2020        false,
2021    )]
2022    #[case::unknown_type(
2023        serde_json::json!({"type": "something_unexpected", "payload": "x"}),
2024        false,
2025        false,
2026    )]
2027    #[case::no_type_field(
2028        serde_json::json!({"error": {"code": 21702, "message": "invalid price"}}),
2029        true,
2030        true,
2031    )]
2032    fn handle_control_text_tri_state(
2033        #[case] payload: serde_json::Value,
2034        #[case] expected_matched: bool,
2035        #[case] expected_has_msg: bool,
2036    ) {
2037        // Pins the (matched, msg) contract that lets the WS receive loop
2038        // distinguish "known control type, consume silently" from
2039        // "surface a typed/Raw message to the consumer". sendTx success ACKs
2040        // and venue-error frames now produce typed SendTxAck/SendTxRejected
2041        // variants instead of Raw, so the execution loop can correlate them
2042        // back to in-flight cloids.
2043        let mut handler = make_handler_with_account();
2044        let text = payload.to_string();
2045        let (matched, msg) = handler.handle_control_text(&text);
2046        assert_eq!(matched, expected_matched, "matched flag");
2047        assert_eq!(msg.is_some(), expected_has_msg, "msg presence");
2048    }
2049
2050    #[rstest]
2051    fn handle_control_text_sendtx_success_emits_typed_ack() {
2052        let mut handler = make_handler_with_account();
2053        let payload = serde_json::json!({
2054            "type": "jsonapi/sendtx",
2055            "code": 200,
2056            "tx_hash": "0000abcd",
2057        })
2058        .to_string();
2059
2060        let (_, msg) = handler.handle_control_text(&payload);
2061
2062        match msg.expect("SendTxAck emitted") {
2063            NautilusWsMessage::SendTxAck { tx_hash, code } => {
2064                assert_eq!(code, 200);
2065                assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2066            }
2067            other => panic!("expected SendTxAck, was {other:?}"),
2068        }
2069    }
2070
2071    #[rstest]
2072    fn handle_control_text_sendtx_failure_emits_ack_sourced_rejection() {
2073        let mut handler = make_handler_with_account();
2074        let payload = serde_json::json!({
2075            "type": "jsonapi/sendtx",
2076            "code": 21727,
2077            "message": "invalid client order index",
2078        })
2079        .to_string();
2080
2081        let (_, msg) = handler.handle_control_text(&payload);
2082
2083        match msg.expect("SendTxRejected emitted") {
2084            NautilusWsMessage::SendTxRejected {
2085                source,
2086                code,
2087                message,
2088                tx_hash,
2089            } => {
2090                assert_eq!(source, SendTxRejectionSource::Ack);
2091                assert_eq!(code, Some(21727));
2092                assert_eq!(message, "invalid client order index");
2093                assert_eq!(tx_hash, None);
2094            }
2095            other => panic!("expected SendTxRejected, was {other:?}"),
2096        }
2097    }
2098
2099    #[rstest]
2100    fn handle_control_text_sendtx_failure_carries_echoed_tx_hash() {
2101        let mut handler = make_handler_with_account();
2102        let payload = serde_json::json!({
2103            "type": "jsonapi/sendtx",
2104            "code": 21727,
2105            "message": "invalid client order index",
2106            "tx_hash": "0000abcd",
2107        })
2108        .to_string();
2109
2110        let (_, msg) = handler.handle_control_text(&payload);
2111
2112        match msg.expect("SendTxRejected emitted") {
2113            NautilusWsMessage::SendTxRejected { tx_hash, .. } => {
2114                assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2115            }
2116            other => panic!("expected SendTxRejected, was {other:?}"),
2117        }
2118    }
2119
2120    #[rstest]
2121    fn handle_control_text_bare_error_frame_emits_bare_error_rejection() {
2122        let mut handler = make_handler_with_account();
2123        let payload = serde_json::json!({
2124            "type": "error",
2125            "code": 21702,
2126            "message": "invalid price",
2127        })
2128        .to_string();
2129
2130        let (_, msg) = handler.handle_control_text(&payload);
2131
2132        match msg.expect("SendTxRejected emitted") {
2133            NautilusWsMessage::SendTxRejected {
2134                source,
2135                code,
2136                message,
2137                tx_hash,
2138            } => {
2139                assert_eq!(source, SendTxRejectionSource::BareError);
2140                assert_eq!(code, Some(21702));
2141                assert_eq!(message, "invalid price");
2142                assert_eq!(tx_hash, None);
2143            }
2144            other => panic!("expected SendTxRejected, was {other:?}"),
2145        }
2146    }
2147
2148    #[rstest]
2149    fn handle_control_text_wrapped_error_emits_bare_error_rejection() {
2150        // No top-level `type` field; the rejection sits under `error.{code,message}`.
2151        let mut handler = make_handler_with_account();
2152        let payload = serde_json::json!({
2153            "error": {"code": 21149, "message": "integrator is not approved"},
2154        })
2155        .to_string();
2156
2157        let (_, msg) = handler.handle_control_text(&payload);
2158
2159        match msg.expect("SendTxRejected emitted") {
2160            NautilusWsMessage::SendTxRejected {
2161                source,
2162                code,
2163                message,
2164                tx_hash,
2165            } => {
2166                assert_eq!(source, SendTxRejectionSource::BareError);
2167                assert_eq!(code, Some(21149));
2168                assert_eq!(message, "integrator is not approved");
2169                assert_eq!(tx_hash, None);
2170            }
2171            other => panic!("expected SendTxRejected, was {other:?}"),
2172        }
2173    }
2174
2175    #[rstest]
2176    fn handle_frame_emits_no_account_state_until_both_streams_seen() {
2177        // Reconciler refuses to emit until both `account_all_assets` and
2178        // `user_stats` have delivered. An assets frame alone produces only
2179        // the AccountStreamFirstFrame marker, no AccountState payload.
2180        let mut handler = make_handler_with_account();
2181        let assets_only: super::LighterWsFrame =
2182            serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2183
2184        let messages = strip_account_marker(handler.handle_frame(assets_only, UnixNanos::from(11)));
2185
2186        assert!(
2187            messages.is_empty(),
2188            "expected no AccountState before user_stats arrives, received {messages:?}"
2189        );
2190    }
2191
2192    #[rstest]
2193    fn handle_frame_routes_account_assets_and_user_stats_to_unified_state() {
2194        // Fixture is the captured production no-position payload (10 USDC
2195        // on spot, 40 USDC pledged as perp collateral, no resting orders).
2196        // Lighter runs unified margin: both legs are deployable equity, so
2197        // total = balance + margin_balance, locked = locked_balance only,
2198        // and MarginBalance.initial = 0 because user_stats.collateral ==
2199        // available_balance (no margin in use).
2200        let mut handler = make_handler_with_account();
2201        let assets_frame: super::LighterWsFrame =
2202            serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2203        let user_stats_frame: super::LighterWsFrame =
2204            serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2205
2206        let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2207        let messages =
2208            strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2209
2210        assert_eq!(messages.len(), 1);
2211        match &messages[0] {
2212            NautilusWsMessage::AccountState(state) => {
2213                let usdc = Currency::get_or_create_crypto("USDC");
2214                assert_eq!(state.account_type, AccountType::Margin);
2215                assert_eq!(state.base_currency, None);
2216                assert_eq!(state.balances.len(), 1);
2217                assert_eq!(state.balances[0].currency, usdc);
2218                // balance 10 + margin_balance 40 = 50 total; locked = 0
2219                // (no resting spot orders); free = 50 (all deployable).
2220                assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2221                assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2222                assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2223                assert_eq!(state.margins.len(), 1);
2224                assert_eq!(state.margins[0].currency, usdc);
2225                assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2226                assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2227                assert!(state.margins[0].instrument_id.is_none());
2228                assert!(state.is_reported);
2229            }
2230            other => panic!("expected account state, was {other:?}"),
2231        }
2232    }
2233
2234    #[rstest]
2235    fn handle_frame_unified_state_reflects_open_position() {
2236        // Fixture pair captured live with one open ETH-LONG position:
2237        //   account_all_assets[USDC] = {balance:10, locked:0, margin_balance:39.995369556}
2238        //   user_stats              = {collateral:39.995369, available:39.168314, margin_usage:2.07}
2239        //
2240        // The 5 mUSDC haircut on margin_balance is the entry fee. The
2241        // 0.827055 USDC gap between collateral and available_balance is
2242        // the initial margin pledged to the open position. AccountBalance
2243        // is unchanged in shape; perp-margin-in-use lives on
2244        // MarginBalance, not in `locked`. Maintenance is zero because
2245        // Lighter doesn't publish a maintenance value on user_stats.
2246        let mut handler = make_handler_with_account();
2247        let assets_frame: super::LighterWsFrame =
2248            serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_WITH_POSITION).unwrap();
2249        let user_stats_frame: super::LighterWsFrame =
2250            serde_json::from_str(WS_USER_STATS_WITH_POSITION).unwrap();
2251
2252        let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2253        let messages =
2254            strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2255
2256        assert_eq!(messages.len(), 1);
2257        match &messages[0] {
2258            NautilusWsMessage::AccountState(state) => {
2259                assert_eq!(state.account_type, AccountType::Margin);
2260                assert_eq!(state.base_currency, None);
2261                assert_eq!(state.balances.len(), 1);
2262                // total = 10 + 39.995369556 = 49.99536956 (Money truncates
2263                // the trailing digit to 8 decimals, matching the
2264                // production log).
2265                assert_eq!(state.balances[0].total, Money::from("49.99536956 USDC"));
2266                assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2267                assert_eq!(state.balances[0].free, Money::from("49.99536956 USDC"));
2268                assert_eq!(state.margins.len(), 1);
2269                // initial = collateral 39.995369 - available 39.168314 = 0.827055
2270                assert_eq!(state.margins[0].initial, Money::from("0.82705500 USDC"));
2271                assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2272                assert!(state.margins[0].instrument_id.is_none());
2273            }
2274            other => panic!("expected account state, was {other:?}"),
2275        }
2276    }
2277
2278    #[rstest]
2279    fn handle_frame_emits_account_stream_first_frame_marker_per_variant() {
2280        // Pins the strict-await wiring: the handler appends an
2281        // `AccountStreamFirstFrame` marker after any typed reports so the
2282        // execution consumption loop can mark readiness only after the
2283        // accompanying state has been applied. The marker fires even for
2284        // empty content frames that produce no typed reports.
2285        let mut handler = make_handler_with_account();
2286        let orders_frame: super::LighterWsFrame =
2287            serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2288        let trades_frame: super::LighterWsFrame =
2289            serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
2290        let positions_frame: super::LighterWsFrame =
2291            serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2292        let assets_frame: super::LighterWsFrame =
2293            serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2294        let user_stats_frame: super::LighterWsFrame =
2295            serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2296
2297        let cases = [
2298            (orders_frame, AccountStream::Orders),
2299            (trades_frame, AccountStream::Trades),
2300            (positions_frame, AccountStream::Positions),
2301            (assets_frame, AccountStream::Assets),
2302            (user_stats_frame, AccountStream::UserStats),
2303        ];
2304
2305        for (frame, expected) in cases {
2306            let msgs = handler.handle_frame(frame, UnixNanos::from(11));
2307            let marker = msgs
2308                .iter()
2309                .find(|m| matches!(m, NautilusWsMessage::AccountStreamFirstFrame(_)))
2310                .unwrap_or_else(|| panic!("missing marker for {expected:?}"));
2311            match marker {
2312                NautilusWsMessage::AccountStreamFirstFrame(stream) => {
2313                    assert_eq!(*stream, expected);
2314                }
2315                other => panic!("expected AccountStreamFirstFrame, was {other:?}"),
2316            }
2317            // The marker must trail any typed reports the handler emitted
2318            // so the consumption loop applies state first.
2319            assert!(
2320                matches!(
2321                    msgs.last(),
2322                    Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
2323                ),
2324                "marker must trail typed reports for {expected:?}",
2325            );
2326        }
2327    }
2328
2329    #[rstest]
2330    fn handle_frame_account_orders_without_context_falls_back_to_raw() {
2331        let signal = Arc::new(AtomicBool::new(false));
2332        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2333        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2334        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2335        let mut handler =
2336            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2337        handler.instruments.insert(0, stub_eth_perp_instrument());
2338        // exec_account intentionally left unset; the handler must preserve
2339        // the prior `Raw` forwarding contract for unauthenticated subscribers.
2340
2341        let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2342        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2343
2344        assert_eq!(messages.len(), 1);
2345        match &messages[0] {
2346            NautilusWsMessage::Raw(value) => {
2347                assert_eq!(value["type"], "update/account_orders");
2348            }
2349            other => panic!("expected raw fallback, was {other:?}"),
2350        }
2351    }
2352
2353    #[rstest]
2354    fn handle_frame_account_orders_skips_unknown_market() {
2355        // Build a handler with the execution context but no instrument
2356        // cached for the order's market_index; the handler should log and
2357        // emit no execution reports (the trailing readiness marker still
2358        // fires so `connect()` does not stall when the venue resubscribes
2359        // before instrument bootstrap completes).
2360        let signal = Arc::new(AtomicBool::new(false));
2361        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2362        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2363        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2364        let mut handler =
2365            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2366        handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
2367        // No instrument inserted for market_index=0.
2368
2369        let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2370        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2371
2372        assert!(messages.is_empty());
2373    }
2374
2375    #[rstest]
2376    fn handle_frame_account_assets_invalid_timestamp_returns_empty() {
2377        let mut handler = make_handler_with_account();
2378        // u64::MAX millis overflows the seconds-to-nanos conversion in
2379        // `parse_millis_to_nanos`; the handler must log and emit no typed
2380        // account state rather than surface a partially constructed one.
2381        // The trailing readiness marker is still emitted so `connect()`
2382        // does not stall if the venue ever sends a malformed timestamp on
2383        // the initial frame.
2384        let frame_json = r#"{
2385            "type": "update/account_all_assets",
2386            "channel": "account_all_assets:1234",
2387            "timestamp": 18446744073709551615,
2388            "assets": {
2389                "0": {
2390                    "symbol": "USDC",
2391                    "asset_id": 0,
2392                    "balance": "100.000000",
2393                    "locked_balance": "1.000000"
2394                }
2395            }
2396        }"#;
2397        let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
2398
2399        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2400
2401        assert!(messages.is_empty());
2402    }
2403
2404    #[rstest]
2405    fn handle_frame_account_all_orders_routes_to_execution_reports() {
2406        // The `update/account_all_orders` variant lacks the per-account
2407        // top-level `account` and `nonce` fields that `account_orders`
2408        // carries, but the handler should treat them identically.
2409        let mut handler = make_handler_with_account();
2410        let frame_json = r#"{
2411            "type": "update/account_all_orders",
2412            "channel": "account_all_orders:1234",
2413            "orders": {
2414                "0": [{
2415                    "order_index": 281476929510110,
2416                    "client_order_index": 42,
2417                    "order_id": "281476929510110",
2418                    "client_order_id": "42",
2419                    "market_index": 0,
2420                    "owner_account_index": 1234,
2421                    "initial_base_amount": "0.0050",
2422                    "price": "2352.74",
2423                    "nonce": 9182390020,
2424                    "remaining_base_amount": "0.0050",
2425                    "is_ask": true,
2426                    "base_size": 50,
2427                    "base_price": 235274,
2428                    "filled_base_amount": "0.0000",
2429                    "filled_quote_amount": "0.000000",
2430                    "side": "sell",
2431                    "type": "limit",
2432                    "time_in_force": "good-till-time",
2433                    "reduce_only": false,
2434                    "trigger_price": "0.00",
2435                    "order_expiry": 1780360584479,
2436                    "status": "open",
2437                    "trigger_status": "na",
2438                    "trigger_time": 0,
2439                    "parent_order_index": 0,
2440                    "parent_order_id": "0",
2441                    "to_trigger_order_id_0": "0",
2442                    "to_trigger_order_id_1": "0",
2443                    "to_cancel_order_id_0": "0",
2444                    "integrator_fee_collector_index": "0",
2445                    "integrator_taker_fee": "0",
2446                    "integrator_maker_fee": "0",
2447                    "block_height": 227535532,
2448                    "timestamp": 1777941383576,
2449                    "created_at": 1777941383576,
2450                    "updated_at": 1777941383576,
2451                    "transaction_time": 1777941383576735
2452                }]
2453            }
2454        }"#;
2455        let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
2456
2457        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2458
2459        assert_eq!(messages.len(), 1);
2460        match &messages[0] {
2461            NautilusWsMessage::ExecutionReports(reports) => {
2462                assert_eq!(reports.len(), 1);
2463                match &reports[0] {
2464                    super::ExecutionReport::Order(order) => {
2465                        assert_eq!(order.order_id, "281476929510110");
2466                    }
2467                    other => panic!("expected order report, was {other:?}"),
2468                }
2469            }
2470            other => panic!("expected execution reports, was {other:?}"),
2471        }
2472    }
2473
2474    fn snapshot_trade_frame_json() -> &'static str {
2475        r#"{
2476            "type": "subscribed/account_all_trades",
2477            "channel": "account_all_trades:1234",
2478            "trades": [{
2479                "trade_id": 19209006902,
2480                "trade_id_str": "19209006902",
2481                "tx_hash": "000000128b1ee814",
2482                "type": "trade",
2483                "market_id": 0,
2484                "size": "0.1336",
2485                "price": "2352.73",
2486                "usd_amount": "314.324728",
2487                "ask_id": 281476929510102,
2488                "bid_id": 562947905631053,
2489                "ask_client_id": 0,
2490                "bid_client_id": 7001011966,
2491                "ask_account_id": 91249,
2492                "bid_account_id": 1234,
2493                "is_maker_ask": true,
2494                "block_height": 227535535,
2495                "timestamp": 1777941384181,
2496                "transaction_time": 1777941384181586
2497            }],
2498            "total_volume": "100.0",
2499            "monthly_volume": "100.0",
2500            "weekly_volume": "100.0",
2501            "daily_volume": "100.0"
2502        }"#
2503    }
2504
2505    #[rstest]
2506    fn handle_frame_account_all_trades_snapshot_is_dropped_with_context() {
2507        let mut handler = make_handler_with_account();
2508        // With execution context active the snapshot must be dropped to
2509        // avoid replaying historical fills as live FillReports on reconnect.
2510        // The trailing readiness marker is still emitted so `connect()`
2511        // observes the trades stream as having delivered.
2512        let frame: super::LighterWsFrame =
2513            serde_json::from_str(snapshot_trade_frame_json()).unwrap();
2514
2515        let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2516
2517        assert!(messages.is_empty());
2518    }
2519
2520    #[rstest]
2521    fn handle_frame_account_all_trades_snapshot_falls_back_to_raw_without_context() {
2522        let signal = Arc::new(AtomicBool::new(false));
2523        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2524        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2525        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2526        let mut handler =
2527            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2528        handler.instruments.insert(0, stub_eth_perp_instrument());
2529        // No exec_account: preserve prior Raw forwarding so unauthenticated
2530        // subscribers still see the snapshot frame.
2531
2532        let frame: super::LighterWsFrame =
2533            serde_json::from_str(snapshot_trade_frame_json()).unwrap();
2534        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2535
2536        assert_eq!(messages.len(), 1);
2537        match &messages[0] {
2538            NautilusWsMessage::Raw(value) => {
2539                assert_eq!(value["type"], "subscribed/account_all_trades");
2540            }
2541            other => panic!("expected raw fallback, was {other:?}"),
2542        }
2543    }
2544
2545    #[rstest]
2546    fn handle_frame_market_stats_emits_mark_index_and_funding_updates() {
2547        let mut handler = make_handler_with_account();
2548        let frame: super::LighterWsFrame =
2549            serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE).unwrap();
2550
2551        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2552
2553        assert_eq!(messages.len(), 3);
2554        match &messages[0] {
2555            NautilusWsMessage::MarkPrice(update) => {
2556                assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2557                assert_eq!(update.value, Price::from("2064.47"));
2558                assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
2559            }
2560            event => panic!("expected mark price update, was {event:?}"),
2561        }
2562
2563        match &messages[1] {
2564            NautilusWsMessage::IndexPrice(update) => {
2565                assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2566                assert_eq!(update.value, Price::from("2064.48"));
2567            }
2568            event => panic!("expected index price update, was {event:?}"),
2569        }
2570
2571        match &messages[2] {
2572            NautilusWsMessage::FundingRate(update) => {
2573                assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2574                assert_eq!(update.rate.to_string(), "0.000001");
2575                assert_eq!(
2576                    update.next_funding_ns,
2577                    Some(UnixNanos::from(1_774_886_400_000_000_000))
2578                );
2579            }
2580            event => panic!("expected funding rate update, was {event:?}"),
2581        }
2582    }
2583
2584    #[rstest]
2585    fn handle_frame_market_stats_all_emits_mark_index_and_funding_updates() {
2586        let mut handler = make_handler_with_account();
2587        let frame: super::LighterWsFrame =
2588            serde_json::from_str(WS_MARKET_STATS_UPDATE_ALL).unwrap();
2589
2590        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2591
2592        assert_eq!(messages.len(), 3);
2593        assert!(matches!(&messages[0], NautilusWsMessage::MarkPrice(_)));
2594        assert!(matches!(&messages[1], NautilusWsMessage::IndexPrice(_)));
2595        assert!(matches!(&messages[2], NautilusWsMessage::FundingRate(_)));
2596        match &messages[0] {
2597            NautilusWsMessage::MarkPrice(update) => {
2598                assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2599                assert_eq!(update.value, Price::from("2064.47"));
2600            }
2601            event => panic!("expected mark price update, was {event:?}"),
2602        }
2603    }
2604
2605    #[rstest]
2606    fn handle_frame_spot_market_stats_emits_index_update() {
2607        let signal = Arc::new(AtomicBool::new(false));
2608        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2609        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2610        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2611        let mut handler =
2612            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2613        handler.instruments.insert(2048, stub_eth_spot_instrument());
2614        let frame: super::LighterWsFrame =
2615            serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE).unwrap();
2616
2617        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2618
2619        assert_eq!(messages.len(), 1);
2620        match &messages[0] {
2621            NautilusWsMessage::IndexPrice(update) => {
2622                assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
2623                assert_eq!(update.value, Price::from("1.00"));
2624            }
2625            event => panic!("expected spot index price update, was {event:?}"),
2626        }
2627    }
2628
2629    #[rstest]
2630    fn handle_frame_spot_market_stats_all_emits_index_update() {
2631        let signal = Arc::new(AtomicBool::new(false));
2632        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2633        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2634        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2635        let mut handler =
2636            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2637        handler.instruments.insert(2048, stub_eth_spot_instrument());
2638        let frame: super::LighterWsFrame =
2639            serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_ALL).unwrap();
2640
2641        let messages = handler.handle_frame(frame, UnixNanos::from(11));
2642
2643        assert_eq!(messages.len(), 1);
2644        match &messages[0] {
2645            NautilusWsMessage::IndexPrice(update) => {
2646                assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
2647                assert_eq!(update.value, Price::from("1.00"));
2648            }
2649            event => panic!("expected spot index price update, was {event:?}"),
2650        }
2651    }
2652
2653    #[rstest]
2654    #[case(LighterWsChannel::OrderBook(0), "order_book:0", "order_book/0")]
2655    #[case(LighterWsChannel::Trade(7), "trade:7", "trade/7")]
2656    #[case(LighterWsChannel::Ticker(2), "ticker:2", "ticker/2")]
2657    #[case(LighterWsChannel::Height, "height", "height")]
2658    #[case(
2659        LighterWsChannel::MarketStats(LighterMarketSelection::All),
2660        "market_stats:all",
2661        "market_stats/all"
2662    )]
2663    #[case(
2664        LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(2048)),
2665        "spot_market_stats:2048",
2666        "spot_market_stats/2048"
2667    )]
2668    #[case(
2669        LighterWsChannel::AccountOrders { market_index: 0, account_index: 1234 },
2670        "account_orders:0:1234",
2671        "account_orders/0/1234",
2672    )]
2673    fn topic_and_subscription_round_trip(
2674        #[case] channel: LighterWsChannel,
2675        #[case] expected_topic: &str,
2676        #[case] expected_subscription: &str,
2677    ) {
2678        assert_eq!(channel.topic_key(), expected_topic);
2679        assert_eq!(channel.subscription_channel(), expected_subscription);
2680    }
2681
2682    #[rstest]
2683    #[case("order_book:0", Some(0))]
2684    #[case("trade:42", Some(42))]
2685    #[case("height", None)]
2686    #[case("malformed", None)]
2687    fn market_index_extraction(#[case] topic: &str, #[case] expected: Option<i16>) {
2688        assert_eq!(market_index_from_topic(topic), expected);
2689    }
2690
2691    #[rstest]
2692    #[case("order_book:0", Some(0))]
2693    #[case("order_book:42", Some(42))]
2694    #[case("trade:42", None)]
2695    #[case("ticker:2", None)]
2696    #[case("market_stats:0", None)]
2697    #[case("height", None)]
2698    #[case("order_book:not-an-int", None)]
2699    fn order_book_market_index_only_matches_order_book_channel(
2700        #[case] topic: &str,
2701        #[case] expected: Option<i16>,
2702    ) {
2703        assert_eq!(order_book_market_index_from_topic(topic), expected);
2704    }
2705
2706    #[rstest]
2707    #[case(LighterWsChannel::AccountAll(1234), true)]
2708    #[case(LighterWsChannel::OrderBook(0), false)]
2709    #[case(LighterWsChannel::AccountAllPositions(1), true)]
2710    #[case(LighterWsChannel::Trade(0), false)]
2711    fn requires_auth_classification(#[case] channel: LighterWsChannel, #[case] expected: bool) {
2712        assert_eq!(channel.requires_auth(), expected);
2713    }
2714
2715    #[rstest]
2716    fn handler_command_subscribe_debug_redacts_auth_token() {
2717        let token = "schnorr-signature-bytes-do-not-leak";
2718        let cmd = HandlerCommand::Subscribe {
2719            channel: LighterWsChannel::AccountAll(1234),
2720            auth: Some(token.to_string()),
2721        };
2722
2723        let dbg = format!("{cmd:?}");
2724
2725        assert!(
2726            !dbg.contains(token),
2727            "Debug output must not contain the auth token, found: {dbg}",
2728        );
2729        assert!(dbg.contains("authed"), "Debug should include authed flag");
2730    }
2731
2732    #[tokio::test]
2733    async fn send_tx_command_returns_handler_send_error_without_active_client() {
2734        let signal = Arc::new(AtomicBool::new(false));
2735        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2736        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2737        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2738        let mut handler =
2739            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2740        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
2741
2742        cmd_tx
2743            .send(HandlerCommand::SendTx {
2744                tx_type: LighterTxType::CreateOrder as u8,
2745                tx_info: serde_json::value::RawValue::from_string(
2746                    r#"{"AccountIndex":12345,"Nonce":42}"#.to_string(),
2747                )
2748                .unwrap(),
2749                response_tx,
2750            })
2751            .unwrap();
2752        drop(cmd_tx);
2753        drop(raw_tx);
2754
2755        let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
2756            .await
2757            .expect("timed out waiting for handler to drain command");
2758        let result = response_rx.await.expect("sendTx response channel closed");
2759
2760        assert!(next.is_none());
2761        let Err(LighterWsError::Client(message)) = result else {
2762            panic!("expected client send error, was {result:?}");
2763        };
2764        assert!(message.contains("no active WebSocket client"));
2765    }
2766
2767    #[tokio::test]
2768    async fn resubscribe_order_book_command_skips_when_reference_removed() {
2769        let signal = Arc::new(AtomicBool::new(false));
2770        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2771        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2772        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2773        let subscriptions = SubscriptionState::new(':');
2774        let topic = LighterWsChannel::OrderBook(0).topic_key();
2775        assert!(subscriptions.add_reference(&topic));
2776        assert!(subscriptions.remove_reference(&topic));
2777
2778        let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, subscriptions.clone());
2779        handler.book_delta_subs.insert(0);
2780
2781        cmd_tx
2782            .send(HandlerCommand::ResubscribeOrderBook { market_index: 0 })
2783            .expect("queue resync");
2784        drop(cmd_tx);
2785        drop(raw_tx);
2786
2787        let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
2788            .await
2789            .expect("timed out waiting for handler to drain command");
2790
2791        assert!(next.is_none());
2792        assert!(subscriptions.pending_subscribe_topics().is_empty());
2793        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2794    }
2795
2796    fn stub_candle(
2797        t: i64,
2798        open: i64,
2799        high: i64,
2800        low: i64,
2801        close: i64,
2802        volume_ticks: i64,
2803    ) -> LighterWsCandle {
2804        LighterWsCandle {
2805            t,
2806            o: Decimal::new(open, 2),
2807            h: Decimal::new(high, 2),
2808            l: Decimal::new(low, 2),
2809            c: Decimal::new(close, 2),
2810            v: Decimal::new(volume_ticks, 4),
2811            quote_volume: Decimal::ZERO,
2812            i: 0,
2813        }
2814    }
2815
2816    fn candle_frame(channel: &str, candle: LighterWsCandle, is_snapshot: bool) -> LighterWsFrame {
2817        if is_snapshot {
2818            LighterWsFrame::CandleSnapshot {
2819                channel: Ustr::from(channel),
2820                candles: vec![candle],
2821                timestamp: 0,
2822            }
2823        } else {
2824            LighterWsFrame::Candle {
2825                channel: Ustr::from(channel),
2826                candles: vec![candle],
2827                timestamp: 0,
2828            }
2829        }
2830    }
2831
2832    #[rstest]
2833    fn handle_candles_first_observation_caches_without_emit() {
2834        let mut handler = make_handler_with_account();
2835        let frame = candle_frame(
2836            "candle:0:1m",
2837            stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
2838            true,
2839        );
2840
2841        let messages = handler.handle_frame(frame, UnixNanos::from(99));
2842
2843        assert!(messages.is_empty(), "first observation must not emit");
2844        let key = (0_i16, LighterCandleResolution::OneMinute);
2845        assert_eq!(handler.last_candles.get(&key).map(|c| c.t), Some(1_000_000));
2846    }
2847
2848    #[rstest]
2849    fn handle_candles_t_advance_emits_bar_for_previous_candle() {
2850        let mut handler = make_handler_with_account();
2851        let prev = stub_candle(1_000_000, 10_000, 11_000, 9_900, 10_500, 10_000);
2852        let next = stub_candle(1_060_000, 10_500, 10_600, 10_450, 10_550, 20_000);
2853        let next_t = next.t;
2854        handler.handle_frame(candle_frame("candle:0:1m", prev, true), UnixNanos::from(1));
2855
2856        let messages =
2857            handler.handle_frame(candle_frame("candle:0:1m", next, false), UnixNanos::from(2));
2858
2859        assert_eq!(messages.len(), 1);
2860        match &messages[0] {
2861            NautilusWsMessage::Bar(bar) => {
2862                // The emitted bar reflects the previous (closed) candle, not the new one.
2863                assert_eq!(bar.open, Price::from("100.00"));
2864                assert_eq!(bar.high, Price::from("110.00"));
2865                assert_eq!(bar.low, Price::from("99.00"));
2866                assert_eq!(bar.close, Price::from("105.00"));
2867                assert_eq!(bar.volume, Quantity::from("1.0000"));
2868                assert_eq!(bar.ts_event, UnixNanos::from(1_000_000 * 1_000_000));
2869            }
2870            other => panic!("expected Bar message, was {other:?}"),
2871        }
2872        let cached = handler
2873            .last_candles
2874            .get(&(0_i16, LighterCandleResolution::OneMinute))
2875            .expect("cache populated");
2876        assert_eq!(cached.t, next_t);
2877    }
2878
2879    #[rstest]
2880    fn handle_candles_same_t_updates_cache_without_emit() {
2881        let mut handler = make_handler_with_account();
2882        let initial = stub_candle(1_000_000, 10_000, 10_050, 9_950, 10_025, 5_000);
2883        let same_t_updated = stub_candle(1_000_000, 10_000, 10_100, 9_950, 10_075, 7_500);
2884        let same_t_h = same_t_updated.h;
2885        let same_t_c = same_t_updated.c;
2886        handler.handle_frame(
2887            candle_frame("candle:0:1m", initial, true),
2888            UnixNanos::from(1),
2889        );
2890
2891        let messages = handler.handle_frame(
2892            candle_frame("candle:0:1m", same_t_updated, false),
2893            UnixNanos::from(2),
2894        );
2895
2896        assert!(messages.is_empty(), "same-`t` update must not emit");
2897        let cached = handler
2898            .last_candles
2899            .get(&(0_i16, LighterCandleResolution::OneMinute))
2900            .expect("cache populated");
2901        assert_eq!(cached.h, same_t_h);
2902        assert_eq!(cached.c, same_t_c);
2903    }
2904
2905    #[rstest]
2906    fn handle_candles_regressed_t_is_skipped() {
2907        let mut handler = make_handler_with_account();
2908        let initial = stub_candle(2_000_000, 10_000, 10_000, 10_000, 10_000, 5_000);
2909        let regressed = stub_candle(1_000_000, 9_000, 9_000, 9_000, 9_000, 5_000);
2910        let initial_t = initial.t;
2911        handler.handle_frame(
2912            candle_frame("candle:0:1m", initial, true),
2913            UnixNanos::from(1),
2914        );
2915
2916        let messages = handler.handle_frame(
2917            candle_frame("candle:0:1m", regressed, false),
2918            UnixNanos::from(2),
2919        );
2920
2921        assert!(messages.is_empty(), "regressed `t` must not emit");
2922        let cached = handler
2923            .last_candles
2924            .get(&(0_i16, LighterCandleResolution::OneMinute))
2925            .expect("cache populated");
2926        // Regressed frame is skipped entirely; cache stays on the original entry.
2927        assert_eq!(cached.t, initial_t);
2928    }
2929
2930    #[rstest]
2931    fn handle_candles_unknown_market_returns_empty() {
2932        let mut handler = make_handler_with_account();
2933        let frame = candle_frame(
2934            "candle:99:1m",
2935            stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 5_000),
2936            true,
2937        );
2938
2939        let messages = handler.handle_frame(frame, UnixNanos::from(1));
2940
2941        assert!(messages.is_empty());
2942    }
2943
2944    #[rstest]
2945    fn handle_unsubscribe_ack_clears_only_matching_candle_key() {
2946        let mut handler = make_handler_with_account();
2947        handler.last_candles.insert(
2948            (0, LighterCandleResolution::OneMinute),
2949            stub_candle(1, 0, 0, 0, 0, 0),
2950        );
2951        handler.last_candles.insert(
2952            (0, LighterCandleResolution::FiveMinute),
2953            stub_candle(2, 0, 0, 0, 0, 0),
2954        );
2955        handler.subscriptions.mark_unsubscribe("candle:0:1m");
2956
2957        let payload = json!({"type": "unsubscribed", "channel": "candle:0:1m"});
2958        let (matched, _) = handler.handle_control_text(&payload.to_string());
2959
2960        assert!(matched);
2961        assert!(
2962            handler
2963                .last_candles
2964                .get(&(0, LighterCandleResolution::OneMinute))
2965                .is_none(),
2966        );
2967        assert!(
2968            handler
2969                .last_candles
2970                .get(&(0, LighterCandleResolution::FiveMinute))
2971                .is_some(),
2972        );
2973    }
2974
2975    #[rstest]
2976    #[case::well_formed("candle:0:1m", Some((0, LighterCandleResolution::OneMinute)))]
2977    #[case::weekly("candle:3:1w", Some((3, LighterCandleResolution::OneWeek)))]
2978    #[case::other_kind("order_book:0", None)]
2979    #[case::missing_resolution("candle:0", None)]
2980    #[case::bad_market("candle:notanint:1m", None)]
2981    #[case::bad_resolution("candle:0:bogus", None)]
2982    fn test_candle_market_and_resolution_from_topic(
2983        #[case] topic: &str,
2984        #[case] expected: Option<(i16, LighterCandleResolution)>,
2985    ) {
2986        assert_eq!(candle_market_and_resolution_from_topic(topic), expected);
2987    }
2988
2989    #[rstest]
2990    #[case::network_retries(LighterWsError::Network("disconnected".into()), true)]
2991    #[case::auth_does_not_retry(LighterWsError::Authentication("bad token".into()), false)]
2992    #[case::parse_does_not_retry(LighterWsError::Parse("bad json".into()), false)]
2993    #[case::client_does_not_retry(LighterWsError::Client("no active WebSocket client".into()), false)]
2994    #[case::transport_closed_does_not_retry(LighterWsError::Transport(SendError::Closed), false)]
2995    #[case::transport_timeout_retries(LighterWsError::Transport(SendError::Timeout), true)]
2996    #[case::transport_broken_pipe_does_not_retry(
2997        LighterWsError::Transport(SendError::BrokenPipe(
2998            "writer closed".into(),
2999        )),
3000        false,
3001    )]
3002    fn test_should_retry_lighter_ws_error(#[case] error: LighterWsError, #[case] expected: bool) {
3003        assert_eq!(should_retry_lighter_ws_error(&error), expected);
3004    }
3005
3006    // Pins the `#[from] SendError` derive that `.map_err(LighterWsError::Transport)` relies on.
3007    #[rstest]
3008    #[case::closed(SendError::Closed)]
3009    #[case::timeout(SendError::Timeout)]
3010    #[case::broken_pipe(SendError::BrokenPipe("writer dropped".into()))]
3011    fn send_error_converts_into_transport_variant(#[case] send_error: SendError) {
3012        let err: LighterWsError = send_error.into();
3013        assert!(
3014            matches!(err, LighterWsError::Transport(_)),
3015            "expected Transport variant, was {err:?}",
3016        );
3017    }
3018
3019    #[tokio::test]
3020    async fn subscribe_command_parks_in_pending_subs_when_inflight_at_cap() {
3021        let signal = Arc::new(AtomicBool::new(false));
3022        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3023        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3024        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3025        let mut handler =
3026            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3027
3028        // Saturate the gate so the pump cannot dispatch the queued subscribe
3029        for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3030            handler
3031                .inflight_subs
3032                .insert(Ustr::from(format!("dummy:{i}").as_str()));
3033        }
3034
3035        cmd_tx
3036            .send(HandlerCommand::Subscribe {
3037                channel: LighterWsChannel::Candle {
3038                    market_index: 0,
3039                    resolution: LighterCandleResolution::OneMinute,
3040                },
3041                auth: None,
3042            })
3043            .expect("queue subscribe");
3044        drop(cmd_tx);
3045        drop(raw_tx);
3046
3047        let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3048            .await
3049            .expect("timed out waiting for handler to drain command");
3050
3051        assert!(next.is_none());
3052        assert_eq!(handler.inflight_subs.len(), SUBSCRIBE_INFLIGHT_MAX);
3053        assert_eq!(handler.pending_subs.len(), 1);
3054    }
3055
3056    #[tokio::test]
3057    async fn unsubscribe_drops_queued_subscribe_while_gate_full() {
3058        let signal = Arc::new(AtomicBool::new(false));
3059        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3060        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3061        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3062        let mut handler =
3063            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3064
3065        // Saturate the gate so the queued subscribe cannot dispatch before the unsubscribe
3066        for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3067            handler
3068                .inflight_subs
3069                .insert(Ustr::from(format!("dummy:{i}").as_str()));
3070        }
3071
3072        cmd_tx
3073            .send(HandlerCommand::Subscribe {
3074                channel: LighterWsChannel::Trade(0),
3075                auth: None,
3076            })
3077            .expect("queue subscribe");
3078        cmd_tx
3079            .send(HandlerCommand::Unsubscribe {
3080                channel: LighterWsChannel::Trade(0),
3081            })
3082            .expect("queue unsubscribe");
3083        drop(cmd_tx);
3084        drop(raw_tx);
3085
3086        let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3087            .await
3088            .expect("timed out waiting for handler to drain commands");
3089
3090        assert!(next.is_none());
3091        assert!(handler.pending_subs.is_empty());
3092    }
3093
3094    #[tokio::test]
3095    async fn reconnect_clears_gate_state() {
3096        let signal = Arc::new(AtomicBool::new(false));
3097        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3098        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3099        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3100        let mut handler =
3101            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3102
3103        // Gate saturated with a subscribe still queued, as during a reconnect storm
3104        for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3105            handler
3106                .inflight_subs
3107                .insert(Ustr::from(format!("dummy:{i}").as_str()));
3108        }
3109        handler
3110            .pending_subs
3111            .push_back((LighterWsChannel::Trade(0), None));
3112
3113        raw_tx
3114            .send(Message::Text(RECONNECTED.to_string().into()))
3115            .expect("queue reconnect sentinel");
3116
3117        let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3118            .await
3119            .expect("timed out waiting for reconnect");
3120
3121        assert!(matches!(next, Some(NautilusWsMessage::Reconnected)));
3122        assert!(handler.inflight_subs.is_empty());
3123        assert!(handler.pending_subs.is_empty());
3124    }
3125
3126    #[tokio::test]
3127    async fn pump_releases_inflight_slot_on_send_failure() {
3128        let signal = Arc::new(AtomicBool::new(false));
3129        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3130        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3131        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3132        let mut handler =
3133            FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3134
3135        // No active client, so every dispatch fails; the gate must not leak slots
3136        for market_index in 0..3 {
3137            handler
3138                .pending_subs
3139                .push_back((LighterWsChannel::Trade(market_index), None));
3140        }
3141
3142        handler.pump_pending_subscribes().await;
3143
3144        assert!(handler.pending_subs.is_empty());
3145        assert!(handler.inflight_subs.is_empty());
3146    }
3147
3148    #[rstest]
3149    fn subscribed_control_frame_releases_inflight_slot() {
3150        let mut handler = make_handler_with_account();
3151        handler.inflight_subs.insert(Ustr::from("candle:0:1m"));
3152
3153        let (matched, msg) =
3154            handler.handle_control_text(r#"{"type":"subscribed","channel":"candle:0:1m"}"#);
3155
3156        assert!(matched);
3157        assert!(msg.is_none());
3158        assert!(!handler.inflight_subs.contains(&Ustr::from("candle:0:1m")));
3159    }
3160
3161    #[rstest]
3162    fn typed_frame_releases_inflight_slot() {
3163        let mut handler = make_handler_with_account();
3164        handler.inflight_subs.insert(Ustr::from("candle:0:1m"));
3165
3166        handler.handle_frame(
3167            candle_frame(
3168                "candle:0:1m",
3169                stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
3170                true,
3171            ),
3172            UnixNanos::from(1),
3173        );
3174
3175        assert!(!handler.inflight_subs.contains(&Ustr::from("candle:0:1m")));
3176    }
3177
3178    #[rstest]
3179    fn release_subscribe_inflight_reports_first_ack_only() {
3180        let mut handler = make_handler_with_account();
3181        handler.inflight_subs.insert(Ustr::from("trade:7"));
3182
3183        assert!(handler.release_subscribe_inflight("trade:7"));
3184        assert!(!handler.release_subscribe_inflight("trade:7"));
3185        assert!(!handler.release_subscribe_inflight("never:1"));
3186    }
3187}