From de0940a33f8e4a72c1f6b1e19feed929b2d6c8e0 Mon Sep 17 00:00:00 2001 From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:06:02 +0000 Subject: [PATCH 1/2] Echo MWL protection price on execution reports Thread MarketWithLeftover ord type and protection price through accepted/modified order events so ExecutionReport_New and ExecutionReport_Modify echo OrdType=K and the resting protection price when an MWL remainder rests. Closes #530 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/B3-ENTRYPOINT-COMPLIANCE.md | 2 +- src/B3.Exchange.Contracts/Ports.cs | 12 +++ .../ChannelDispatcher.Sinks.cs | 2 + .../ExecutionReportEncoder.cs | 19 ++-- .../FixpOutboundEncoder.cs | 12 ++- src/B3.Exchange.Gateway/FixpSession.cs | 7 +- src/B3.Exchange.Gateway/GatewayRouter.cs | 14 +++ src/B3.Exchange.Matching/Events.cs | 8 +- src/B3.Exchange.Matching/LimitOrderBook.cs | 2 + src/B3.Exchange.Matching/MatchingEngine.cs | 15 +++- .../ChannelDispatcherTests.cs | 48 ++++++++++ .../ExecutionReportEncoderTests.cs | 87 +++++++++++++++++++ 12 files changed, 211 insertions(+), 17 deletions(-) diff --git a/docs/B3-ENTRYPOINT-COMPLIANCE.md b/docs/B3-ENTRYPOINT-COMPLIANCE.md index 02a1396..eecf69c 100644 --- a/docs/B3-ENTRYPOINT-COMPLIANCE.md +++ b/docs/B3-ENTRYPOINT-COMPLIANCE.md @@ -171,7 +171,7 @@ contract supervision is a post-trade analytics concern. | GAP-25 | 7.1.20 | In-flight modification semantics (priority loss rules) | partial | medium | — | | GAP-26 | 8.3 | Daily GTC/GTD restatement at session boundary (carry-over + re-emit ER) | done | — | At each daily-reset boundary the exchange emits a private restatement `ExecutionReport_Modify` (`OrdStatus=RESTATED 'R'`, `ExecRestatementReason=GT_RESTATEMENT 1`) for every surviving GTC and unexpired-GTD order, plus parked GTC stop / stop-limit orders. The restatement echoes the order's real `OrdType` / `TimeInForce` / `ExpireDate` / `StopPx`, reports new-trading-day quantities (`CumQty=0`, `LeavesQty == OrderQty == open`, iceberg-aware), and changes no book state (no UMDF frame, no `RptSeq` advance, no eviction). Runs after `GtdExpirySweeper` so past-dated GTD orders are cancelled, not restated. ([#498](https://github.com/pedrosakuma/B3MatchingPlatform/issues/498), [#507](https://github.com/pedrosakuma/B3MatchingPlatform/pull/507)) Day-order boundary expiry is tracked separately by [#506](https://github.com/pedrosakuma/B3MatchingPlatform/issues/506). | | GAP-27 | 15.4 | Self-Trading Prevention (STPC) | missing | medium (in-scope per ADR 0012) | covered by [#14](https://github.com/pedrosakuma/B3MatchingPlatform/issues/14) | -| GAP-28 | 15.5 | Market Protections (price collars / fat-finger / max value) | partial | medium (in-scope per ADR 0012) | Engine-side static per-instrument price bands, auction-phase TOP collars, per-instrument max order quantity, and per-instrument max order value are implemented with existing ER reject reasons. The gateway dynamic last-trade-relative `priceBandPercent` remains as an outer decode-time guardrail. Outstanding: EntryPoint `protectionPrice` semantics for market orders are deferred. Tracked by [#500](https://github.com/pedrosakuma/B3MatchingPlatform/issues/500). | +| GAP-28 | 15.5 | Market Protections (price collars / fat-finger / max value) | done | — | Engine-side static per-instrument price bands, auction-phase TOP collars, per-instrument max order quantity, and per-instrument max order value are implemented with existing ER reject reasons. The gateway dynamic last-trade-relative `priceBandPercent` remains as an outer decode-time guardrail. EntryPoint `protectionPrice` is echoed on `ExecutionReport_New` / `ExecutionReport_Modify` for Market-with-leftover-as-Limit (`OrdType=K`) remainders that rest at the last execution price. Triggered Stop Protect → MWL behavior, if B3 requires it, is tracked separately and is not a GAP-28 blocker. Tracked by [#500](https://github.com/pedrosakuma/B3MatchingPlatform/issues/500) and [#530](https://github.com/pedrosakuma/B3MatchingPlatform/issues/530). | | GAP-29 | 15.1 | User-Defined Spreads (UDS) — synthetic multi-leg instruments | missing | low (boundary case; borderline between exchange-side and broker-side) | — | | GAP-30 | 16.6 | Sweep & Cross | partial | low (in-scope per ADR 0012) | Matching semantics for `CrossType=AgainstBook` are implemented (#218), and sweep-phase UMDF `Trade_53.trdSubType` now emits `SWEEP_TRADE` (109). Remaining wire refinements: residual-cancel `ExecRestatementReason=210` is intentionally out of scope while residuals rest, and EntryPoint ER `crossType` / `crossPrioritization` echo remains pending. | | GAP-31 | 7.1.19 / UMDF v2.2.0 `SecurityDefinition_12` | `SecurityDefinition_12` does not emit option fields (`strikePrice`, `putOrCall`, `exerciseStyle`, `contractMultiplier`, `noUnderlyings`, `optPayoutType`, `maturityMonthYear`). | missing | high | tracked via [RFC 0002](rfc/0002-equity-options-support.md) issue OPT-02 | diff --git a/src/B3.Exchange.Contracts/Ports.cs b/src/B3.Exchange.Contracts/Ports.cs index 73c66bc..71f0908 100644 --- a/src/B3.Exchange.Contracts/Ports.cs +++ b/src/B3.Exchange.Contracts/Ports.cs @@ -1,6 +1,7 @@ using B3.Exchange.Matching; using MatchingRejectEvent = B3.Exchange.Matching.RejectEvent; using MatchingSide = B3.Exchange.Matching.Side; +using MatchingOrderType = B3.Exchange.Matching.OrderType; namespace B3.Exchange.Contracts; @@ -72,6 +73,17 @@ bool WriteExecutionReportModify(SessionId session, long securityId, long orderId DurabilityHandle durability = default, InvestorId? investorId = null); + bool WriteExecutionReportModify(SessionId session, long securityId, long orderId, + ulong clOrdIdValue, ulong origClOrdIdValue, + MatchingSide side, long newPriceMantissa, long newRemainingQty, ulong transactTimeNanos, uint rptSeq, + MatchingOrderType ordType, long? protectionPriceMantissa, + ulong receivedTimeNanos = ulong.MaxValue, + DurabilityHandle durability = default, + InvestorId? investorId = null) + => WriteExecutionReportModify(session, securityId, orderId, clOrdIdValue, origClOrdIdValue, + side, newPriceMantissa, newRemainingQty, transactTimeNanos, rptSeq, + receivedTimeNanos, durability, investorId); + bool WriteExecutionReportReject(SessionId session, in MatchingRejectEvent e, ulong clOrdIdValue, DurabilityHandle durability = default); diff --git a/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs b/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs index 2d6913b..77c0a3e 100644 --- a/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs +++ b/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs @@ -84,6 +84,8 @@ public void OnOrderModified(in OrderModifiedEvent e) side: e.Side, newPriceMantissa: e.NewPriceMantissa, newRemainingQty: e.NewRemainingQuantity, transactTimeNanos: e.TransactTimeNanos, rptSeq: e.RptSeq, + ordType: e.OrdType, + protectionPriceMantissa: e.ProtectionPriceMantissa, receivedTimeNanos: _currentReceivedTimeNanos, durability: CurrentDurability, investorId: e.InvestorId); diff --git a/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs b/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs index 6b2f3e1..266721a 100644 --- a/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs +++ b/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs @@ -145,7 +145,8 @@ public static int EncodeExecReportNew(Span dst, uint sessionId, uint msgSeqNum, ulong sendingTimeNanos, Matching.Side side, ulong clOrdIdValue, long secondaryOrderId, long securityId, long orderId, ulong execId, ulong transactTimeNanos, Matching.OrderType ordType, Matching.TimeInForce tif, - long orderQty, long? priceMantissa, ReadOnlySpan memo = default, ulong receivedTimeNanos = UTCTimestampNullValue) + long orderQty, long? priceMantissa, ReadOnlySpan memo = default, ulong receivedTimeNanos = UTCTimestampNullValue, + long? protectionPriceMantissa = null) { int total = TotalSize(ExecReportNewBlock, memo.Length); if (dst.Length < total) throw new ArgumentException("buffer too small for ER_New", nameof(dst)); @@ -165,7 +166,10 @@ public static int EncodeExecReportNew(Span dst, ulong nullTs = UTCTimestampNullValue; MemoryMarshal.Write(body.Slice(72, 8), in nullTs); // MarketSegmentReceivedTime long nullPx = PriceNullMantissa; - MemoryMarshal.Write(body.Slice(80, 8), in nullPx); // ProtectionPrice + long protectionPx = ordType == Matching.OrderType.MarketWithLeftover + ? protectionPriceMantissa ?? PriceNullMantissa + : PriceNullMantissa; + MemoryMarshal.Write(body.Slice(80, 8), in protectionPx); // ProtectionPrice body[92] = EncodeOrdType(ordType); body[93] = EncodeTif(tif); MemoryMarshal.Write(body.Slice(96, 8), in orderQty); @@ -189,7 +193,9 @@ public static int EncodeExecReportModify(Span dst, long securityId, long orderId, ulong execId, ulong transactTimeNanos, long leavesQty, long cumQty, long orderQty, long priceMantissa, ReadOnlySpan memo = default, ulong receivedTimeNanos = UTCTimestampNullValue, - Matching.InvestorId? investorId = null) + Matching.InvestorId? investorId = null, + Matching.OrderType ordType = Matching.OrderType.Limit, + long? protectionPriceMantissa = null) { int total = TotalSize(ExecReportModifyBlock, memo.Length); if (dst.Length < total) throw new ArgumentException("buffer too small for ER_Modify", nameof(dst)); @@ -212,8 +218,11 @@ public static int EncodeExecReportModify(Span dst, MemoryMarshal.Write(body.Slice(88, 8), in orderId); MemoryMarshal.Write(body.Slice(96, 8), in origClOrdIdValue); long nullPx = PriceNullMantissa; - MemoryMarshal.Write(body.Slice(104, 8), in nullPx); // ProtectionPrice - body[116] = OrdTypeLimit; // OrdType - replace can only be on limit + long protectionPx = ordType == Matching.OrderType.MarketWithLeftover + ? protectionPriceMantissa ?? PriceNullMantissa + : PriceNullMantissa; + MemoryMarshal.Write(body.Slice(104, 8), in protectionPx); // ProtectionPrice + body[116] = EncodeOrdType(ordType); // OrdType body[117] = TifDay; // TimeInForce - replace inherits original; default Day MemoryMarshal.Write(body.Slice(120, 8), in orderQty); MemoryMarshal.Write(body.Slice(128, 8), in priceMantissa); diff --git a/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs b/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs index 403126e..e605801 100644 --- a/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs +++ b/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs @@ -82,9 +82,9 @@ public bool WriteExecutionReportNew(in OrderAcceptedEvent e, ulong receivedTimeN _sessionId(), _nextMsgSeqNum(), e.InsertTimestampNanos, e.Side, clOrd, e.OrderId, e.SecurityId, e.OrderId, (ulong)e.RptSeq, e.InsertTimestampNanos, - OrderType.Limit, TimeInForce.Day, + e.OrdType, TimeInForce.Day, e.RemainingQuantity, e.PriceMantissa, - memo.Span, receivedTimeNanos); + memo.Span, receivedTimeNanos, e.ProtectionPriceMantissa); return AppendAndEnqueueLocked(exact, durability); } } @@ -154,7 +154,9 @@ public bool WriteExecutionReportModify(long securityId, long orderId, ulong clOr Side side, long newPriceMantissa, long newRemainingQty, ulong transactTimeNanos, uint rptSeq, ulong receivedTimeNanos = ulong.MaxValue, DurabilityHandle durability = default, ReadOnlyMemory memo = default, - Matching.InvestorId? investorId = null) + Matching.InvestorId? investorId = null, + OrderType ordType = OrderType.Limit, + long? protectionPriceMantissa = null) { if (!_isOpen()) return false; var exact = PooledOutboundFrame.Rent(ExecutionReportEncoder.TotalSize(ExecutionReportEncoder.ExecReportModifyBlock, memo.Length)); @@ -168,7 +170,9 @@ public bool WriteExecutionReportModify(long securityId, long orderId, ulong clOr securityId, orderId, (ulong)rptSeq, transactTimeNanos, leavesQty: newRemainingQty, cumQty: 0, orderQty: newRemainingQty, priceMantissa: newPriceMantissa, memo: memo.Span, receivedTimeNanos: receivedTimeNanos, - investorId: investorId); + investorId: investorId, + ordType: ordType, + protectionPriceMantissa: protectionPriceMantissa); return AppendAndEnqueueLocked(exact, durability); } } diff --git a/src/B3.Exchange.Gateway/FixpSession.cs b/src/B3.Exchange.Gateway/FixpSession.cs index 1d5eada..fe14db5 100644 --- a/src/B3.Exchange.Gateway/FixpSession.cs +++ b/src/B3.Exchange.Gateway/FixpSession.cs @@ -616,9 +616,12 @@ public bool WriteExecutionReportModify(long securityId, long orderId, ulong clOr ulong receivedTimeNanos = ulong.MaxValue, DurabilityHandle durability = default, ReadOnlyMemory memo = default, - B3.Exchange.Matching.InvestorId? investorId = null) + B3.Exchange.Matching.InvestorId? investorId = null, + B3.Exchange.Matching.OrderType ordType = B3.Exchange.Matching.OrderType.Limit, + long? protectionPriceMantissa = null) => _outboundEncoder.WriteExecutionReportModify(securityId, orderId, clOrdIdValue, origClOrdIdValue, - side, newPriceMantissa, newRemainingQty, transactTimeNanos, rptSeq, receivedTimeNanos, durability, memo, investorId); + side, newPriceMantissa, newRemainingQty, transactTimeNanos, rptSeq, receivedTimeNanos, durability, memo, investorId, + ordType, protectionPriceMantissa); public bool WriteExecutionReportRestate(in B3.Exchange.Matching.OrderRestatedEvent e, ulong ownerClOrdId, DurabilityHandle durability = default, ReadOnlyMemory memo = default) diff --git a/src/B3.Exchange.Gateway/GatewayRouter.cs b/src/B3.Exchange.Gateway/GatewayRouter.cs index 09ea7b5..e22d86a 100644 --- a/src/B3.Exchange.Gateway/GatewayRouter.cs +++ b/src/B3.Exchange.Gateway/GatewayRouter.cs @@ -93,6 +93,20 @@ public bool WriteExecutionReportModify(ContractsSessionId session, long security memo: default, investorId: investorId); } + public bool WriteExecutionReportModify(ContractsSessionId session, long securityId, long orderId, + ulong clOrdIdValue, ulong origClOrdIdValue, + Side side, long newPriceMantissa, long newRemainingQty, ulong transactTimeNanos, uint rptSeq, + OrderType ordType, long? protectionPriceMantissa, + ulong receivedTimeNanos = ulong.MaxValue, + DurabilityHandle durability = default, + Matching.InvestorId? investorId = null) + { + if (!_registry.TryGet(session, out var s)) { LogMiss(session, "ExecReportModify"); return false; } + return s.WriteExecutionReportModify(securityId, orderId, clOrdIdValue, origClOrdIdValue, + side, newPriceMantissa, newRemainingQty, transactTimeNanos, rptSeq, receivedTimeNanos, durability, + memo: default, investorId: investorId, ordType: ordType, protectionPriceMantissa: protectionPriceMantissa); + } + public bool WriteExecutionReportReject(ContractsSessionId session, in RejectEvent e, ulong clOrdIdValue, DurabilityHandle durability = default) { diff --git a/src/B3.Exchange.Matching/Events.cs b/src/B3.Exchange.Matching/Events.cs index 500188b..b045538 100644 --- a/src/B3.Exchange.Matching/Events.cs +++ b/src/B3.Exchange.Matching/Events.cs @@ -20,7 +20,9 @@ public readonly record struct OrderAcceptedEvent( uint EnteringFirm, ulong InsertTimestampNanos, uint RptSeq, - byte[]? Memo = null); + byte[]? Memo = null, + OrderType OrdType = OrderType.Limit, + long? ProtectionPriceMantissa = null); /// /// Fired when a resting order's remaining quantity is reduced as a passive maker @@ -323,7 +325,9 @@ public readonly record struct OrderModifiedEvent( ulong TransactTimeNanos, uint RptSeq, byte[]? Memo = null, - InvestorId? InvestorId = null); + InvestorId? InvestorId = null, + OrderType OrdType = OrderType.Limit, + long? ProtectionPriceMantissa = null); /// /// GAP-26 / issue #498: fired once per surviving Good-Till (GTC or diff --git a/src/B3.Exchange.Matching/LimitOrderBook.cs b/src/B3.Exchange.Matching/LimitOrderBook.cs index 60bc0ff..96d53b0 100644 --- a/src/B3.Exchange.Matching/LimitOrderBook.cs +++ b/src/B3.Exchange.Matching/LimitOrderBook.cs @@ -16,6 +16,8 @@ internal sealed class RestingOrder public string? Asset { get; init; } public InvestorId? InvestorId { get; set; } public byte[] Memo { get; init; } = []; + public OrderType OrdType { get; init; } = OrderType.Limit; + public long? ProtectionPriceMantissa { get; init; } /// /// Wall-clock timestamp at which the order entered (or last re-entered) diff --git a/src/B3.Exchange.Matching/MatchingEngine.cs b/src/B3.Exchange.Matching/MatchingEngine.cs index 20d59a7..60abb82 100644 --- a/src/B3.Exchange.Matching/MatchingEngine.cs +++ b/src/B3.Exchange.Matching/MatchingEngine.cs @@ -1526,7 +1526,9 @@ public void Replace(ReplaceOrderCommand cmd) // order's identity. resting.InvestorId has already // been mutated above when cmd.NewInvestorId was set, // so this is the canonical post-replace value. - InvestorId: resting.InvestorId)); + InvestorId: resting.InvestorId, + OrdType: resting.OrdType, + ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); RecomputeAuctionTopIfApplicable(cmd.SecurityId, cmd.EnteredAtNanos); return; } @@ -1613,6 +1615,7 @@ private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, L MaxFloor = (long)cmd.MaxFloor, HiddenQuantity = hidden, ExpireDate = cmd.ExpireDate, + OrdType = cmd.Type, }; book.Insert(resting); _sink.OnOrderAccepted(new OrderAcceptedEvent( @@ -1624,7 +1627,9 @@ private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, L RemainingQuantity: resting.RemainingQuantity, EnteringFirm: resting.EnteringFirm, InsertTimestampNanos: resting.InsertTimestampNanos, - RptSeq: NextRptSeq())); + RptSeq: NextRptSeq(), + OrdType: resting.OrdType, + ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); RecomputeAuctionTopIfApplicable(book.SecurityId, cmd.EnteredAtNanos); } @@ -2071,6 +2076,8 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR HiddenQuantity = hidden, ExpireDate = cmd.ExpireDate, Memo = cmd.Memo, + OrdType = cmd.Type, + ProtectionPriceMantissa = isMwl ? limitPx : null, }; book.Insert(resting); _sink.OnOrderAccepted(new OrderAcceptedEvent( @@ -2083,7 +2090,9 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR EnteringFirm: resting.EnteringFirm, InsertTimestampNanos: resting.InsertTimestampNanos, RptSeq: NextRptSeq(), - Memo: resting.Memo)); + Memo: resting.Memo, + OrdType: resting.OrdType, + ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); } finally { diff --git a/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs b/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs index 3beb176..eaea2ab 100644 --- a/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs +++ b/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs @@ -51,6 +51,7 @@ private sealed class FakeSession public List RejectClOrdIds { get; } = new(); public List Trades { get; } = new(); public List<(long LeavesQty, long CumQty)> TradeQty { get; } = new(); + public List<(OrderType OrdType, long? ProtectionPriceMantissa, long PriceMantissa, long RemainingQty)> Modifies { get; } = new(); public List Restates { get; } = new(); public bool CaptureCancelIds { get; set; } public List<(ulong ClOrdId, ulong OrigClOrdId)> CancelIds { get; } = new(); @@ -84,6 +85,8 @@ public bool WriteExecutionReportPassiveCancel(B3.Exchange.Contracts.SessionId ow } public bool WriteExecutionReportModify(B3.Exchange.Contracts.SessionId session, long securityId, long orderId, ulong clOrdIdValue, ulong origClOrdIdValue, Side side, long newPriceMantissa, long newRemainingQty, ulong transactTimeNanos, uint rptSeq, ulong receivedTimeNanos = ulong.MaxValue, DurabilityHandle d = default, InvestorId? iv = null) { if (Find(session) is { } s) { s.Calls.Add("Modify"); s.LastReceivedTime = receivedTimeNanos; } return true; } + public bool WriteExecutionReportModify(B3.Exchange.Contracts.SessionId session, long securityId, long orderId, ulong clOrdIdValue, ulong origClOrdIdValue, Side side, long newPriceMantissa, long newRemainingQty, ulong transactTimeNanos, uint rptSeq, OrderType ordType, long? protectionPriceMantissa, ulong receivedTimeNanos = ulong.MaxValue, DurabilityHandle d = default, InvestorId? iv = null) + { if (Find(session) is { } s) { s.Calls.Add("Modify"); s.LastReceivedTime = receivedTimeNanos; s.Modifies.Add((ordType, protectionPriceMantissa, newPriceMantissa, newRemainingQty)); } return true; } public bool WriteExecutionReportReject(B3.Exchange.Contracts.SessionId session, in RejectEvent e, ulong clOrdIdValue, DurabilityHandle d = default) { if (Find(session) is { } s) { s.Rejects.Add(e); s.RejectClOrdIds.Add(clOrdIdValue); s.Calls.Add("Reject"); } return true; } public bool WriteExecutionReportRestate(B3.Exchange.Contracts.SessionId ownerSession, ulong ownerClOrdId, in OrderRestatedEvent e, DurabilityHandle d = default) @@ -141,6 +144,51 @@ public void NewOrder_AcceptedRestingOrder_EmitsOrderAddedFrameAndExecReportNew() Assert.Equal(Petr, MemoryMarshal.Read(packet.AsSpan(bodyStart + WireOffsets.OrderBodySecurityIdOffset, 8))); } + [Fact] + public void MwlPartialFillThenRest_ExecReportNewCarriesMwlOrdTypeAndProtectionPrice() + { + var (disp, _, outbound) = NewDispatcher(); + var seller = new FakeSession(outbound) { EnteringFirm = 7 }; + var mwlBuyer = new FakeSession(outbound) { EnteringFirm = 8 }; + var plainLimit = new FakeSession(outbound) { EnteringFirm = 9 }; + + disp.EnqueueNewOrder(new NewOrderCommand("S1", Petr, Side.Sell, OrderType.Limit, TimeInForce.Day, Px(10m), 100, seller.EnteringFirm, 1_000UL), + seller.Id, seller.EnteringFirm, clOrdIdValue: 1UL); + DrainInbound(disp); + + var limitNew = Assert.Single(seller.News); + Assert.Equal(OrderType.Limit, limitNew.OrdType); + Assert.Null(limitNew.ProtectionPriceMantissa); + + disp.EnqueueNewOrder(new NewOrderCommand("MWL", Petr, Side.Buy, OrderType.MarketWithLeftover, TimeInForce.Day, 0L, 300, mwlBuyer.EnteringFirm, 2_000UL), + mwlBuyer.Id, mwlBuyer.EnteringFirm, clOrdIdValue: 2UL); + DrainInbound(disp); + + var mwlNew = Assert.Single(mwlBuyer.News); + Assert.Equal(OrderType.MarketWithLeftover, mwlNew.OrdType); + Assert.Equal(Px(10m), mwlNew.PriceMantissa); + Assert.Equal(Px(10m), mwlNew.ProtectionPriceMantissa); + Assert.Equal(200, mwlNew.RemainingQuantity); + + disp.EnqueueReplace(new ReplaceOrderCommand("MWL-2", Petr, mwlNew.OrderId, Px(10m), 100, 2_500UL), + mwlBuyer.Id, mwlBuyer.EnteringFirm, clOrdIdValue: 22UL, origClOrdIdValue: 2UL); + DrainInbound(disp); + + var mwlModify = Assert.Single(mwlBuyer.Modifies); + Assert.Equal(OrderType.MarketWithLeftover, mwlModify.OrdType); + Assert.Equal(Px(10m), mwlModify.ProtectionPriceMantissa); + Assert.Equal(Px(10m), mwlModify.PriceMantissa); + Assert.Equal(100, mwlModify.RemainingQty); + + disp.EnqueueNewOrder(new NewOrderCommand("B1", Petr, Side.Buy, OrderType.Limit, TimeInForce.Day, Px(9m), 100, plainLimit.EnteringFirm, 3_000UL), + plainLimit.Id, plainLimit.EnteringFirm, clOrdIdValue: 3UL); + DrainInbound(disp); + + var plainNew = Assert.Single(plainLimit.News); + Assert.Equal(OrderType.Limit, plainNew.OrdType); + Assert.Null(plainNew.ProtectionPriceMantissa); + } + [Fact] public void NewOrder_UnsupportedCharacteristic_EmitsExecutionReportReject_NoUmdfPacket() { diff --git a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs index e3dd3cb..ad70f53 100644 --- a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs +++ b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs @@ -47,6 +47,60 @@ public void EncodeNew_WritesHeaderAndCoreFields() Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(112, 8))); // StopPx null } + [Fact] + public void EncodeNew_Mwl_WritesProtectionPriceAndOrdTypeK() + { + var buf = new byte[ExecutionReportEncoder.ExecReportNewTotal]; + ExecutionReportEncoder.EncodeExecReportNew(buf, + sessionId: 42, msgSeqNum: 1, sendingTimeNanos: 1_000_000_000UL, + side: Side.Buy, clOrdIdValue: 99, secondaryOrderId: 555, + securityId: 1122, orderId: 7777, + execId: 100UL, transactTimeNanos: 1_000_000_001UL, + ordType: OrderType.MarketWithLeftover, tif: TimeInForce.Day, + orderQty: 10, priceMantissa: 12_3450L, + protectionPriceMantissa: 12_3450L); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(12_3450L, MemoryMarshal.Read(body.Slice(80, 8))); // ProtectionPrice + Assert.Equal((byte)'K', body[92]); // OrdType=MWL + } + + [Fact] + public void EncodeNew_Limit_WritesNullProtectionPriceAndLimitOrdType() + { + var buf = new byte[ExecutionReportEncoder.ExecReportNewTotal]; + ExecutionReportEncoder.EncodeExecReportNew(buf, + sessionId: 42, msgSeqNum: 1, sendingTimeNanos: 1_000_000_000UL, + side: Side.Buy, clOrdIdValue: 99, secondaryOrderId: 555, + securityId: 1122, orderId: 7777, + execId: 100UL, transactTimeNanos: 1_000_000_001UL, + ordType: OrderType.Limit, tif: TimeInForce.Day, + orderQty: 10, priceMantissa: 12_3450L, + protectionPriceMantissa: 12_3450L); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(80, 8))); // ProtectionPrice null + Assert.Equal((byte)'2', body[92]); // OrdType=Limit + } + + [Fact] + public void EncodeNew_Market_WritesNullProtectionPriceAndMarketOrdType() + { + var buf = new byte[ExecutionReportEncoder.ExecReportNewTotal]; + ExecutionReportEncoder.EncodeExecReportNew(buf, + sessionId: 42, msgSeqNum: 1, sendingTimeNanos: 1_000_000_000UL, + side: Side.Buy, clOrdIdValue: 99, secondaryOrderId: 555, + securityId: 1122, orderId: 7777, + execId: 100UL, transactTimeNanos: 1_000_000_001UL, + ordType: OrderType.Market, tif: TimeInForce.IOC, + orderQty: 10, priceMantissa: null, + protectionPriceMantissa: 12_3450L); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(80, 8))); // ProtectionPrice null + Assert.Equal((byte)'1', body[92]); // OrdType=Market + } + [Fact] public void EncodeNew_WritesMemoVarData() @@ -223,6 +277,39 @@ public void EncodeModify_WritesLeavesQtyAndOrigClOrd() Assert.Equal(99_0000L, MemoryMarshal.Read(body.Slice(128, 8))); // Price } + [Fact] + public void EncodeModify_Mwl_WritesProtectionPriceAndOrdTypeK() + { + var buf = new byte[ExecutionReportEncoder.ExecReportModifyTotal]; + ExecutionReportEncoder.EncodeExecReportModify(buf, + sessionId: 1, msgSeqNum: 1, sendingTimeNanos: 0UL, + side: Side.Buy, clOrdIdValue: 22, origClOrdIdValue: 21, secondaryOrderId: 0, + securityId: 7, orderId: 1234, execId: 0UL, transactTimeNanos: 0UL, + leavesQty: 50, cumQty: 25, orderQty: 75, priceMantissa: 99_0000L, + ordType: OrderType.MarketWithLeftover, + protectionPriceMantissa: 99_0000L); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(99_0000L, MemoryMarshal.Read(body.Slice(104, 8))); // ProtectionPrice + Assert.Equal((byte)'K', body[116]); // OrdType=MWL + } + + [Fact] + public void EncodeModify_Limit_WritesNullProtectionPriceAndLimitOrdType() + { + var buf = new byte[ExecutionReportEncoder.ExecReportModifyTotal]; + ExecutionReportEncoder.EncodeExecReportModify(buf, + sessionId: 1, msgSeqNum: 1, sendingTimeNanos: 0UL, + side: Side.Buy, clOrdIdValue: 22, origClOrdIdValue: 21, secondaryOrderId: 0, + securityId: 7, orderId: 1234, execId: 0UL, transactTimeNanos: 0UL, + leavesQty: 50, cumQty: 25, orderQty: 75, priceMantissa: 99_0000L, + protectionPriceMantissa: 99_0000L); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(104, 8))); // ProtectionPrice null + Assert.Equal((byte)'2', body[116]); // OrdType=Limit + } + // ====== #49 / #GAP-11: receivedTime (tag 35544) round-trip ====== // // ER_New / ER_Modify / ER_Cancel were bumped to V3 to expose the optional From 78013547444f0b9eda5a63cb1d21277627cd3eb9 Mon Sep 17 00:00:00 2001 From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:24:26 +0000 Subject: [PATCH 2/2] Fix ER ordType echo regressions Derive MWL modify echo metadata from replace requests instead of persisted resting-order state, and thread stop order types into ER_New. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ChannelDispatcher.Sinks.cs | 7 +++-- src/B3.Exchange.Matching/Commands.cs | 21 ++++++------- src/B3.Exchange.Matching/LimitOrderBook.cs | 2 -- src/B3.Exchange.Matching/MatchingEngine.cs | 30 ++++++++++--------- .../ChannelDispatcherTests.cs | 23 +++++++++++++- .../ExecutionReportEncoderTests.cs | 20 +++++++++++++ .../MatchingEngineRestoreTests.cs | 28 ++++++++++++++++- 7 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs b/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs index 77c0a3e..dbee2ba 100644 --- a/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs +++ b/src/B3.Exchange.Core/ChannelDispatcher.Sinks.cs @@ -526,8 +526,8 @@ public void OnIcebergReplenished(in IcebergReplenishedEvent e) /// can resolve the owner) and emit ER_New back to the active session. /// No MBO frame is emitted because stops are not on the public book /// until they trigger. The wire ER reuses the regular OrderAccepted - /// shape and therefore does not carry StopPx/OrdType=Stop on the - /// wire — an MVP limitation tracked in the issue body. + /// shape and carries the stop OrdType so ER_New echoes the accepted + /// stop semantics. StopPx is still not present on ER_New. /// public void OnStopOrderAccepted(in StopOrderAcceptedEvent e) { @@ -546,7 +546,8 @@ public void OnStopOrderAccepted(in StopOrderAcceptedEvent e) RemainingQuantity: e.Quantity, EnteringFirm: e.EnteringFirm, InsertTimestampNanos: e.InsertTimestampNanos, - RptSeq: e.RptSeq); + RptSeq: e.RptSeq, + OrdType: e.StopType); _outbound.WriteExecutionReportNew(_currentSession, _currentFirm, _currentClOrdId, accepted, _currentReceivedTimeNanos, CurrentDurability); _metrics?.IncExecutionReport(ExecutionReportKind.New); } diff --git a/src/B3.Exchange.Matching/Commands.cs b/src/B3.Exchange.Matching/Commands.cs index 53e7107..e957285 100644 --- a/src/B3.Exchange.Matching/Commands.cs +++ b/src/B3.Exchange.Matching/Commands.cs @@ -291,10 +291,12 @@ public sealed record CancelOrderCommand( /// /// Replace command. is interpreted as the new /// remaining open quantity (not the original total). Replace cannot -/// change Side, SecurityId, Type or TIF — those would be a new order. -/// Priority is preserved iff (PriceMantissa unchanged AND NewQuantity <= current -/// remaining qty); otherwise the engine emits a -/// with followed by an +/// change Side or SecurityId; Type/TIF overrides are carried by +/// and . Priority is preserved iff +/// the effective type can rest in place, TIF and price priority are unchanged, +/// and NewQuantity <= current remaining qty; otherwise the engine emits a +/// with +/// followed by an /// for the replacement (which may then cross /// or rest like a brand-new order, including emitting trades). /// @@ -307,12 +309,11 @@ public sealed record ReplaceOrderCommand( ulong EnteredAtNanos) { /// - /// New order type for the replacement. null means "preserve - /// the resting order's type" (always - /// for an order that is on the book). Setting OrderType.Market - /// turns the priority-loss path into a market aggressor that consumes - /// liquidity and never rests; must then be - /// IOC or FOK. Issue #204. + /// New order type for the replacement. null means "preserve as + /// resting-limit semantics" for an order that is on the book. Setting + /// OrderType.Market turns the priority-loss path into a market + /// aggressor that consumes liquidity and never rests; + /// must then be IOC or FOK. Issue #204. /// public OrderType? NewOrdType { get; init; } diff --git a/src/B3.Exchange.Matching/LimitOrderBook.cs b/src/B3.Exchange.Matching/LimitOrderBook.cs index 96d53b0..60bc0ff 100644 --- a/src/B3.Exchange.Matching/LimitOrderBook.cs +++ b/src/B3.Exchange.Matching/LimitOrderBook.cs @@ -16,8 +16,6 @@ internal sealed class RestingOrder public string? Asset { get; init; } public InvestorId? InvestorId { get; set; } public byte[] Memo { get; init; } = []; - public OrderType OrdType { get; init; } = OrderType.Limit; - public long? ProtectionPriceMantissa { get; init; } /// /// Wall-clock timestamp at which the order entered (or last re-entered) diff --git a/src/B3.Exchange.Matching/MatchingEngine.cs b/src/B3.Exchange.Matching/MatchingEngine.cs index 60abb82..bb5f111 100644 --- a/src/B3.Exchange.Matching/MatchingEngine.cs +++ b/src/B3.Exchange.Matching/MatchingEngine.cs @@ -1397,7 +1397,10 @@ public void Replace(ReplaceOrderCommand cmd) // #204: effective Type/TIF — null means "preserve original". // The resting order is by construction Limit (Market never rests), // so the only meaningful Type override is Limit -> Market, which - // turns the priority-loss path into a market aggressor. + // turns the priority-loss path into a market aggressor. A MWL + // replace may keep priority when it is otherwise equivalent to a + // resting limit amend; its ER_Modify echoes the replace request's + // OrdType and uses the post-replace resting price as ProtectionPrice. var effectiveType = cmd.NewOrdType ?? OrderType.Limit; var effectiveTif = cmd.NewTif ?? resting.Tif; @@ -1466,10 +1469,12 @@ public void Replace(ReplaceOrderCommand cmd) { Reject(cmd.ClOrdId, cmd.SecurityId, cmd.OrderId, RejectReason.OrderExceedsLimit, cmd.EnteredAtNanos); return; } } - // Priority-keep is only possible if Type/TIF are unchanged: a Type - // or TIF transition is by definition a logical re-entry (the order - // semantics differ), so we must DEL+NEW. - bool priorityKept = effectiveType == OrderType.Limit + // Priority-keep is only possible when the replacement still has + // resting-limit semantics (Limit, or MWL after it has already + // become a resting leftover) and leaves TIF/price priority intact. + bool effectiveTypeCanRestInPlace = effectiveType == OrderType.Limit + || effectiveType == OrderType.MarketWithLeftover; + bool priorityKept = effectiveTypeCanRestInPlace && effectiveTif == resting.Tif && cmd.NewPriceMantissa == resting.PriceMantissa && cmd.NewQuantity <= resting.RemainingQuantity; @@ -1527,8 +1532,8 @@ public void Replace(ReplaceOrderCommand cmd) // been mutated above when cmd.NewInvestorId was set, // so this is the canonical post-replace value. InvestorId: resting.InvestorId, - OrdType: resting.OrdType, - ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); + OrdType: effectiveType, + ProtectionPriceMantissa: effectiveType == OrderType.MarketWithLeftover ? resting.PriceMantissa : null)); RecomputeAuctionTopIfApplicable(cmd.SecurityId, cmd.EnteredAtNanos); return; } @@ -1615,7 +1620,6 @@ private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, L MaxFloor = (long)cmd.MaxFloor, HiddenQuantity = hidden, ExpireDate = cmd.ExpireDate, - OrdType = cmd.Type, }; book.Insert(resting); _sink.OnOrderAccepted(new OrderAcceptedEvent( @@ -1628,8 +1632,8 @@ private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, L EnteringFirm: resting.EnteringFirm, InsertTimestampNanos: resting.InsertTimestampNanos, RptSeq: NextRptSeq(), - OrdType: resting.OrdType, - ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); + OrdType: cmd.Type, + ProtectionPriceMantissa: cmd.Type == OrderType.MarketWithLeftover ? resting.PriceMantissa : null)); RecomputeAuctionTopIfApplicable(book.SecurityId, cmd.EnteredAtNanos); } @@ -2076,8 +2080,6 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR HiddenQuantity = hidden, ExpireDate = cmd.ExpireDate, Memo = cmd.Memo, - OrdType = cmd.Type, - ProtectionPriceMantissa = isMwl ? limitPx : null, }; book.Insert(resting); _sink.OnOrderAccepted(new OrderAcceptedEvent( @@ -2091,8 +2093,8 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR InsertTimestampNanos: resting.InsertTimestampNanos, RptSeq: NextRptSeq(), Memo: resting.Memo, - OrdType: resting.OrdType, - ProtectionPriceMantissa: resting.ProtectionPriceMantissa)); + OrdType: cmd.Type, + ProtectionPriceMantissa: isMwl ? limitPx : null)); } finally { diff --git a/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs b/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs index eaea2ab..34e0f21 100644 --- a/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs +++ b/tests/B3.Exchange.Core.Tests/ChannelDispatcherTests.cs @@ -170,7 +170,10 @@ public void MwlPartialFillThenRest_ExecReportNewCarriesMwlOrdTypeAndProtectionPr Assert.Equal(Px(10m), mwlNew.ProtectionPriceMantissa); Assert.Equal(200, mwlNew.RemainingQuantity); - disp.EnqueueReplace(new ReplaceOrderCommand("MWL-2", Petr, mwlNew.OrderId, Px(10m), 100, 2_500UL), + disp.EnqueueReplace(new ReplaceOrderCommand("MWL-2", Petr, mwlNew.OrderId, Px(10m), 100, 2_500UL) + { + NewOrdType = OrderType.MarketWithLeftover, + }, mwlBuyer.Id, mwlBuyer.EnteringFirm, clOrdIdValue: 22UL, origClOrdIdValue: 2UL); DrainInbound(disp); @@ -189,6 +192,24 @@ public void MwlPartialFillThenRest_ExecReportNewCarriesMwlOrdTypeAndProtectionPr Assert.Null(plainNew.ProtectionPriceMantissa); } + [Theory] + [InlineData(OrderType.StopLoss)] + [InlineData(OrderType.StopLimit)] + public void StopAccepted_ExecReportNewCarriesStopOrdType(OrderType stopType) + { + var (disp, _, outbound) = NewDispatcher(); + var reply = new FakeSession(outbound); + long limitPrice = stopType == OrderType.StopLimit ? Px(10.50m) : 0L; + + disp.EnqueueNewOrder(new NewOrderCommand("S1", Petr, Side.Buy, stopType, TimeInForce.Day, limitPrice, 100, 7, 1_000UL) + { StopPxMantissa = Px(10.45m) }, reply.Id, reply.EnteringFirm, clOrdIdValue: 1UL); + DrainInbound(disp); + + var accepted = Assert.Single(reply.News); + Assert.Equal(stopType, accepted.OrdType); + Assert.Null(accepted.ProtectionPriceMantissa); + } + [Fact] public void NewOrder_UnsupportedCharacteristic_EmitsExecutionReportReject_NoUmdfPacket() { diff --git a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs index ad70f53..f831c24 100644 --- a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs +++ b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs @@ -101,6 +101,26 @@ public void EncodeNew_Market_WritesNullProtectionPriceAndMarketOrdType() Assert.Equal((byte)'1', body[92]); // OrdType=Market } + [Theory] + [InlineData(OrderType.StopLoss, (byte)'3', 0L, 10_4500L)] + [InlineData(OrderType.StopLimit, (byte)'4', 10_5000L, 10_4500L)] + public void EncodeNew_StopOrders_WriteStopOrdTypeByte(OrderType ordType, byte expectedOrdTypeByte, long priceMantissa, long stopPxMantissa) + { + var buf = new byte[ExecutionReportEncoder.ExecReportNewTotal]; + ExecutionReportEncoder.EncodeExecReportNew(buf, + sessionId: 42, msgSeqNum: 1, sendingTimeNanos: 1_000_000_000UL, + side: Side.Buy, clOrdIdValue: 99, secondaryOrderId: 555, + securityId: 1122, orderId: 7777, + execId: 100UL, transactTimeNanos: 1_000_000_001UL, + ordType: ordType, tif: TimeInForce.Day, + orderQty: 10, priceMantissa: priceMantissa, + protectionPriceMantissa: stopPxMantissa); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(80, 8))); // ProtectionPrice null + Assert.Equal(expectedOrdTypeByte, body[92]); + } + [Fact] public void EncodeNew_WritesMemoVarData() diff --git a/tests/B3.Exchange.Persistence.Tests/MatchingEngineRestoreTests.cs b/tests/B3.Exchange.Persistence.Tests/MatchingEngineRestoreTests.cs index b9803ca..dd6c149 100644 --- a/tests/B3.Exchange.Persistence.Tests/MatchingEngineRestoreTests.cs +++ b/tests/B3.Exchange.Persistence.Tests/MatchingEngineRestoreTests.cs @@ -31,9 +31,10 @@ private sealed class CountingSink : IMatchingEventSink { public List AcceptedOrderIds { get; } = new(); public List Trades { get; } = new(); + public List Modified { get; } = new(); public void OnOrderAccepted(in OrderAcceptedEvent e) => AcceptedOrderIds.Add(e.OrderId); public void OnOrderQuantityReduced(in OrderQuantityReducedEvent e) { } - public void OnOrderModified(in OrderModifiedEvent e) { } + public void OnOrderModified(in OrderModifiedEvent e) => Modified.Add(e); public void OnOrderCanceled(in OrderCanceledEvent e) { } public void OnOrderFilled(in OrderFilledEvent e) { } public void OnTrade(in TradeEvent e) => Trades.Add(e); @@ -143,4 +144,29 @@ public void Restore_PreservesIcebergFields() var view = dst.EnumerateBook(Sec, Side.Buy).Single(); Assert.Equal(100L, view.RemainingQuantity); } + + [Fact] + public void Restore_MwlLeftoverModifyEchoesReplaceOrdTypeAndRestingProtectionPrice() + { + var src = NewEngine(out var srcSink); + src.Submit(new NewOrderCommand("S", Sec, Side.Sell, OrderType.Limit, + TimeInForce.Day, Px(10.00m), 100, 100, 1_000UL)); + src.Submit(new NewOrderCommand("MWL", Sec, Side.Buy, OrderType.MarketWithLeftover, + TimeInForce.Day, 0, 300, 200, 2_000UL)); + long mwlOrderId = srcSink.AcceptedOrderIds.Last(); + + var dst = NewEngine(out var dstSink); + dst.RestoreState(src.CaptureState()); + + dst.Replace(new ReplaceOrderCommand("MWL-R", Sec, mwlOrderId, Px(10.00m), 100, 3_000UL) + { + NewOrdType = OrderType.MarketWithLeftover, + }); + + var modified = Assert.Single(dstSink.Modified); + Assert.Equal(OrderType.MarketWithLeftover, modified.OrdType); + Assert.Equal(Px(10.00m), modified.ProtectionPriceMantissa); + Assert.Equal(Px(10.00m), modified.NewPriceMantissa); + Assert.Equal(100, modified.NewRemainingQuantity); + } }