diff --git a/docs/B3-ENTRYPOINT-COMPLIANCE.md b/docs/B3-ENTRYPOINT-COMPLIANCE.md index 02a1396..658b013 100644 --- a/docs/B3-ENTRYPOINT-COMPLIANCE.md +++ b/docs/B3-ENTRYPOINT-COMPLIANCE.md @@ -173,7 +173,7 @@ contract supervision is a post-trade analytics concern. | 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-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-30 | 16.6 | Sweep & Cross | partial | low (in-scope per ADR 0012) | Matching semantics for `CrossType=AgainstBook` are implemented (#218), sweep-phase UMDF `Trade_53.trdSubType` emits `SWEEP_TRADE` (109), and EntryPoint `ExecutionReport_New` / `ExecutionReport_Trade` now echo `crossType` + `crossPrioritization` for cross-generated ERs (#529). Remaining wire refinement: residual-cancel `ExecRestatementReason=210` is intentionally out of scope while residuals rest. | | 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 | | GAP-32 | 8.3 / lifecycle | Expiring option series are not automatically moved to `Close` based on `ExpirationDate`. | missing | medium | tracked via [RFC 0002](rfc/0002-equity-options-support.md) issue OPT-03 | diff --git a/src/B3.Exchange.Core/ChannelDispatcher.Loop.cs b/src/B3.Exchange.Core/ChannelDispatcher.Loop.cs index 3654d62..9c7213a 100644 --- a/src/B3.Exchange.Core/ChannelDispatcher.Loop.cs +++ b/src/B3.Exchange.Core/ChannelDispatcher.Loop.cs @@ -378,11 +378,11 @@ internal void ProcessOne(in WorkItem item) _metrics?.IncOrdersIn(); _currentClOrdId = prioClOrd; BeginAggressor(prioLeg.Quantity); - _engine.Submit(prioLeg); + _engine.SubmitCrossLeg(prioLeg, cross.CrossType, cross.CrossPrioritization); _metrics?.IncOrdersIn(); _currentClOrdId = otherClOrd; BeginAggressor(otherLeg.Quantity); - _engine.Submit(otherLeg); + _engine.SubmitCrossLeg(otherLeg, cross.CrossType, cross.CrossPrioritization); break; } @@ -407,7 +407,7 @@ internal void ProcessOne(in WorkItem item) try { BeginAggressor(sweepLeg.Quantity); - _engine.SubmitCrossSweep(sweepLeg); + _engine.SubmitCrossSweep(sweepLeg, cross.CrossType, cross.CrossPrioritization); swept = _crossSweepFilledQty.GetValueOrDefault(); } finally @@ -425,7 +425,7 @@ internal void ProcessOne(in WorkItem item) _metrics?.IncOrdersIn(); _currentClOrdId = prioClOrd; BeginAggressor(residual); - _engine.Submit(prioLeg with { Quantity = residual }); + _engine.SubmitCrossLeg(prioLeg with { Quantity = residual }, cross.CrossType, cross.CrossPrioritization); } // Phase 3: the other leg at full OrderQty — @@ -435,7 +435,7 @@ internal void ProcessOne(in WorkItem item) _metrics?.IncOrdersIn(); _currentClOrdId = otherClOrd; BeginAggressor(otherLeg.Quantity); - _engine.Submit(otherLeg); + _engine.SubmitCrossLeg(otherLeg, cross.CrossType, cross.CrossPrioritization); } else { @@ -446,11 +446,11 @@ internal void ProcessOne(in WorkItem item) _metrics?.IncOrdersIn(); _currentClOrdId = prioClOrd; BeginAggressor(prioLeg.Quantity); - _engine.Submit(prioLeg); + _engine.SubmitCrossLeg(prioLeg, cross.CrossType, cross.CrossPrioritization); _metrics?.IncOrdersIn(); _currentClOrdId = otherClOrd; BeginAggressor(otherLeg.Quantity); - _engine.Submit(otherLeg); + _engine.SubmitCrossLeg(otherLeg, cross.CrossType, cross.CrossPrioritization); } break; } diff --git a/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs b/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs index 6b2f3e1..4824a4d 100644 --- a/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs +++ b/src/B3.Exchange.Gateway/ExecutionReportEncoder.cs @@ -129,6 +129,11 @@ private static int WriteMemoTrailer(Span dst, int blockLength, ReadOnlySpa _ => 0, }; + private static byte EncodeCrossType(Matching.CrossType? value) => value.HasValue ? (byte)value.Value : (byte)255; + + private static byte EncodeCrossPrioritization(Matching.CrossPrioritization? value) + => value.HasValue ? (byte)value.Value : (byte)255; + private static void WriteBusinessHeader(Span body, uint sessionId, uint msgSeqNum, ulong sendingTimeNanos) { // OutboundBusinessHeader (sequential, Pack=1): @@ -145,7 +150,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, + Matching.CrossType? crossType = null, Matching.CrossPrioritization? crossPrioritization = null) { int total = TotalSize(ExecReportNewBlock, memo.Length); if (dst.Length < total) throw new ArgumentException("buffer too small for ER_New", nameof(dst)); @@ -175,8 +181,8 @@ public static int EncodeExecReportNew(Span dst, // V3 trailing fields: receivedTime + non-zero null sentinels. MemoryMarshal.Write(body.Slice(144, 8), in receivedTimeNanos); // ReceivedTime (tag 35544) // ordTagID@155 null=0 already, investorID@156 null=zeros already, strategyID@168 null=0 already. - body[162] = 255; // CrossType null - body[163] = 255; // CrossPrioritization null + body[162] = EncodeCrossType(crossType); // CrossType + body[163] = EncodeCrossPrioritization(crossPrioritization); // CrossPrioritization body[164] = 255; // MmProtectionReset null // V6 trailing field: tradingSubAccount@172 (uint, null=0) — covered // by body.Clear() above. strategyID@168 (int, null=0) ditto. @@ -374,7 +380,8 @@ public static int EncodeExecReportTrade(Span dst, Matching.Side side, ulong clOrdIdValue, long secondaryOrderId, long securityId, long orderId, long lastQty, long lastPxMantissa, ulong execId, ulong transactTimeNanos, long leavesQty, long cumQty, - bool aggressor, uint tradeId, uint contraBroker, ushort tradeDate, long orderQty, ReadOnlySpan memo = default) + bool aggressor, uint tradeId, uint contraBroker, ushort tradeDate, long orderQty, ReadOnlySpan memo = default, + Matching.CrossType? crossType = null, Matching.CrossPrioritization? crossPrioritization = null) { int total = TotalSize(ExecReportTradeBlock, memo.Length); if (dst.Length < total) throw new ArgumentException("buffer too small for ER_Trade", nameof(dst)); @@ -408,8 +415,8 @@ public static int EncodeExecReportTrade(Span dst, body[154] = 255; // TradingSessionID null body[155] = 255; // TradingSessionSubID null body[156] = 255; // SecurityTradingStatus null - body[157] = 255; // CrossType null - body[158] = 255; // CrossPrioritization null + body[157] = EncodeCrossType(crossType); // CrossType + body[158] = EncodeCrossPrioritization(crossPrioritization); // CrossPrioritization // strategyID@160 (int, null=0), impliedEventID@164 (6 bytes; eventID // and noRelatedTrades both null=0) and tradingSubAccount@170 (uint, // null=0) are covered by body.Clear() above. diff --git a/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs b/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs index 403126e..dcd49fa 100644 --- a/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs +++ b/src/B3.Exchange.Gateway/FixpOutboundEncoder.cs @@ -84,7 +84,7 @@ public bool WriteExecutionReportNew(in OrderAcceptedEvent e, ulong receivedTimeN (ulong)e.RptSeq, e.InsertTimestampNanos, OrderType.Limit, TimeInForce.Day, e.RemainingQuantity, e.PriceMantissa, - memo.Span, receivedTimeNanos); + memo.Span, receivedTimeNanos, e.CrossType, e.CrossPrioritization); return AppendAndEnqueueLocked(exact, durability); } } @@ -112,7 +112,7 @@ public bool WriteExecutionReportTrade(in TradeEvent e, bool isAggressor, long ow isAggressor, e.TradeId, isAggressor ? e.RestingFirm : e.AggressorFirm, tradeDate: 0, - orderQty: leavesQty + cumQty, memo.Span); + orderQty: leavesQty + cumQty, memo.Span, e.CrossType, e.CrossPrioritization); return AppendAndEnqueueLocked(exact, durability); } } diff --git a/src/B3.Exchange.Matching/Events.cs b/src/B3.Exchange.Matching/Events.cs index 500188b..e80444f 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, + CrossType? CrossType = null, + CrossPrioritization? CrossPrioritization = null); /// /// Fired when a resting order's remaining quantity is reduced as a passive maker @@ -89,7 +91,9 @@ public readonly record struct TradeEvent( ulong TransactTimeNanos, uint RptSeq, byte[]? AggressorMemo = null, - byte[]? RestingMemo = null); + byte[]? RestingMemo = null, + CrossType? CrossType = null, + CrossPrioritization? CrossPrioritization = null); public readonly record struct RejectEvent( string ClOrdId, diff --git a/src/B3.Exchange.Matching/MatchingEngine.cs b/src/B3.Exchange.Matching/MatchingEngine.cs index 20d59a7..48f4228 100644 --- a/src/B3.Exchange.Matching/MatchingEngine.cs +++ b/src/B3.Exchange.Matching/MatchingEngine.cs @@ -777,7 +777,14 @@ private void FinalizeUncrossSide(LimitOrderBook book, RestingOrder o, long lastT /// public void SubmitCrossSweep(NewOrderCommand cmd) => SubmitImpl(cmd, emitUnmatchedIocClose: false); - private void SubmitImpl(NewOrderCommand cmd, bool emitUnmatchedIocClose) + public void SubmitCrossSweep(NewOrderCommand cmd, CrossType crossType, CrossPrioritization crossPrioritization) + => SubmitImpl(cmd, emitUnmatchedIocClose: false, crossType, crossPrioritization); + + public void SubmitCrossLeg(NewOrderCommand cmd, CrossType crossType, CrossPrioritization crossPrioritization) + => SubmitImpl(cmd, emitUnmatchedIocClose: true, crossType, crossPrioritization); + + private void SubmitImpl(NewOrderCommand cmd, bool emitUnmatchedIocClose, + CrossType? crossType = null, CrossPrioritization? crossPrioritization = null) { _currentMemo = cmd.Memo; EnterDispatch(); @@ -985,10 +992,10 @@ private void SubmitImpl(NewOrderCommand cmd, bool emitUnmatchedIocClose) // rest cmd directly with cmd.PriceMantissa. if (phase == TradingPhase.Reserved || phase == TradingPhase.FinalClosingCall) { - RestForAuction(cmd, rules, book); + RestForAuction(cmd, rules, book, crossType, crossPrioritization); return; } - ExecuteAggressor(cmd, rules, book, emitUnmatchedIocClose); + ExecuteAggressor(cmd, rules, book, emitUnmatchedIocClose, crossType, crossPrioritization); } finally { ExitDispatch(); } } @@ -1574,8 +1581,9 @@ public void Replace(ReplaceOrderCommand cmd) } private void ExecuteAggressor(NewOrderCommand cmd, InstrumentTradingRules rules, LimitOrderBook book, - bool emitUnmatchedIocClose) - => ExecuteAggressorWithOrderId(cmd, rules, book, _nextOrderId++, emitUnmatchedIocClose); + bool emitUnmatchedIocClose, CrossType? crossType = null, CrossPrioritization? crossPrioritization = null) + => ExecuteAggressorWithOrderId(cmd, rules, book, _nextOrderId++, emitUnmatchedIocClose, + crossType, crossPrioritization); /// /// Issue #228 (Onda M · M1): rest the order on the book without any @@ -1588,7 +1596,8 @@ private void ExecuteAggressor(NewOrderCommand cmd, InstrumentTradingRules rules, /// () is honored so the visible /// slice on the book matches the continuous-Open semantics from #211. /// - private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, LimitOrderBook book) + private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, LimitOrderBook book, + CrossType? crossType = null, CrossPrioritization? crossPrioritization = null) { long visible = cmd.Quantity; long hidden = 0; @@ -1624,7 +1633,9 @@ private void RestForAuction(NewOrderCommand cmd, InstrumentTradingRules rules, L RemainingQuantity: resting.RemainingQuantity, EnteringFirm: resting.EnteringFirm, InsertTimestampNanos: resting.InsertTimestampNanos, - RptSeq: NextRptSeq())); + RptSeq: NextRptSeq(), + CrossType: crossType, + CrossPrioritization: crossPrioritization)); RecomputeAuctionTopIfApplicable(book.SecurityId, cmd.EnteredAtNanos); } @@ -1813,7 +1824,8 @@ private static bool IsMarketLike(OrderType t) => t == OrderType.Market || t == OrderType.MarketWithLeftover; private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingRules rules, - LimitOrderBook book, long aggressorOrderIdForTrades, bool emitUnmatchedIocClose) + LimitOrderBook book, long aggressorOrderIdForTrades, bool emitUnmatchedIocClose, + CrossType? crossType = null, CrossPrioritization? crossPrioritization = null) { long aggressorRemaining = cmd.Quantity; bool isMarket = IsMarketLike(cmd.Type); @@ -1883,7 +1895,9 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR TransactTimeNanos: cmd.EnteredAtNanos, RptSeq: NextRptSeq(), AggressorMemo: cmd.Memo, - RestingMemo: maker.Memo)); + RestingMemo: maker.Memo, + CrossType: crossType, + CrossPrioritization: crossPrioritization)); aggressorRemaining -= tradeQty; maker.RemainingQuantity -= tradeQty; @@ -2083,7 +2097,9 @@ private void ExecuteAggressorWithOrderId(NewOrderCommand cmd, InstrumentTradingR EnteringFirm: resting.EnteringFirm, InsertTimestampNanos: resting.InsertTimestampNanos, RptSeq: NextRptSeq(), - Memo: resting.Memo)); + Memo: resting.Memo, + CrossType: crossType, + CrossPrioritization: crossPrioritization)); } finally { diff --git a/tests/B3.Exchange.Core.Tests/CrossOrderSemanticsTests.cs b/tests/B3.Exchange.Core.Tests/CrossOrderSemanticsTests.cs index e6b2c3f..dcd6729 100644 --- a/tests/B3.Exchange.Core.Tests/CrossOrderSemanticsTests.cs +++ b/tests/B3.Exchange.Core.Tests/CrossOrderSemanticsTests.cs @@ -400,6 +400,59 @@ public void AgainstBook_MaxSweepQtyZero_StillBehavesLikeAon() Assert.Equal(100, s.Trades[0].Quantity); } + [Fact] + public void AgainstBook_ExecutionReportsEchoSubmittedCrossAttributes() + { + var (disp, _, outbound) = NewDispatcher(); + var external = new FakeSession(outbound); + var crosser = new FakeSession(outbound); + + disp.EnqueueNewOrder( + new NewOrderCommand("EXT", Petr, Side.Sell, OrderType.Limit, TimeInForce.Day, Px(9.99m), 100, external.EnteringFirm, 1_000UL), + external.Id, external.EnteringFirm, clOrdIdValue: 1UL); + DrainInbound(disp); + + Assert.Single(external.News); + Assert.Null(external.News[0].CrossType); + Assert.Null(external.News[0].CrossPrioritization); + + var cross = new CrossOrderCommand(Buy(200, 10m, 10UL), Sell(200, 10m, 11UL), 10UL, 11UL, 999UL) + { + CrossType = CrossType.AgainstBook, + CrossPrioritization = CrossPrioritization.BuyPrioritized, + MaxSweepQty = 100, + }; + disp.EnqueueCross(cross, crosser.Id, crosser.EnteringFirm); + DrainInbound(disp); + + Assert.NotEmpty(crosser.News); + Assert.All(crosser.News, e => + { + Assert.Equal(CrossType.AgainstBook, e.CrossType); + Assert.Equal(CrossPrioritization.BuyPrioritized, e.CrossPrioritization); + }); + Assert.NotEmpty(crosser.Trades); + Assert.All(crosser.Trades, e => + { + Assert.Equal(CrossType.AgainstBook, e.CrossType); + Assert.Equal(CrossPrioritization.BuyPrioritized, e.CrossPrioritization); + }); + } + + [Fact] + public void LimitOrder_ExecutionReportNewLeavesCrossAttributesNull() + { + var (disp, _, outbound) = NewDispatcher(); + var session = new FakeSession(outbound); + + disp.EnqueueNewOrder(Buy(100, 10m, 1UL), session.Id, session.EnteringFirm, clOrdIdValue: 1UL); + DrainInbound(disp); + + Assert.Single(session.News); + Assert.Null(session.News[0].CrossType); + Assert.Null(session.News[0].CrossPrioritization); + } + [Fact] public void MaxOpenOrdersPerFirm_CrossAtCapMinusOneIsRejectedForWorstCaseResidual() { diff --git a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs index e3dd3cb..5bd8f0b 100644 --- a/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs +++ b/tests/B3.Exchange.Gateway.Tests/ExecutionReportEncoderTests.cs @@ -45,8 +45,27 @@ public void EncodeNew_WritesHeaderAndCoreFields() Assert.Equal(10L, MemoryMarshal.Read(body.Slice(96, 8))); // OrderQty Assert.Equal(12_3450L, MemoryMarshal.Read(body.Slice(104, 8))); // Price Assert.Equal(long.MinValue, MemoryMarshal.Read(body.Slice(112, 8))); // StopPx null + Assert.Equal((byte)255, body[162]); // CrossType null + Assert.Equal((byte)255, body[163]); // CrossPrioritization null } + [Fact] + public void EncodeNew_CrossEchoesCrossTypeAndPrioritization() + { + 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, + crossType: CrossType.AgainstBook, crossPrioritization: CrossPrioritization.BuyPrioritized); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal((byte)CrossType.AgainstBook, body[162]); + Assert.Equal((byte)CrossPrioritization.BuyPrioritized, body[163]); + } [Fact] public void EncodeNew_WritesMemoVarData() @@ -96,6 +115,29 @@ public void EncodeTrade_WritesCoreFields() Assert.Equal((ushort)19000, MemoryMarshal.Read(body.Slice(116, 2))); // TradeDate Assert.Equal((ushort)65535, MemoryMarshal.Read(body.Slice(144, 2))); // CrossedIndicator null Assert.Equal(5L, MemoryMarshal.Read(body.Slice(146, 8))); // OrderQty + Assert.Equal((byte)255, body[157]); // CrossType null + Assert.Equal((byte)255, body[158]); // CrossPrioritization null + } + + [Fact] + public void EncodeTrade_CrossEchoesCrossTypeAndPrioritization() + { + var buf = new byte[ExecutionReportEncoder.ExecReportTradeTotal]; + + ExecutionReportEncoder.EncodeExecReportTrade(buf, + sessionId: 1, msgSeqNum: 2, sendingTimeNanos: 0UL, + side: Side.Sell, clOrdIdValue: 77, secondaryOrderId: 0, + securityId: 999, orderId: 1234, + lastQty: 5, lastPxMantissa: 50_0000L, + execId: 10UL, transactTimeNanos: 0UL, + leavesQty: 0, cumQty: 5, + aggressor: true, tradeId: 4242, contraBroker: 8, + tradeDate: 19000, orderQty: 5, + crossType: CrossType.AgainstBook, crossPrioritization: CrossPrioritization.SellPrioritized); + + var body = buf.AsSpan(EntryPointFrameReader.WireHeaderSize); + Assert.Equal((byte)CrossType.AgainstBook, body[157]); + Assert.Equal((byte)CrossPrioritization.SellPrioritized, body[158]); } [Fact]