Skip to main content

nautilus_polymarket/websocket/
dispatch.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//! WebSocket message dispatch for the Polymarket execution client.
17//!
18//! Routes user-channel WS messages (order updates and trades) for orders submitted through this
19//! client into Nautilus order events (`OrderAccepted` / `OrderFilled` / `OrderCanceled` /
20//! `OrderRejected` / `OrderExpired`), building them from the identity captured at submit
21//! (`OrderIdentityRegistry`). Order-channel messages drive lifecycle events; trade-channel
22//! messages drive fills, and acceptance is synthesized before a fill or cancel that races ahead.
23//! Messages are emitted once the order is known (accepted, or with a submit in flight), otherwise
24//! buffered until acceptance. Reports are reserved for the `generate_*` query and reconciliation
25//! methods. Trade fills are deduped via a FIFO cache; maker and taker fills are handled separately
26//! to account for multi-leg maker order matching.
27
28use std::str::FromStr;
29
30use nautilus_common::cache::fifo::{FifoCache, FifoCacheMap};
31use nautilus_core::{UUID4, UnixNanos, collections::AtomicMap, time::AtomicTime};
32use nautilus_live::ExecutionEventEmitter;
33use nautilus_model::{
34    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
35    events::{
36        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
37        OrderUpdated,
38    },
39    identifiers::{AccountId, VenueOrderId},
40    instruments::{Instrument, InstrumentAny},
41    reports::{FillReport, OrderStatusReport},
42    types::{Money, Price, Quantity},
43};
44use rust_decimal::Decimal;
45use ustr::Ustr;
46
47use super::{
48    messages::{PolymarketUserOrder, PolymarketUserTrade, UserWsMessage},
49    parse::parse_timestamp_ms,
50};
51use crate::{
52    common::enums::{PolymarketLiquiditySide, PolymarketOrderStatus},
53    execution::{
54        get_pusd_currency,
55        identity::{OrderIdentity, OrderIdentityRegistry},
56        order_fill_tracker::OrderFillTrackerMap,
57        parse::{
58            build_maker_fill_report, compute_commission, determine_order_side,
59            instrument_taker_fee, make_composite_trade_id, parse_liquidity_side,
60        },
61        pending::PendingSubmitTracker,
62    },
63};
64
65/// Signal returned when a finalized trade requires an async account refresh.
66#[derive(Debug)]
67pub(crate) struct AccountRefreshRequest;
68
69/// Mutable state owned by the WS message loop (not shared via Arc).
70#[derive(Debug, Default)]
71pub(crate) struct WsDispatchState {
72    pub processed_fills: FifoCache<String, 10_000>,
73    /// Cancel reports saved for orders known to be terminal at the venue.
74    /// Re-emitted after a fill to restore terminal state when fills race
75    /// ahead of (or arrive after) cancel messages.
76    terminal_cancel_reports: FifoCacheMap<VenueOrderId, OrderStatusReport, 10_000>,
77}
78
79/// Immutable context borrowed from the async block's owned values.
80#[derive(Debug)]
81pub(crate) struct WsDispatchContext<'a> {
82    pub token_instruments: &'a AtomicMap<Ustr, InstrumentAny>,
83    pub fill_tracker: &'a OrderFillTrackerMap,
84    pub pending_submits: &'a PendingSubmitTracker,
85    pub order_identities: &'a OrderIdentityRegistry,
86    pub emitter: &'a ExecutionEventEmitter,
87    pub account_id: AccountId,
88    pub clock: &'static AtomicTime,
89    pub user_address: &'a str,
90    pub user_api_key: &'a str,
91}
92
93/// Top-level router: synchronous, returns signal for async account refresh.
94pub(crate) fn dispatch_user_message(
95    message: &UserWsMessage,
96    ctx: &WsDispatchContext<'_>,
97    state: &mut WsDispatchState,
98) -> Option<AccountRefreshRequest> {
99    match message {
100        UserWsMessage::Order(order) => {
101            dispatch_order_update(order, ctx, state);
102            None
103        }
104        UserWsMessage::Trade(trade) => dispatch_trade_update(trade, ctx, state),
105    }
106}
107
108fn dispatch_order_update(
109    order: &PolymarketUserOrder,
110    ctx: &WsDispatchContext<'_>,
111    state: &mut WsDispatchState,
112) {
113    let instruments = ctx.token_instruments.load();
114    let instrument = match instruments.get(&order.asset_id) {
115        Some(i) => i,
116        None => {
117            log::warn!("Unknown asset_id in order update: {}", order.asset_id);
118            return;
119        }
120    };
121
122    let ts_event = parse_timestamp_ms(&order.timestamp).unwrap_or_else(|_| ctx.clock.get_time_ns());
123    let venue_order_id = VenueOrderId::from(order.id.as_str());
124
125    let ts_init = ctx.clock.get_time_ns();
126    let mut report =
127        build_ws_order_status_report(order, instrument, ctx.account_id, ts_event, ts_init);
128    let local_client_order_id = ctx.pending_submits.client_order_id(&venue_order_id);
129    let mut is_accepted = ctx.fill_tracker.contains(&venue_order_id);
130    report.client_order_id = local_client_order_id;
131
132    // A known own order (submit in flight) self-registers on its first WS update
133    let buffered_fills = if local_client_order_id.is_some()
134        && !is_accepted
135        && report.order_status != OrderStatus::Rejected
136    {
137        is_accepted = true;
138        ctx.fill_tracker.register_and_take_pending_fills(
139            venue_order_id,
140            local_client_order_id,
141            report.quantity,
142            report.order_side,
143            report.instrument_id,
144            instrument.size_precision(),
145            instrument.price_precision(),
146        )
147    } else if is_accepted {
148        ctx.fill_tracker
149            .take_pending_fills(venue_order_id, local_client_order_id)
150    } else {
151        Vec::new()
152    };
153
154    // Order updates can race ahead of trade messages, so cap filled_qty
155    // to what the fill tracker has recorded to prevent duplicate inferred fills
156    if let Some(tracked_filled) = ctx.fill_tracker.get_cumulative_filled(&venue_order_id) {
157        let tracked_qty = Quantity::new(tracked_filled, instrument.size_precision());
158        if report.filled_qty > tracked_qty {
159            log::debug!(
160                "Capping filled_qty for {venue_order_id} from {} to {} (awaiting trade messages)",
161                report.filled_qty,
162                tracked_qty,
163            );
164            report.filled_qty = tracked_qty;
165        }
166    }
167
168    // Track cancel reports so we can re-emit them after late-arriving fills.
169    // Saved regardless of acceptance state so that cancels arriving during
170    // the HTTP round-trip are available once the order is later accepted.
171    if report.order_status == OrderStatus::Canceled {
172        state
173            .terminal_cancel_reports
174            .insert(venue_order_id, report.clone());
175    }
176
177    // Tracked own orders route through order events; externally-managed orders
178    // (no captured identity) buffer until accepted or fall back to reports.
179    let identity = ctx.order_identities.get(&venue_order_id);
180    if is_accepted || local_client_order_id.is_some() {
181        match identity {
182            Some(identity) => emit_tracked_order_status(&report, &identity, ts_event, ctx),
183            None => ctx.emitter.send_order_status_report(report),
184        }
185    } else if let Some(report) = ctx
186        .fill_tracker
187        .accept_or_buffer_report(venue_order_id, report)
188    {
189        // Registered between the early accepted-check and here: emit rather than buffer
190        match ctx.order_identities.get(&venue_order_id) {
191            Some(identity) => emit_tracked_order_status(&report, &identity, ts_event, ctx),
192            None => ctx.emitter.send_order_status_report(report),
193        }
194    }
195
196    for fill in buffered_fills {
197        match identity {
198            Some(identity) => emit_order_filled(&identity, &fill, ctx),
199            None => ctx.emitter.send_fill_report(fill),
200        }
201    }
202
203    // MATCHED convergence: check for dust residual. A dust fill is only built for a
204    // registered order, so it always emits (accepted by construction, no gate).
205    if order.status == PolymarketOrderStatus::Matched {
206        let price_precision = instrument.price_precision();
207        let price = Decimal::from_str(&order.price)
208            .ok()
209            .and_then(|d| Price::from_decimal_dp(d, price_precision).ok())
210            .unwrap_or_else(|| Price::zero(price_precision));
211
212        if let Some(dust_fill) = ctx.fill_tracker.check_dust_and_build_fill(
213            &venue_order_id,
214            ctx.account_id,
215            &order.id,
216            price.as_f64(),
217            get_pusd_currency(),
218            ts_event,
219            ts_init,
220        ) {
221            match ctx.order_identities.get(&venue_order_id) {
222                Some(identity) => emit_order_filled(&identity, &dust_fill, ctx),
223                None => ctx.emitter.send_fill_report(dust_fill),
224            }
225        }
226    }
227}
228
229fn dispatch_trade_update(
230    trade: &PolymarketUserTrade,
231    ctx: &WsDispatchContext<'_>,
232    state: &mut WsDispatchState,
233) -> Option<AccountRefreshRequest> {
234    if !trade.status.is_finalized()
235        && !matches!(
236            trade.status,
237            crate::common::enums::PolymarketTradeStatus::Matched
238        )
239    {
240        log::debug!(
241            "Skipping trade with status {:?}: {}",
242            trade.status,
243            trade.id
244        );
245        return None;
246    }
247
248    let dedup_key = format!("{}-{}", trade.id, trade.taker_order_id);
249    let is_duplicate = state.processed_fills.contains(&dedup_key);
250
251    let needs_refresh = trade.status.is_finalized();
252
253    if is_duplicate {
254        log::debug!("Duplicate fill skipped: {dedup_key}");
255        return if needs_refresh {
256            Some(AccountRefreshRequest)
257        } else {
258            None
259        };
260    }
261    state.processed_fills.add(dedup_key);
262
263    let is_maker = trade.trader_side == PolymarketLiquiditySide::Maker;
264    let liquidity_side = parse_liquidity_side(trade.trader_side);
265    let ts_event = parse_timestamp_ms(&trade.timestamp).unwrap_or_else(|_| ctx.clock.get_time_ns());
266    let ts_init = ctx.clock.get_time_ns();
267
268    if is_maker {
269        dispatch_maker_fills(trade, ctx, state, liquidity_side, ts_event, ts_init);
270    } else {
271        dispatch_taker_fill(trade, ctx, state, liquidity_side, ts_event, ts_init);
272    }
273
274    if needs_refresh {
275        Some(AccountRefreshRequest)
276    } else {
277        None
278    }
279}
280
281fn dispatch_maker_fills(
282    trade: &PolymarketUserTrade,
283    ctx: &WsDispatchContext<'_>,
284    state: &WsDispatchState,
285    liquidity_side: LiquiditySide,
286    ts_event: UnixNanos,
287    ts_init: UnixNanos,
288) {
289    let user_orders: Vec<_> = trade
290        .maker_orders
291        .iter()
292        .filter(|mo| mo.maker_address == ctx.user_address || mo.owner == ctx.user_api_key)
293        .collect();
294
295    if user_orders.is_empty() {
296        log::warn!("No matching maker orders for user in trade: {}", trade.id);
297        return;
298    }
299
300    let instruments = ctx.token_instruments.load();
301
302    for mo in user_orders {
303        let asset_id = Ustr::from(mo.asset_id.as_str());
304        let instrument = match instruments.get(&asset_id) {
305            Some(i) => i,
306            None => {
307                log::warn!("Unknown asset_id in maker order: {asset_id}");
308                continue;
309            }
310        };
311        let mut report = build_maker_fill_report(
312            mo,
313            &trade.id,
314            trade.trader_side,
315            trade.side,
316            trade.asset_id.as_str(),
317            ctx.account_id,
318            instrument.id(),
319            instrument.price_precision(),
320            instrument.size_precision(),
321            crate::execution::get_pusd_currency(),
322            liquidity_side,
323            ts_event,
324            ts_init,
325        );
326        let maker_venue_order_id = report.venue_order_id;
327        report.client_order_id = ctx.pending_submits.client_order_id(&maker_venue_order_id);
328        report.last_qty = ctx
329            .fill_tracker
330            .snap_fill_qty(&maker_venue_order_id, report.last_qty);
331
332        if let Some(report) = ctx
333            .fill_tracker
334            .accept_or_buffer_fill(maker_venue_order_id, report)
335        {
336            match ctx.order_identities.get(&maker_venue_order_id) {
337                Some(identity) => emit_order_filled(&identity, &report, ctx),
338                None => ctx.emitter.send_fill_report(report),
339            }
340            reemit_terminal_cancel(maker_venue_order_id, state, ctx);
341        }
342    }
343}
344
345fn dispatch_taker_fill(
346    trade: &PolymarketUserTrade,
347    ctx: &WsDispatchContext<'_>,
348    state: &WsDispatchState,
349    liquidity_side: LiquiditySide,
350    ts_event: UnixNanos,
351    ts_init: UnixNanos,
352) {
353    let instruments = ctx.token_instruments.load();
354    let instrument = match instruments.get(&trade.asset_id) {
355        Some(i) => i,
356        None => {
357            log::warn!("Unknown asset_id in trade: {}", trade.asset_id);
358            return;
359        }
360    };
361
362    let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
363
364    let mut report = build_ws_taker_fill_report(
365        trade,
366        instrument,
367        ctx.account_id,
368        liquidity_side,
369        ts_event,
370        ts_init,
371    );
372    report.client_order_id = ctx.pending_submits.client_order_id(&venue_order_id);
373    report.last_qty = ctx
374        .fill_tracker
375        .snap_fill_qty(&venue_order_id, report.last_qty);
376
377    if let Some(report) = ctx
378        .fill_tracker
379        .accept_or_buffer_fill(venue_order_id, report)
380    {
381        match ctx.order_identities.get(&venue_order_id) {
382            Some(identity) => emit_order_filled(&identity, &report, ctx),
383            None => ctx.emitter.send_fill_report(report),
384        }
385        reemit_terminal_cancel(venue_order_id, state, ctx);
386    }
387}
388
389/// Re-emits a saved cancel report after a fill to restore terminal state.
390///
391/// When fills race ahead of (or arrive after) cancel messages, the order can
392/// get stuck in `PartiallyFilled`. This re-emission ensures the execution
393/// engine transitions the order back to `Canceled`.
394///
395/// Skips re-emission when the fill tracker shows the order is fully filled,
396/// because `Filled` is already terminal and a spurious cancel would fail
397/// the `Filled -> Canceled` state transition.
398fn reemit_terminal_cancel(
399    venue_order_id: VenueOrderId,
400    state: &WsDispatchState,
401    ctx: &WsDispatchContext<'_>,
402) {
403    if ctx.fill_tracker.is_fully_filled(&venue_order_id) {
404        return;
405    }
406
407    if let Some(cancel_report) = state.terminal_cancel_reports.get(&venue_order_id) {
408        log::debug!("Re-emitting cancel for {venue_order_id} after fill to restore terminal state");
409        match ctx.order_identities.get(&venue_order_id) {
410            Some(identity) => {
411                emit_order_canceled(&identity, venue_order_id, cancel_report.ts_last, ctx);
412            }
413            None => ctx.emitter.send_order_status_report(cancel_report.clone()),
414        }
415    }
416}
417
418fn build_ws_order_status_report(
419    order: &PolymarketUserOrder,
420    instrument: &InstrumentAny,
421    account_id: AccountId,
422    ts_event: UnixNanos,
423    ts_init: UnixNanos,
424) -> OrderStatusReport {
425    let venue_order_id = VenueOrderId::from(order.id.as_str());
426    let order_status =
427        crate::execution::parse::resolve_order_status(order.status, order.event_type);
428    let order_side = OrderSide::from(order.side);
429    let time_in_force = TimeInForce::from(order.order_type);
430    let size_precision = instrument.size_precision();
431    let price_precision = instrument.price_precision();
432    let quantity = Decimal::from_str(&order.original_size)
433        .ok()
434        .and_then(|d| Quantity::from_decimal_dp(d, size_precision).ok())
435        .unwrap_or_else(|| Quantity::zero(size_precision));
436    let filled_qty = Decimal::from_str(&order.size_matched)
437        .ok()
438        .and_then(|d| Quantity::from_decimal_dp(d, size_precision).ok())
439        .unwrap_or_else(|| Quantity::zero(size_precision));
440    let price = Decimal::from_str(&order.price)
441        .ok()
442        .and_then(|d| Price::from_decimal_dp(d, price_precision).ok())
443        .unwrap_or_else(|| Price::zero(price_precision));
444
445    let mut report = OrderStatusReport::new(
446        account_id,
447        instrument.id(),
448        None,
449        venue_order_id,
450        order_side,
451        OrderType::Limit,
452        time_in_force,
453        order_status,
454        quantity,
455        filled_qty,
456        ts_event,
457        ts_event,
458        ts_init,
459        None,
460    );
461    report.price = Some(price);
462    report
463}
464
465fn build_ws_taker_fill_report(
466    trade: &PolymarketUserTrade,
467    instrument: &InstrumentAny,
468    account_id: AccountId,
469    liquidity_side: LiquiditySide,
470    ts_event: UnixNanos,
471    ts_init: UnixNanos,
472) -> FillReport {
473    let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
474    let trade_id = make_composite_trade_id(&trade.id, &trade.taker_order_id);
475    let order_side = determine_order_side(
476        trade.trader_side,
477        trade.side,
478        trade.asset_id.as_str(),
479        trade.asset_id.as_str(),
480    );
481
482    let size_precision = instrument.size_precision();
483    let price_precision = instrument.price_precision();
484    let size_dec = Decimal::from_str(&trade.size).unwrap_or_default();
485    let price_dec = Decimal::from_str(&trade.price).unwrap_or_default();
486    let last_qty = Quantity::from_decimal_dp(size_dec, size_precision)
487        .unwrap_or_else(|_| Quantity::zero(size_precision));
488    let last_px = Price::from_decimal_dp(price_dec, price_precision)
489        .unwrap_or_else(|_| Price::zero(price_precision));
490
491    let fee_rate = instrument_taker_fee(instrument);
492    let commission_value = compute_commission(fee_rate, size_dec, price_dec, liquidity_side);
493    let pusd = crate::execution::get_pusd_currency();
494
495    FillReport {
496        account_id,
497        instrument_id: instrument.id(),
498        venue_order_id,
499        trade_id,
500        order_side,
501        last_qty,
502        last_px,
503        commission: Money::new(commission_value, pusd),
504        liquidity_side,
505        avg_px: None,
506        report_id: UUID4::new(),
507        ts_event,
508        ts_init,
509        client_order_id: None,
510        venue_position_id: None,
511    }
512}
513
514/// Emits order events for a tracked own-order status update.
515///
516/// Order-channel messages drive lifecycle events only; fills arrive separately on the trade
517/// channel as `OrderFilled`. `PartiallyFilled` / `Filled` statuses therefore emit no fill here,
518/// they only ensure acceptance has been emitted so the order lifecycle stays well-formed.
519fn emit_tracked_order_status(
520    report: &OrderStatusReport,
521    identity: &OrderIdentity,
522    ts_event: UnixNanos,
523    ctx: &WsDispatchContext<'_>,
524) {
525    let venue_order_id = report.venue_order_id;
526    match report.order_status {
527        OrderStatus::Accepted => ensure_accepted(identity, venue_order_id, ts_event, ctx),
528        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
529            ensure_accepted(identity, venue_order_id, ts_event, ctx);
530        }
531        OrderStatus::Canceled => {
532            ensure_accepted(identity, venue_order_id, ts_event, ctx);
533            emit_order_canceled(identity, venue_order_id, ts_event, ctx);
534        }
535        OrderStatus::Expired => {
536            ensure_accepted(identity, venue_order_id, ts_event, ctx);
537            emit_order_expired(identity, venue_order_id, ts_event, ctx);
538        }
539        OrderStatus::Rejected => {
540            let reason = report
541                .cancel_reason
542                .clone()
543                .unwrap_or_else(|| "REJECTED".to_string());
544            emit_order_rejected(identity, &reason, ts_event, ctx);
545        }
546        other => log::debug!("No order event for status {other:?} on {venue_order_id}"),
547    }
548}
549
550/// Emits `OrderAccepted` for a tracked order if acceptance has not yet been emitted.
551///
552/// Acceptance is also emitted on the submit happy path; the registry's dedup set ensures it
553/// fires exactly once across the submit confirmation and the WS stream, including when a fill or
554/// cancel races ahead of the acceptance message.
555fn ensure_accepted(
556    identity: &OrderIdentity,
557    venue_order_id: VenueOrderId,
558    ts_event: UnixNanos,
559    ctx: &WsDispatchContext<'_>,
560) {
561    if !ctx.order_identities.mark_accepted(venue_order_id) {
562        return;
563    }
564    let accepted = OrderAccepted::new(
565        ctx.emitter.trader_id(),
566        identity.strategy_id,
567        identity.instrument_id,
568        identity.client_order_id,
569        venue_order_id,
570        ctx.account_id,
571        UUID4::new(),
572        ts_event,
573        ctx.clock.get_time_ns(),
574        false,
575    );
576    ctx.emitter
577        .send_order_event(OrderEventAny::Accepted(accepted));
578}
579
580/// Builds and emits an `OrderFilled` event for a tracked order, synthesizing acceptance first.
581fn emit_order_filled(identity: &OrderIdentity, fill: &FillReport, ctx: &WsDispatchContext<'_>) {
582    ensure_accepted(identity, fill.venue_order_id, fill.ts_event, ctx);
583
584    if let Some(new_qty) = ctx.fill_tracker.buy_overfill_bump(&fill.venue_order_id) {
585        emit_buy_overfill_update(identity, fill.venue_order_id, new_qty, fill.ts_event, ctx);
586    }
587
588    let filled = OrderFilled::new(
589        ctx.emitter.trader_id(),
590        identity.strategy_id,
591        identity.instrument_id,
592        identity.client_order_id,
593        fill.venue_order_id,
594        ctx.account_id,
595        fill.trade_id,
596        identity.order_side,
597        identity.order_type,
598        fill.last_qty,
599        fill.last_px,
600        get_pusd_currency(),
601        fill.liquidity_side,
602        UUID4::new(),
603        fill.ts_event,
604        fill.ts_init,
605        false,
606        fill.venue_position_id,
607        Some(fill.commission),
608    );
609    ctx.emitter.send_order_event(OrderEventAny::Filled(filled));
610}
611
612/// Emits an `OrderUpdated` raising the order quantity to the actual BUY fill, before the fill.
613///
614/// A Polymarket BUY is bounded by the USDC it spends, so a marketable fill below the limit price
615/// returns more shares than the nominal quantity. The engine rejects a fill past the order
616/// quantity, so the quantity is raised first. The price is left unchanged (`None`).
617fn emit_buy_overfill_update(
618    identity: &OrderIdentity,
619    venue_order_id: VenueOrderId,
620    new_qty: Quantity,
621    ts_event: UnixNanos,
622    ctx: &WsDispatchContext<'_>,
623) {
624    let updated = OrderUpdated::new(
625        ctx.emitter.trader_id(),
626        identity.strategy_id,
627        identity.instrument_id,
628        identity.client_order_id,
629        new_qty,
630        UUID4::new(),
631        ts_event,
632        ctx.clock.get_time_ns(),
633        false,
634        Some(venue_order_id),
635        Some(ctx.account_id),
636        None,
637        None,
638        None,
639        false,
640    );
641    ctx.emitter
642        .send_order_event(OrderEventAny::Updated(updated));
643}
644
645fn emit_order_canceled(
646    identity: &OrderIdentity,
647    venue_order_id: VenueOrderId,
648    ts_event: UnixNanos,
649    ctx: &WsDispatchContext<'_>,
650) {
651    let canceled = OrderCanceled::new(
652        ctx.emitter.trader_id(),
653        identity.strategy_id,
654        identity.instrument_id,
655        identity.client_order_id,
656        UUID4::new(),
657        ts_event,
658        ctx.clock.get_time_ns(),
659        false,
660        Some(venue_order_id),
661        Some(ctx.account_id),
662    );
663    ctx.emitter
664        .send_order_event(OrderEventAny::Canceled(canceled));
665}
666
667fn emit_order_expired(
668    identity: &OrderIdentity,
669    venue_order_id: VenueOrderId,
670    ts_event: UnixNanos,
671    ctx: &WsDispatchContext<'_>,
672) {
673    let expired = OrderExpired::new(
674        ctx.emitter.trader_id(),
675        identity.strategy_id,
676        identity.instrument_id,
677        identity.client_order_id,
678        UUID4::new(),
679        ts_event,
680        ctx.clock.get_time_ns(),
681        false,
682        Some(venue_order_id),
683        Some(ctx.account_id),
684    );
685    ctx.emitter
686        .send_order_event(OrderEventAny::Expired(expired));
687}
688
689fn emit_order_rejected(
690    identity: &OrderIdentity,
691    reason: &str,
692    ts_event: UnixNanos,
693    ctx: &WsDispatchContext<'_>,
694) {
695    let rejected = OrderRejected::new(
696        ctx.emitter.trader_id(),
697        identity.strategy_id,
698        identity.instrument_id,
699        identity.client_order_id,
700        ctx.account_id,
701        Ustr::from(reason),
702        UUID4::new(),
703        ts_event,
704        ctx.clock.get_time_ns(),
705        false,
706        false,
707    );
708    ctx.emitter
709        .send_order_event(OrderEventAny::Rejected(rejected));
710}
711
712#[cfg(test)]
713mod tests {
714    use nautilus_common::messages::{ExecutionEvent, ExecutionReport};
715    use nautilus_core::time::AtomicTime;
716    use nautilus_model::{
717        enums::{AccountType, OrderStatus},
718        events::OrderEventAny,
719        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
720        types::Currency,
721    };
722    use rstest::rstest;
723
724    use super::*;
725    use crate::http::{
726        models::GammaMarket,
727        parse::{create_instrument_from_def, parse_gamma_market},
728    };
729
730    /// Registers a tracked-order identity so the dispatch routes the order through events.
731    fn register_identity(
732        order_identities: &OrderIdentityRegistry,
733        venue_order_id: VenueOrderId,
734        instrument_id: InstrumentId,
735        client_order_id: &str,
736    ) {
737        order_identities.register_order_identity(
738            venue_order_id,
739            OrderIdentity {
740                client_order_id: ClientOrderId::from(client_order_id),
741                strategy_id: StrategyId::from("S-001"),
742                instrument_id,
743                order_side: OrderSide::Buy,
744                order_type: OrderType::Limit,
745            },
746        );
747    }
748
749    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
750        let path = format!("test_data/{filename}");
751        let content = std::fs::read_to_string(path).expect("Failed to read test data");
752        serde_json::from_str(&content).expect("Failed to parse test data")
753    }
754
755    fn test_instrument() -> InstrumentAny {
756        let market: GammaMarket = load("gamma_market.json");
757        let defs = parse_gamma_market(&market).unwrap();
758        create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap()
759    }
760
761    fn test_emitter() -> ExecutionEventEmitter {
762        ExecutionEventEmitter::new(
763            nautilus_core::time::get_atomic_clock_realtime(),
764            TraderId::from("TESTER-001"),
765            AccountId::from("POLY-001"),
766            AccountType::Cash,
767            Some(Currency::pUSD()),
768        )
769    }
770
771    #[rstest]
772    fn test_build_ws_order_status_report() {
773        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
774        let instrument = test_instrument();
775        let ts_event = UnixNanos::from(1_000_000_000u64);
776        let ts_init = UnixNanos::from(2_000_000_000u64);
777
778        let report = build_ws_order_status_report(
779            &order,
780            &instrument,
781            AccountId::from("POLY-001"),
782            ts_event,
783            ts_init,
784        );
785
786        assert_eq!(report.order_side, OrderSide::Buy);
787        assert_eq!(report.order_type, OrderType::Limit);
788        assert!(report.price.is_some());
789        assert_eq!(report.ts_accepted, ts_event);
790        assert_eq!(report.ts_init, ts_init);
791    }
792
793    #[rstest]
794    fn test_build_ws_order_status_report_venue_cancel_maps_to_canceled() {
795        let order: PolymarketUserOrder = load("ws_user_order_venue_cancel.json");
796        let instrument = test_instrument();
797        let ts_event = UnixNanos::from(1_000_000_000u64);
798        let ts_init = UnixNanos::from(2_000_000_000u64);
799
800        let report = build_ws_order_status_report(
801            &order,
802            &instrument,
803            AccountId::from("POLY-001"),
804            ts_event,
805            ts_init,
806        );
807
808        assert_eq!(report.order_status, OrderStatus::Canceled);
809    }
810
811    #[rstest]
812    fn test_build_ws_taker_fill_report() {
813        let trade: PolymarketUserTrade = load("ws_user_trade.json");
814        let instrument = test_instrument();
815        let ts_event = UnixNanos::from(1_000_000_000u64);
816        let ts_init = UnixNanos::from(2_000_000_000u64);
817
818        let report = build_ws_taker_fill_report(
819            &trade,
820            &instrument,
821            AccountId::from("POLY-001"),
822            LiquiditySide::Taker,
823            ts_event,
824            ts_init,
825        );
826
827        assert_eq!(report.order_side, OrderSide::Buy);
828        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
829        assert_eq!(report.ts_event, ts_event);
830        assert_eq!(report.ts_init, ts_init);
831    }
832
833    #[rstest]
834    fn test_dispatch_order_message_buffers_when_not_accepted() {
835        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
836        let instrument = test_instrument();
837
838        let token_instruments = AtomicMap::new();
839        token_instruments.insert(order.asset_id, instrument);
840
841        let fill_tracker = OrderFillTrackerMap::new();
842        let pending_submits = PendingSubmitTracker::default();
843        let order_identities = OrderIdentityRegistry::default();
844        let emitter = test_emitter();
845
846        let ctx = WsDispatchContext {
847            token_instruments: &token_instruments,
848            fill_tracker: &fill_tracker,
849            pending_submits: &pending_submits,
850            order_identities: &order_identities,
851            emitter: &emitter,
852            account_id: AccountId::from("POLY-001"),
853            clock: nautilus_core::time::get_atomic_clock_realtime(),
854            user_address: "0xtest",
855            user_api_key: "test-key",
856        };
857        let mut state = WsDispatchState::default();
858
859        let result = dispatch_user_message(&UserWsMessage::Order(order.clone()), &ctx, &mut state);
860        assert!(result.is_none());
861
862        // Order not registered in fill_tracker, so should be buffered
863        let venue_order_id = VenueOrderId::from(order.id.as_str());
864        assert!(fill_tracker.has_pending_report(&venue_order_id));
865    }
866
867    #[rstest]
868    fn test_dispatch_order_message_uses_pending_submit_client_order_id() {
869        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
870        let instrument = test_instrument();
871
872        let token_instruments = AtomicMap::new();
873        token_instruments.insert(order.asset_id, instrument);
874
875        let fill_tracker = OrderFillTrackerMap::new();
876        let pending_submits = PendingSubmitTracker::default();
877        let order_identities = OrderIdentityRegistry::default();
878        let mut emitter = test_emitter();
879        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
880        emitter.set_sender(sender);
881
882        let venue_order_id = VenueOrderId::from(order.id.as_str());
883        let client_order_id = ClientOrderId::from("O-UNKNOWN-SUBMIT");
884        pending_submits.insert(venue_order_id, client_order_id);
885        register_identity(
886            &order_identities,
887            venue_order_id,
888            test_instrument().id(),
889            "O-UNKNOWN-SUBMIT",
890        );
891
892        let ctx = WsDispatchContext {
893            token_instruments: &token_instruments,
894            fill_tracker: &fill_tracker,
895            pending_submits: &pending_submits,
896            order_identities: &order_identities,
897            emitter: &emitter,
898            account_id: AccountId::from("POLY-001"),
899            clock: nautilus_core::time::get_atomic_clock_realtime(),
900            user_address: "0xtest",
901            user_api_key: "test-key",
902        };
903        let mut state = WsDispatchState::default();
904
905        let _ = dispatch_user_message(&UserWsMessage::Order(order), &ctx, &mut state);
906
907        // The tracked own order emits an OrderAccepted event carrying the client order ID.
908        let event = receiver.try_recv().expect("expected accepted event");
909        match event {
910            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => {
911                assert_eq!(accepted.client_order_id, client_order_id);
912            }
913            other => panic!("Expected accepted event, was {other:?}"),
914        }
915
916        assert!(!fill_tracker.has_pending_report(&venue_order_id));
917    }
918
919    #[rstest]
920    fn test_dispatch_trade_dedup() {
921        let trade: PolymarketUserTrade = load("ws_user_trade.json");
922        let instrument = test_instrument();
923
924        let token_instruments = AtomicMap::new();
925        token_instruments.insert(trade.asset_id, instrument);
926
927        let fill_tracker = OrderFillTrackerMap::new();
928        let pending_submits = PendingSubmitTracker::default();
929        let order_identities = OrderIdentityRegistry::default();
930        let emitter = test_emitter();
931
932        let ctx = WsDispatchContext {
933            token_instruments: &token_instruments,
934            fill_tracker: &fill_tracker,
935            pending_submits: &pending_submits,
936            order_identities: &order_identities,
937            emitter: &emitter,
938            account_id: AccountId::from("POLY-001"),
939            clock: nautilus_core::time::get_atomic_clock_realtime(),
940            user_address: "0xtest",
941            user_api_key: "test-key",
942        };
943        let mut state = WsDispatchState::default();
944
945        let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
946
947        // First dispatch processes the trade
948        let _ = dispatch_user_message(&UserWsMessage::Trade(trade.clone()), &ctx, &mut state);
949        assert_eq!(fill_tracker.pending_fills_for(&venue_order_id).len(), 1);
950
951        // Second dispatch should be deduped, no additional fill
952        let _ = dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
953        assert_eq!(fill_tracker.pending_fills_for(&venue_order_id).len(), 1);
954    }
955
956    #[rstest]
957    fn test_dispatch_trade_uses_pending_submit_client_order_id() {
958        let trade: PolymarketUserTrade = load("ws_user_trade.json");
959        let instrument = test_instrument();
960
961        let token_instruments = AtomicMap::new();
962        token_instruments.insert(trade.asset_id, instrument);
963
964        let fill_tracker = OrderFillTrackerMap::new();
965        let pending_submits = PendingSubmitTracker::default();
966        let order_identities = OrderIdentityRegistry::default();
967        let emitter = test_emitter();
968
969        let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
970        let client_order_id = ClientOrderId::from("O-UNKNOWN-FILL");
971        pending_submits.insert(venue_order_id, client_order_id);
972
973        let ctx = WsDispatchContext {
974            token_instruments: &token_instruments,
975            fill_tracker: &fill_tracker,
976            pending_submits: &pending_submits,
977            order_identities: &order_identities,
978            emitter: &emitter,
979            account_id: AccountId::from("POLY-001"),
980            clock: nautilus_core::time::get_atomic_clock_realtime(),
981            user_address: "0xtest",
982            user_api_key: "test-key",
983        };
984        let mut state = WsDispatchState::default();
985
986        let _ = dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
987
988        let fills = fill_tracker.pending_fills_for(&venue_order_id);
989        assert_eq!(fills[0].client_order_id, Some(client_order_id));
990    }
991
992    #[rstest]
993    fn test_dispatch_order_matched_caps_filled_qty_when_no_trades_tracked() {
994        let order: PolymarketUserOrder = load("ws_user_order_matched.json");
995        let instrument = test_instrument();
996
997        let token_instruments = AtomicMap::new();
998        token_instruments.insert(order.asset_id, instrument.clone());
999
1000        let fill_tracker = OrderFillTrackerMap::new();
1001        let venue_order_id = VenueOrderId::from(order.id.as_str());
1002
1003        // Register order so it is "accepted" but with no fills tracked
1004        fill_tracker.register(
1005            venue_order_id,
1006            Quantity::from("100"),
1007            OrderSide::Buy,
1008            instrument.id(),
1009            instrument.size_precision(),
1010            instrument.price_precision(),
1011        );
1012
1013        let pending_submits = PendingSubmitTracker::default();
1014        // No identity registered, so the order surfaces as a report (the external/reconciliation
1015        // fallback), where filled_qty is capped to tracked fills.
1016        let order_identities = OrderIdentityRegistry::default();
1017        let mut emitter = test_emitter();
1018        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1019        emitter.set_sender(sender);
1020
1021        let ctx = WsDispatchContext {
1022            token_instruments: &token_instruments,
1023            fill_tracker: &fill_tracker,
1024            pending_submits: &pending_submits,
1025            order_identities: &order_identities,
1026            emitter: &emitter,
1027            account_id: AccountId::from("POLY-001"),
1028            clock: nautilus_core::time::get_atomic_clock_realtime(),
1029            user_address: "0xtest",
1030            user_api_key: "test-key",
1031        };
1032        let mut state = WsDispatchState::default();
1033
1034        dispatch_user_message(&UserWsMessage::Order(order), &ctx, &mut state);
1035
1036        let event = receiver.try_recv().expect("Expected report");
1037        match event {
1038            ExecutionEvent::Report(report) => match report {
1039                ExecutionReport::Order(order_report) => {
1040                    assert_eq!(order_report.filled_qty, Quantity::from("0"));
1041                }
1042                other => panic!("Expected order report, was {other:?}"),
1043            },
1044            other => panic!("Expected report event, was {other:?}"),
1045        }
1046    }
1047
1048    #[rstest]
1049    fn test_dispatch_order_matched_uses_tracked_fills_for_filled_qty() {
1050        let order: PolymarketUserOrder = load("ws_user_order_matched.json");
1051        let instrument = test_instrument();
1052
1053        let token_instruments = AtomicMap::new();
1054        token_instruments.insert(order.asset_id, instrument.clone());
1055
1056        let fill_tracker = OrderFillTrackerMap::new();
1057        let venue_order_id = VenueOrderId::from(order.id.as_str());
1058
1059        // Register and record a partial fill (50 of 100)
1060        fill_tracker.register(
1061            venue_order_id,
1062            Quantity::from("100"),
1063            OrderSide::Buy,
1064            instrument.id(),
1065            instrument.size_precision(),
1066            instrument.price_precision(),
1067        );
1068        fill_tracker.record_fill(&venue_order_id, 50.0, 0.5, UnixNanos::from(1_000u64));
1069
1070        let pending_submits = PendingSubmitTracker::default();
1071        // No identity registered, so the order surfaces as a report (the external/reconciliation
1072        // fallback), where filled_qty is capped to tracked fills.
1073        let order_identities = OrderIdentityRegistry::default();
1074        let mut emitter = test_emitter();
1075        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1076        emitter.set_sender(sender);
1077
1078        let ctx = WsDispatchContext {
1079            token_instruments: &token_instruments,
1080            fill_tracker: &fill_tracker,
1081            pending_submits: &pending_submits,
1082            order_identities: &order_identities,
1083            emitter: &emitter,
1084            account_id: AccountId::from("POLY-001"),
1085            clock: nautilus_core::time::get_atomic_clock_realtime(),
1086            user_address: "0xtest",
1087            user_api_key: "test-key",
1088        };
1089        let mut state = WsDispatchState::default();
1090
1091        dispatch_user_message(&UserWsMessage::Order(order), &ctx, &mut state);
1092
1093        let event = receiver.try_recv().expect("Expected report");
1094        match event {
1095            ExecutionEvent::Report(report) => match report {
1096                ExecutionReport::Order(order_report) => {
1097                    assert_eq!(order_report.filled_qty, Quantity::from("50"));
1098                }
1099                other => panic!("Expected order report, was {other:?}"),
1100            },
1101            other => panic!("Expected report event, was {other:?}"),
1102        }
1103    }
1104
1105    #[rstest]
1106    fn test_dispatch_order_matched_dust_fill_uses_local_ts_init() {
1107        let order: PolymarketUserOrder = load("ws_user_order_matched.json");
1108        let instrument = test_instrument();
1109
1110        let token_instruments = AtomicMap::new();
1111        token_instruments.insert(order.asset_id, instrument.clone());
1112
1113        let fill_tracker = OrderFillTrackerMap::new();
1114        let venue_order_id = VenueOrderId::from(order.id.as_str());
1115        fill_tracker.register(
1116            venue_order_id,
1117            Quantity::from("100"),
1118            OrderSide::Buy,
1119            instrument.id(),
1120            instrument.size_precision(),
1121            instrument.price_precision(),
1122        );
1123        fill_tracker.record_fill(&venue_order_id, 99.995, 0.5, UnixNanos::from(1_000u64));
1124
1125        let pending_submits = PendingSubmitTracker::default();
1126        let order_identities = OrderIdentityRegistry::default();
1127        register_identity(
1128            &order_identities,
1129            venue_order_id,
1130            instrument.id(),
1131            "O-MATCHED",
1132        );
1133        order_identities.mark_accepted(venue_order_id);
1134        let mut emitter = test_emitter();
1135        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1136        emitter.set_sender(sender);
1137
1138        let clock = Box::leak(Box::new(AtomicTime::new(
1139            false,
1140            UnixNanos::from(2_000_000_000u64),
1141        )));
1142
1143        let ctx = WsDispatchContext {
1144            token_instruments: &token_instruments,
1145            fill_tracker: &fill_tracker,
1146            pending_submits: &pending_submits,
1147            order_identities: &order_identities,
1148            emitter: &emitter,
1149            account_id: AccountId::from("POLY-001"),
1150            clock,
1151            user_address: "0xtest",
1152            user_api_key: "test-key",
1153        };
1154        let mut state = WsDispatchState::default();
1155
1156        dispatch_user_message(&UserWsMessage::Order(order), &ctx, &mut state);
1157
1158        // Acceptance was already emitted at submit, so the MATCHED message emits no order
1159        // event; the dust residual converts to an OrderFilled event carrying the local ts_init.
1160        let event = receiver.try_recv().expect("Expected dust filled event");
1161        match event {
1162            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1163                assert_eq!(
1164                    filled.ts_event,
1165                    UnixNanos::from(1_703_875_201_000_000_000u64)
1166                );
1167                assert_eq!(filled.ts_init, UnixNanos::from(2_000_000_000u64));
1168            }
1169            other => panic!("Expected filled event, was {other:?}"),
1170        }
1171    }
1172
1173    #[rstest]
1174    fn test_cancel_reemitted_after_fill_for_canceled_order() {
1175        let cancel_order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
1176        let trade: PolymarketUserTrade = load("ws_user_trade.json");
1177        let instrument = test_instrument();
1178
1179        let token_instruments = AtomicMap::new();
1180        token_instruments.insert(cancel_order.asset_id, instrument.clone());
1181
1182        let fill_tracker = OrderFillTrackerMap::new();
1183        let venue_order_id = VenueOrderId::from(cancel_order.id.as_str());
1184
1185        // Register order as accepted with original qty=100
1186        fill_tracker.register(
1187            venue_order_id,
1188            Quantity::from("100"),
1189            OrderSide::Buy,
1190            instrument.id(),
1191            instrument.size_precision(),
1192            instrument.price_precision(),
1193        );
1194
1195        let pending_submits = PendingSubmitTracker::default();
1196        let order_identities = OrderIdentityRegistry::default();
1197        register_identity(
1198            &order_identities,
1199            venue_order_id,
1200            instrument.id(),
1201            "O-CANCEL",
1202        );
1203        order_identities.mark_accepted(venue_order_id);
1204        let mut emitter = test_emitter();
1205        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1206        emitter.set_sender(sender);
1207
1208        let ctx = WsDispatchContext {
1209            token_instruments: &token_instruments,
1210            fill_tracker: &fill_tracker,
1211            pending_submits: &pending_submits,
1212            order_identities: &order_identities,
1213            emitter: &emitter,
1214            account_id: AccountId::from("POLY-001"),
1215            clock: nautilus_core::time::get_atomic_clock_realtime(),
1216            user_address: "0xtest",
1217            user_api_key: "test-key",
1218        };
1219        let mut state = WsDispatchState::default();
1220
1221        // Step 1: Dispatch cancel (simulates message A from the bug)
1222        dispatch_user_message(&UserWsMessage::Order(cancel_order), &ctx, &mut state);
1223        let cancel_event = receiver.try_recv().expect("Expected canceled event");
1224        match &cancel_event {
1225            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1226                assert_eq!(c.venue_order_id, Some(venue_order_id));
1227            }
1228            other => panic!("Expected canceled event, was {other:?}"),
1229        }
1230
1231        // Step 2: Dispatch trade fill (simulates trade arriving after cancel)
1232        dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
1233
1234        // Should get: filled event, then re-emitted canceled event
1235        let fill_event = receiver.try_recv().expect("Expected filled event");
1236        match &fill_event {
1237            ExecutionEvent::Order(OrderEventAny::Filled(f)) => {
1238                assert_eq!(f.venue_order_id, venue_order_id);
1239            }
1240            other => panic!("Expected filled event, was {other:?}"),
1241        }
1242
1243        let reemitted_cancel = receiver
1244            .try_recv()
1245            .expect("Expected re-emitted canceled event");
1246
1247        match &reemitted_cancel {
1248            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1249                assert_eq!(c.venue_order_id, Some(venue_order_id));
1250            }
1251            other => panic!("Expected canceled event, was {other:?}"),
1252        }
1253    }
1254
1255    #[rstest]
1256    fn test_cancel_not_reemitted_when_fill_completes_order() {
1257        let cancel_order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
1258        let trade: PolymarketUserTrade = load("ws_user_trade.json");
1259        let instrument = test_instrument();
1260
1261        let token_instruments = AtomicMap::new();
1262        token_instruments.insert(cancel_order.asset_id, instrument.clone());
1263
1264        let fill_tracker = OrderFillTrackerMap::new();
1265        let venue_order_id = VenueOrderId::from(cancel_order.id.as_str());
1266
1267        // Register with qty=25 matching the trade size so the fill completes the order
1268        fill_tracker.register(
1269            venue_order_id,
1270            Quantity::from("25"),
1271            OrderSide::Buy,
1272            instrument.id(),
1273            instrument.size_precision(),
1274            instrument.price_precision(),
1275        );
1276
1277        let pending_submits = PendingSubmitTracker::default();
1278        let order_identities = OrderIdentityRegistry::default();
1279        register_identity(
1280            &order_identities,
1281            venue_order_id,
1282            instrument.id(),
1283            "O-CANCEL-FULL",
1284        );
1285        order_identities.mark_accepted(venue_order_id);
1286        let mut emitter = test_emitter();
1287        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1288        emitter.set_sender(sender);
1289
1290        let ctx = WsDispatchContext {
1291            token_instruments: &token_instruments,
1292            fill_tracker: &fill_tracker,
1293            pending_submits: &pending_submits,
1294            order_identities: &order_identities,
1295            emitter: &emitter,
1296            account_id: AccountId::from("POLY-001"),
1297            clock: nautilus_core::time::get_atomic_clock_realtime(),
1298            user_address: "0xtest",
1299            user_api_key: "test-key",
1300        };
1301        let mut state = WsDispatchState::default();
1302
1303        // Cancel then fill that completes the order
1304        dispatch_user_message(&UserWsMessage::Order(cancel_order), &ctx, &mut state);
1305        let _cancel = receiver.try_recv().expect("Expected canceled event");
1306
1307        dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
1308        let _fill = receiver.try_recv().expect("Expected filled event");
1309
1310        // Channel should be empty: no re-emitted cancel for a fully-filled order
1311        assert!(
1312            receiver.try_recv().is_err(),
1313            "Should not re-emit cancel when fill completes the order"
1314        );
1315    }
1316
1317    #[rstest]
1318    fn test_cancel_saved_before_acceptance() {
1319        let cancel_order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
1320        let instrument = test_instrument();
1321
1322        let token_instruments = AtomicMap::new();
1323        token_instruments.insert(cancel_order.asset_id, instrument);
1324
1325        // Fill tracker has NO registration (simulates HTTP still in-flight)
1326        let fill_tracker = OrderFillTrackerMap::new();
1327        let venue_order_id = VenueOrderId::from(cancel_order.id.as_str());
1328
1329        let pending_submits = PendingSubmitTracker::default();
1330        let order_identities = OrderIdentityRegistry::default();
1331        let emitter = test_emitter();
1332
1333        let ctx = WsDispatchContext {
1334            token_instruments: &token_instruments,
1335            fill_tracker: &fill_tracker,
1336            pending_submits: &pending_submits,
1337            order_identities: &order_identities,
1338            emitter: &emitter,
1339            account_id: AccountId::from("POLY-001"),
1340            clock: nautilus_core::time::get_atomic_clock_realtime(),
1341            user_address: "0xtest",
1342            user_api_key: "test-key",
1343        };
1344        let mut state = WsDispatchState::default();
1345
1346        // Dispatch cancel while order is not yet accepted
1347        dispatch_user_message(&UserWsMessage::Order(cancel_order), &ctx, &mut state);
1348
1349        // Cancel should be buffered (not emitted) AND saved to terminal_cancel_reports
1350        assert!(fill_tracker.has_pending_report(&venue_order_id));
1351        assert!(state.terminal_cancel_reports.get(&venue_order_id).is_some());
1352    }
1353
1354    /// Replays the exact 5-message WS sequence from issue #3797.
1355    ///
1356    /// Messages in arrival order:
1357    ///   (A) Order Canceled, size_matched=0
1358    ///   (B) Trade fill 1.219511 (maker side)
1359    ///   (C) Order Canceled, size_matched=1.219511
1360    ///   (D) Order Canceled, size_matched=2.560972 (capped to tracked)
1361    ///   (E) Trade fill 1.341461 (maker side)
1362    ///
1363    /// Without the fix, the order ends in PartiallyFilled after (E).
1364    /// With the fix, a re-emitted cancel after (E) restores Canceled.
1365    #[rstest]
1366    fn test_issue_3797_interleaved_cancel_fill_sequence() {
1367        use crate::common::{
1368            enums::{
1369                PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
1370                PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
1371                PolymarketTradeStatus,
1372            },
1373            models::PolymarketMakerOrder,
1374        };
1375
1376        let instrument = test_instrument();
1377        let asset_id = instrument.id().symbol.inner();
1378
1379        let order_id =
1380            "0xe743f6c823ecdfa9ddaaf08673b2441d15a38d89e14dcb25b3b70c284be4f6ad".to_string();
1381        let venue_order_id = VenueOrderId::from(order_id.as_str());
1382
1383        let token_instruments = AtomicMap::new();
1384        token_instruments.insert(asset_id, instrument.clone());
1385
1386        let fill_tracker = OrderFillTrackerMap::new();
1387        fill_tracker.register(
1388            venue_order_id,
1389            Quantity::from("20"),
1390            OrderSide::Buy,
1391            instrument.id(),
1392            instrument.size_precision(),
1393            instrument.price_precision(),
1394        );
1395
1396        let pending_submits = PendingSubmitTracker::default();
1397        let order_identities = OrderIdentityRegistry::default();
1398        register_identity(&order_identities, venue_order_id, instrument.id(), "O-3797");
1399        order_identities.mark_accepted(venue_order_id);
1400        let mut emitter = test_emitter();
1401        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1402        emitter.set_sender(sender);
1403
1404        let ctx = WsDispatchContext {
1405            token_instruments: &token_instruments,
1406            fill_tracker: &fill_tracker,
1407            pending_submits: &pending_submits,
1408            order_identities: &order_identities,
1409            emitter: &emitter,
1410            account_id: AccountId::from("POLY-001"),
1411            clock: nautilus_core::time::get_atomic_clock_realtime(),
1412            user_address: "0xabc",
1413            user_api_key: "xxx",
1414        };
1415        let mut state = WsDispatchState::default();
1416
1417        // Helper to build order updates
1418        let make_order =
1419            |size_matched: &str, ts: &str, event_type: PolymarketEventType| PolymarketUserOrder {
1420                asset_id,
1421                associate_trades: None,
1422                created_at: "1775074735".to_string(),
1423                expiration: Some("0".to_string()),
1424                id: order_id.clone(),
1425                maker_address: Ustr::from("0xabc"),
1426                market: Ustr::from("0x4134"),
1427                order_owner: Ustr::from("xxx"),
1428                order_type: PolymarketOrderType::GTC,
1429                original_size: "20".to_string(),
1430                outcome: PolymarketOutcome::yes(),
1431                owner: Ustr::from("xxx"),
1432                price: "0.18".to_string(),
1433                side: PolymarketOrderSide::Buy,
1434                size_matched: size_matched.to_string(),
1435                status: PolymarketOrderStatus::Canceled,
1436                timestamp: ts.to_string(),
1437                event_type,
1438            };
1439
1440        // Helper to build maker trades
1441        let make_trade = |trade_id: &str, matched_amount: f64, ts: &str| PolymarketUserTrade {
1442            asset_id,
1443            bucket_index: 0,
1444            fee_rate_bps: "1000".to_string(),
1445            id: trade_id.to_string(),
1446            last_update: "1775074738".to_string(),
1447            maker_address: Ustr::from("0xother"),
1448            maker_orders: vec![PolymarketMakerOrder {
1449                asset_id,
1450                maker_address: "0xabc".to_string(),
1451                matched_amount: Decimal::from_f64_retain(matched_amount).unwrap_or(Decimal::ZERO),
1452                order_id: order_id.clone(),
1453                outcome: PolymarketOutcome::yes(),
1454                owner: "xxx".to_string(),
1455                price: Decimal::from_f64_retain(0.18).unwrap_or(Decimal::ZERO),
1456                side: None,
1457            }],
1458            market: Ustr::from("0x4134"),
1459            match_time: "1775074735".to_string(),
1460            outcome: PolymarketOutcome::yes(),
1461            owner: Ustr::from("other-owner"),
1462            price: "0.82".to_string(),
1463            side: PolymarketOrderSide::Buy,
1464            size: "1.219511".to_string(),
1465            status: PolymarketTradeStatus::Matched,
1466            taker_order_id: "0xtaker01".to_string(),
1467            timestamp: ts.to_string(),
1468            trade_owner: Ustr::from("other-owner"),
1469            trader_side: PolymarketLiquiditySide::Maker,
1470            event_type: PolymarketEventType::Trade,
1471        };
1472
1473        // (A) Cancel with size_matched=0
1474        let msg_a = make_order("0", "1775074738031", PolymarketEventType::Cancellation);
1475        dispatch_user_message(&UserWsMessage::Order(msg_a), &ctx, &mut state);
1476
1477        let evt = receiver.try_recv().expect("(A) canceled event");
1478        match &evt {
1479            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1480                assert_eq!(c.venue_order_id, Some(venue_order_id));
1481            }
1482            other => panic!("(A) expected canceled event, was {other:?}"),
1483        }
1484
1485        // (B) Trade fill 1.219511
1486        let msg_b = make_trade("trade-b", 1.219511, "1775074738032");
1487        dispatch_user_message(&UserWsMessage::Trade(msg_b), &ctx, &mut state);
1488
1489        let evt = receiver.try_recv().expect("(B) filled event");
1490        match &evt {
1491            ExecutionEvent::Order(OrderEventAny::Filled(f)) => {
1492                assert_eq!(f.venue_order_id, venue_order_id);
1493            }
1494            other => panic!("(B) expected filled event, was {other:?}"),
1495        }
1496        // Re-emitted cancel after fill (B)
1497        let evt = receiver.try_recv().expect("(B) re-emitted cancel");
1498        match &evt {
1499            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1500                assert_eq!(c.venue_order_id, Some(venue_order_id));
1501            }
1502            other => panic!("(B) expected re-emitted cancel, was {other:?}"),
1503        }
1504
1505        // (C) Cancel with size_matched=1.219511
1506        let msg_c = make_order("1.219511", "1775074738034", PolymarketEventType::Update);
1507        dispatch_user_message(&UserWsMessage::Order(msg_c), &ctx, &mut state);
1508
1509        let evt = receiver.try_recv().expect("(C) canceled event");
1510        match &evt {
1511            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1512                assert_eq!(c.venue_order_id, Some(venue_order_id));
1513            }
1514            other => panic!("(C) expected canceled event, was {other:?}"),
1515        }
1516
1517        // (D) Cancel with size_matched=2.560972 (capped to tracked 1.219511)
1518        let msg_d = make_order("2.560972", "1775074738038", PolymarketEventType::Update);
1519        dispatch_user_message(&UserWsMessage::Order(msg_d), &ctx, &mut state);
1520
1521        let evt = receiver.try_recv().expect("(D) canceled event");
1522        match &evt {
1523            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1524                assert_eq!(c.venue_order_id, Some(venue_order_id));
1525            }
1526            other => panic!("(D) expected canceled event, was {other:?}"),
1527        }
1528
1529        // (E) Trade fill 1.341461
1530        let msg_e = make_trade("trade-e", 1.341461, "1775074738036");
1531        dispatch_user_message(&UserWsMessage::Trade(msg_e), &ctx, &mut state);
1532
1533        let evt = receiver.try_recv().expect("(E) filled event");
1534        match &evt {
1535            ExecutionEvent::Order(OrderEventAny::Filled(f)) => {
1536                assert_eq!(f.venue_order_id, venue_order_id);
1537            }
1538            other => panic!("(E) expected filled event, was {other:?}"),
1539        }
1540
1541        // The fix: re-emitted cancel after (E) restores terminal state
1542        let evt = receiver.try_recv().expect("(E) re-emitted cancel");
1543        match &evt {
1544            ExecutionEvent::Order(OrderEventAny::Canceled(c)) => {
1545                assert_eq!(c.venue_order_id, Some(venue_order_id));
1546            }
1547            other => panic!("(E) expected re-emitted cancel, was {other:?}"),
1548        }
1549
1550        // No more events
1551        assert!(
1552            receiver.try_recv().is_err(),
1553            "No further events expected after the sequence"
1554        );
1555    }
1556
1557    #[rstest]
1558    fn test_dispatch_taker_fill_snaps_overfill_to_submitted_qty() {
1559        // Reproduces the V2 market-BUY scenario that motivated the dust-snap
1560        // fix: SDK truncates the registered qty to USDC scale, but the
1561        // on-chain fill comes back at full precision and exceeds submitted
1562        // by microshares. Without the snap the engine rejects as overfill.
1563        use crate::common::enums::{
1564            PolymarketEventType, PolymarketOrderSide, PolymarketOutcome, PolymarketTradeStatus,
1565        };
1566
1567        let instrument = test_instrument();
1568        let asset_id = instrument.id().symbol.inner();
1569        let token_instruments = AtomicMap::new();
1570        token_instruments.insert(asset_id, instrument.clone());
1571
1572        let fill_tracker = OrderFillTrackerMap::new();
1573        let venue_order_id = VenueOrderId::from("0xtaker-overfill");
1574        // Submitted qty truncated to USDC scale.
1575        let submitted = Quantity::new(714.285710, instrument.size_precision());
1576        fill_tracker.register(
1577            venue_order_id,
1578            submitted,
1579            OrderSide::Buy,
1580            instrument.id(),
1581            instrument.size_precision(),
1582            instrument.price_precision(),
1583        );
1584
1585        let pending_submits = PendingSubmitTracker::default();
1586        let order_identities = OrderIdentityRegistry::default();
1587        register_identity(
1588            &order_identities,
1589            venue_order_id,
1590            instrument.id(),
1591            "O-OVERFILL",
1592        );
1593        order_identities.mark_accepted(venue_order_id);
1594        let mut emitter = test_emitter();
1595        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1596        emitter.set_sender(sender);
1597
1598        let ctx = WsDispatchContext {
1599            token_instruments: &token_instruments,
1600            fill_tracker: &fill_tracker,
1601            pending_submits: &pending_submits,
1602            order_identities: &order_identities,
1603            emitter: &emitter,
1604            account_id: AccountId::from("POLY-001"),
1605            clock: nautilus_core::time::get_atomic_clock_realtime(),
1606            user_address: "0xtest",
1607            user_api_key: "test-key",
1608        };
1609        let mut state = WsDispatchState::default();
1610
1611        let trade = PolymarketUserTrade {
1612            asset_id,
1613            bucket_index: 0,
1614            fee_rate_bps: "0".to_string(),
1615            id: "trade-overfill".to_string(),
1616            last_update: "1700000001".to_string(),
1617            maker_address: Ustr::from("0xmaker"),
1618            maker_orders: vec![],
1619            market: Ustr::from("0xmarket"),
1620            match_time: "1700000000".to_string(),
1621            outcome: PolymarketOutcome::yes(),
1622            owner: Ustr::from("00000000-0000-0000-0000-000000000001"),
1623            price: "0.014".to_string(),
1624            side: PolymarketOrderSide::Buy,
1625            // Fill exceeds submitted_qty by 4 ulps at size_precision=6,
1626            // matching the production drift observed during smoke tests.
1627            size: "714.285714".to_string(),
1628            status: PolymarketTradeStatus::Matched,
1629            taker_order_id: venue_order_id.as_str().to_string(),
1630            timestamp: "1700000000000".to_string(),
1631            trade_owner: Ustr::from("00000000-0000-0000-0000-000000000001"),
1632            trader_side: PolymarketLiquiditySide::Taker,
1633            event_type: PolymarketEventType::Trade,
1634        };
1635
1636        dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
1637
1638        // The dispatcher must record the snapped quantity in the tracker so
1639        // any subsequent ORDER MATCHED with size_matched > submitted_qty is
1640        // capped to it. record_fill happens before the FillReport is sent.
1641        let cumulative = fill_tracker
1642            .get_cumulative_filled(&venue_order_id)
1643            .expect("order must be registered");
1644        let expected_snapped = submitted.as_f64();
1645        let drift = (cumulative - expected_snapped).abs();
1646        assert!(
1647            drift < 1e-9,
1648            "cumulative_filled {cumulative} must be snapped to submitted {expected_snapped}",
1649        );
1650
1651        // The emitted OrderFilled must carry the snapped qty so the engine
1652        // does not reject it as an overfill.
1653        let event = receiver.try_recv().expect("expected a filled event");
1654        match event {
1655            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1656                assert_eq!(
1657                    filled.last_qty, submitted,
1658                    "filled qty must be snapped to submitted",
1659                );
1660                assert_eq!(filled.venue_order_id, venue_order_id);
1661            }
1662            other => panic!("expected filled event, was {other:?}"),
1663        }
1664    }
1665
1666    #[rstest]
1667    fn test_dispatch_taker_fill_gross_overfill_raises_qty_then_fills() {
1668        // A marketable BUY filled below its limit returns more shares than the nominal qty (a
1669        // gross overfill, beyond the dust band). The dispatcher must raise the order qty via
1670        // OrderUpdated before the OrderFilled, or the engine drops the fill as an overfill.
1671        use crate::common::enums::{
1672            PolymarketEventType, PolymarketOrderSide, PolymarketOutcome, PolymarketTradeStatus,
1673        };
1674
1675        let instrument = test_instrument();
1676        let asset_id = instrument.id().symbol.inner();
1677        let size_precision = instrument.size_precision();
1678        let token_instruments = AtomicMap::new();
1679        token_instruments.insert(asset_id, instrument.clone());
1680
1681        let fill_tracker = OrderFillTrackerMap::new();
1682        let venue_order_id = VenueOrderId::from("0xtaker-gross-overfill");
1683        let submitted = Quantity::new(30.0, size_precision);
1684        fill_tracker.register(
1685            venue_order_id,
1686            submitted,
1687            OrderSide::Buy,
1688            instrument.id(),
1689            size_precision,
1690            instrument.price_precision(),
1691        );
1692
1693        let pending_submits = PendingSubmitTracker::default();
1694        let order_identities = OrderIdentityRegistry::default();
1695        register_identity(
1696            &order_identities,
1697            venue_order_id,
1698            instrument.id(),
1699            "O-GROSS-OVERFILL",
1700        );
1701        order_identities.mark_accepted(venue_order_id);
1702        let mut emitter = test_emitter();
1703        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1704        emitter.set_sender(sender);
1705
1706        let ctx = WsDispatchContext {
1707            token_instruments: &token_instruments,
1708            fill_tracker: &fill_tracker,
1709            pending_submits: &pending_submits,
1710            order_identities: &order_identities,
1711            emitter: &emitter,
1712            account_id: AccountId::from("POLY-001"),
1713            clock: nautilus_core::time::get_atomic_clock_realtime(),
1714            user_address: "0xtest",
1715            user_api_key: "test-key",
1716        };
1717        let mut state = WsDispatchState::default();
1718
1719        // 33.846152 shares against a nominal 30: a marketable fill below the limit price.
1720        let trade = PolymarketUserTrade {
1721            asset_id,
1722            bucket_index: 0,
1723            fee_rate_bps: "0".to_string(),
1724            id: "trade-gross-overfill".to_string(),
1725            last_update: "1700000001".to_string(),
1726            maker_address: Ustr::from("0xmaker"),
1727            maker_orders: vec![],
1728            market: Ustr::from("0xmarket"),
1729            match_time: "1700000000".to_string(),
1730            outcome: PolymarketOutcome::yes(),
1731            owner: Ustr::from("00000000-0000-0000-0000-000000000001"),
1732            price: "0.014".to_string(),
1733            side: PolymarketOrderSide::Buy,
1734            size: "33.846152".to_string(),
1735            status: PolymarketTradeStatus::Matched,
1736            taker_order_id: venue_order_id.as_str().to_string(),
1737            timestamp: "1700000000000".to_string(),
1738            trade_owner: Ustr::from("00000000-0000-0000-0000-000000000001"),
1739            trader_side: PolymarketLiquiditySide::Taker,
1740            event_type: PolymarketEventType::Trade,
1741        };
1742
1743        dispatch_user_message(&UserWsMessage::Trade(trade), &ctx, &mut state);
1744
1745        let expected_qty = Quantity::new(33.846152, size_precision);
1746
1747        // The raise must precede the fill so the engine accepts the larger quantity.
1748        match receiver.try_recv().expect("expected an updated event") {
1749            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
1750                assert_eq!(updated.quantity, expected_qty);
1751                assert_eq!(updated.venue_order_id, Some(venue_order_id));
1752            }
1753            other => panic!("expected updated event raising qty to the fill, was {other:?}"),
1754        }
1755
1756        match receiver.try_recv().expect("expected a filled event") {
1757            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1758                assert_eq!(filled.last_qty, expected_qty);
1759                assert_eq!(filled.venue_order_id, venue_order_id);
1760            }
1761            other => panic!("expected filled event, was {other:?}"),
1762        }
1763    }
1764
1765    // Unmatched -> Rejected (placement never became live); CanceledMarketResolved -> Expired
1766    // (market settled). Both are tracked own-order terminal states emitted as order events.
1767    #[rstest]
1768    #[case(crate::common::enums::PolymarketOrderStatus::Unmatched, "Rejected")]
1769    #[case(
1770        crate::common::enums::PolymarketOrderStatus::CanceledMarketResolved,
1771        "Expired"
1772    )]
1773    fn test_dispatch_order_terminal_status_emits_event(
1774        #[case] status: crate::common::enums::PolymarketOrderStatus,
1775        #[case] expected: &str,
1776    ) {
1777        use crate::common::enums::{
1778            PolymarketEventType, PolymarketOrderSide, PolymarketOrderType, PolymarketOutcome,
1779        };
1780
1781        let instrument = test_instrument();
1782        let asset_id = instrument.id().symbol.inner();
1783        let order_id = "0xterminal-order".to_string();
1784        let venue_order_id = VenueOrderId::from(order_id.as_str());
1785
1786        let token_instruments = AtomicMap::new();
1787        token_instruments.insert(asset_id, instrument.clone());
1788
1789        let fill_tracker = OrderFillTrackerMap::new();
1790        fill_tracker.register(
1791            venue_order_id,
1792            Quantity::from("10"),
1793            OrderSide::Buy,
1794            instrument.id(),
1795            instrument.size_precision(),
1796            instrument.price_precision(),
1797        );
1798
1799        let pending_submits = PendingSubmitTracker::default();
1800        let order_identities = OrderIdentityRegistry::default();
1801        register_identity(
1802            &order_identities,
1803            venue_order_id,
1804            instrument.id(),
1805            "O-TERMINAL",
1806        );
1807        order_identities.mark_accepted(venue_order_id);
1808        let mut emitter = test_emitter();
1809        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1810        emitter.set_sender(sender);
1811
1812        let ctx = WsDispatchContext {
1813            token_instruments: &token_instruments,
1814            fill_tracker: &fill_tracker,
1815            pending_submits: &pending_submits,
1816            order_identities: &order_identities,
1817            emitter: &emitter,
1818            account_id: AccountId::from("POLY-001"),
1819            clock: nautilus_core::time::get_atomic_clock_realtime(),
1820            user_address: "0xabc",
1821            user_api_key: "xxx",
1822        };
1823        let mut state = WsDispatchState::default();
1824
1825        let order = PolymarketUserOrder {
1826            asset_id,
1827            associate_trades: None,
1828            created_at: "1775074735".to_string(),
1829            expiration: Some("0".to_string()),
1830            id: order_id,
1831            maker_address: Ustr::from("0xabc"),
1832            market: Ustr::from("0x4134"),
1833            order_owner: Ustr::from("xxx"),
1834            order_type: PolymarketOrderType::FOK,
1835            original_size: "10".to_string(),
1836            outcome: PolymarketOutcome::yes(),
1837            owner: Ustr::from("xxx"),
1838            price: "0.50".to_string(),
1839            side: PolymarketOrderSide::Buy,
1840            size_matched: "0".to_string(),
1841            status,
1842            timestamp: "1775074738031".to_string(),
1843            event_type: PolymarketEventType::Placement,
1844        };
1845
1846        dispatch_user_message(&UserWsMessage::Order(order), &ctx, &mut state);
1847
1848        let event = receiver.try_recv().expect("expected terminal order event");
1849        match event {
1850            ExecutionEvent::Order(order_event) => {
1851                assert!(
1852                    format!("{order_event:?}").starts_with(expected),
1853                    "expected {expected}, was {order_event:?}"
1854                );
1855                assert_eq!(
1856                    order_event.client_order_id(),
1857                    ClientOrderId::from("O-TERMINAL")
1858                );
1859            }
1860            other => panic!("expected order event, was {other:?}"),
1861        }
1862    }
1863}