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);
+ }
}