package com.example.trading.gateway.fix; import java.math.BigDecimal; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.UUID; import com.example.trading.gateway.order.IncomingOrder; import com.example.trading.gateway.order.OrderSide; import com.example.trading.gateway.order.OrderSubmissionResult; import org.springframework.stereotype.Component; import quickfix.field.*; import quickfix.fix44.ExecutionReport; /** Creates decision events only; sequence numbers and retransmission belong to QuickFIX/J. */ @Component public class ExecutionReportFactory { private final Clock clock; public ExecutionReportFactory() { this(Clock.systemUTC()); } ExecutionReportFactory(Clock clock) { this.clock = clock; } public ExecutionReport create(IncomingOrder order, OrderSubmissionResult result) { boolean accepted = result.outcome() == OrderSubmissionResult.Outcome.ACCEPTED; if (!accepted && result.outcome() != OrderSubmissionResult.Outcome.REJECTED) { throw new IllegalArgumentException("Only definitive OMS decisions create ExecutionReports"); } if (accepted && result.orderId().isBlank()) { throw new IllegalArgumentException("An accepted OMS decision must have an OrderID"); } ExecutionReport report = new ExecutionReport(); // NONE means OMS did not assign an identifier. It is never a fabricated OMS ID. report.set(new OrderID(result.orderId().isBlank() ? "NONE" : result.orderId())); report.set(new ExecID(UUID.randomUUID().toString())); report.set(new ExecType(accepted ? ExecType.NEW : ExecType.REJECTED)); report.set(new OrdStatus(accepted ? OrdStatus.NEW : OrdStatus.REJECTED)); report.set(new ClOrdID(order.clientOrderId())); report.set(new Symbol(order.symbol())); report.set(new Side(order.side() == OrderSide.BUY ? Side.BUY : Side.SELL)); report.set(new OrdType(OrdType.LIMIT)); // QF/J 3.0.2 generated numeric fields use double. FieldMap.setDecimal writes // BigDecimal through DecimalConverter without a binary floating-point conversion. report.setDecimal(OrderQty.FIELD, BigDecimal.valueOf(order.quantity())); report.setDecimal(Price.FIELD, order.price()); report.setDecimal(LeavesQty.FIELD, accepted ? BigDecimal.valueOf(order.quantity()) : BigDecimal.ZERO); report.setDecimal(CumQty.FIELD, BigDecimal.ZERO); report.setDecimal(AvgPx.FIELD, BigDecimal.ZERO); report.set(new TransactTime(LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC))); if (!accepted) { String reason = result.rejectReason(); int code = switch (reason.split(":", 2)[0].strip()) { case "UNSUPPORTED_SYMBOL", "UNKNOWN_SYMBOL" -> OrdRejReason.UNKNOWN_SYMBOL; case "MAX_ORDER_QUANTITY_EXCEEDED", "ORDER_EXCEEDS_LIMIT" -> OrdRejReason.ORDER_EXCEEDS_LIMIT; case "DUPLICATE_CL_ORD_ID", "DUPLICATE_ORDER" -> OrdRejReason.DUPLICATE_ORDER; default -> OrdRejReason.OTHER; }; report.set(new OrdRejReason(code)); // Bound free text, exclude SOH, controls, non-ASCII and log/display delimiters. StringBuilder sanitized = new StringBuilder(); for (int i = 0; i < reason.length() && sanitized.length() < 256; i++) { char c = reason.charAt(i); sanitized.append(c >= 32 && c <= 126 && c != '|' ? c : ' '); } report.set(new Text(sanitized.toString().isBlank() ? "Order rejected" : sanitized.toString())); } return report; } }