Phase 26d — /orders poller, PERCENT trailing, order modification¶
Summary¶
Three independent capabilities that complete the MT5 broker:
-
/ordersendpoint integration — a dedicatedMT5PendingOrderPollerdetects when tracked pending tickets disappear from the venue. Combined with a TTL-cached "recently filled" set, the broker disambiguates "pending filled" (Phase 26c position-poller path, emitsOrderFilled) from "pending externally cancelled or GTD-expired" (this phase's path, emitsOrderCancelledwith reason). -
PERCENT trailing stops on MT5 —
MT5OrderTranslatornow accepts an optionalMarketPriceProvider. WhenOrderRequest.TrailingStop.trailMode == PERCENT, the translator computescurrentPrice × fracto resolve the absolute trail distance at submit time, then converts to MT5 points via the instrument'spointSize. -
Order modification surface —
MT5Broker.modify(orderId, OrderModification)overrides the defaultUnsupportedOperationException. It looks up the venue ticket frompendingTickets, translatesOrderModification(qkt-side) toMT5OrderModification(wire), callsclient.modifyOrder(ticket, ...), and publishes a newBrokerEvent.OrderModifiedevent on success. Capability declared viaOrderTypeCapability.MODIFYinMT5Protocol.
After this phase the MT5 broker handles every OrderRequest shape qkt's DSL can emit, with full lifecycle event propagation. Hedge-straddle's pending-OCO loop runs end-to-end with sub-poll-interval latency for cancel-on-fill and dedicated cancellation detection for GTD-expiry.
What's new¶
/orders poller¶
MT5Client.getPendingOrders(magic): List<MT5PendingOrder>— new method, mirrorsgetPositionsshape. Returns empty if the gateway doesn't expose/orders(404 → retry-with-null path).MT5PendingOrderwire type —ticket,symbol,type(BUY_STOP/SELL_LIMIT/...),volume,priceOpen,sl,tp,magic,timeSetup,timeExpiration,comment.MT5PendingOrderPoller— independent thread-based poller, same lifecycle pattern asMT5PositionPoller. Calls back to the broker viaonPendingDisappeared(ticket).- TTL-cached fill-vs-cancel disambiguation —
MT5Broker.recentlyFilledTickets: Map<Long, Long>(ticket → fill epoch ms). Populated byonPendingPositionOpened(Phase 26c path); consumed byonPendingDisappeared. The TTL ispollIntervalMs × 3— enough headroom for the position poller to tick after the pending poller does.
PERCENT trailing¶
MT5OrderTranslator(... priceTracker: MarketPriceProvider? = null)— new optional constructor parameter. Tests construct the translator without it; production wires it through fromBrokerFactory.translateTrailingStopPERCENT branch — computesmid × trailAmount / 100at submit time, then÷ pointSizefor the MT5 wire'ssl_distancefield.- Actionable error paths:
- No
priceTrackerconfigured →"PERCENT trailing requires a MarketPriceProvider" - Tracker present but no
lastPricefor symbol →"PERCENT trailing requires lastPrice for $symbol; ensure the tick stream is active"
- No
Order modification¶
BrokerEvent.OrderModified— new event sibling ofOrderCancelled. CarriesclientOrderId,brokerOrderId,strategyId,timestamp. The event itself doesn't carry the new SL/TP values — qkt-sideOrderManagerupdates state from theOrderModificationthe caller supplied.MT5Client.modifyOrder(ticket, MT5OrderModification)— POSTs to/modify-order/{ticket}. Encodes only the non-null fields (price, sl, tp, sl_distance, expiration).MT5OrderModificationwire type — five nullable BigDecimal/Long fields.MT5Broker.modify(orderId, changes)— looks up ticket inpendingTickets; rejects fast if not found; calls client; emitsOrderModifiedon success or returns aSubmitAck(accepted=false)with the venue's retcode/message.OrderTypeCapability.MODIFYadded toMT5Protocol.capabilities.
Migration from previous phase¶
Two breaking changes for downstream consumers:
| Before (Phase 26c) | After (Phase 26d) |
|---|---|
MT5OrderTranslator(profile, symbol) |
MT5OrderTranslator(profile, symbol, priceTracker: MarketPriceProvider? = null) |
MT5Broker(profile, bus, clock, client?) |
MT5Broker(profile, bus, clock, priceTracker: MarketPriceProvider? = null, client?) |
Both new parameters default to null and preserve existing test fixtures. Production wiring (DaemonCommand.kt) updates the factory to pass the MarketPriceTracker it already receives.
BrokerEvent gains a new variant (OrderModified). EventBus.stamp adds the case. No existing event subscriptions need to change — they only receive the variants they subscribe to.
MT5BrokerIntegrationTest setup now enqueues 3 [] responses instead of 2 (the third for the pending poller's startup seed). Existing tests' server.takeRequest() chains updated to consume the extra setup call.
MT5Protocol.capabilities gains MODIFY. Strategy authors can now rely on the capability check at submit time when calling broker.modify from Kotlin-DSL strategies.
Usage cookbook¶
PERCENT trailing stop on EURUSD¶
val req = OrderRequest.TrailingStop(
id = "trail-pct",
symbol = "EURUSD",
side = Side.BUY,
quantity = BigDecimal("0.1"),
trailAmount = BigDecimal("0.5"), // 0.5% of current price
trailMode = TrailMode.PERCENT,
timeInForce = TimeInForce.GTC,
timestamp = clock.now(),
)
broker.submit(req)
// At current price 1.10000 and pointSize 0.00001:
// abs distance = 1.10000 × 0.5 / 100 = 0.00550
// slDistance = 0.00550 ÷ 0.00001 = 550 points
// Wire request: { type: "BUY", sl_distance: 550, ... }
Detecting an externally cancelled pending order¶
Place a pending order from any strategy. If the user cancels it in MetaTrader (or its GTD expires):
broker.exness pending poller: ticket=999 left /orders snapshot
broker.exness OrderCancelled clientOrderId=stop-26d-cancel reason="external or gtd-expired (pending disappeared from venue)"
OrderManager removes stop-26d-cancel from tracked orders
The strategy's POSITION.<stream> accessor immediately reflects "no pending" without waiting for the next strategy tick.
Modifying a working pending order¶
// Move a pending stop's trigger price from 1.1050 to 1.1075
val ack = broker.modify(
orderId = "stop-1",
changes = OrderModification(newStopPrice = BigDecimal("1.1075")),
)
if (ack.accepted) {
// OrderModified event already on bus; OrderManager has updated its tracked trigger price
}
For changing SL/TP on a position post-fill, use the existing Signal.Modify(orderId = positionTicket, ...) path — same broker method, different orderId source.
Testing patterns¶
Path-routing dispatcher¶
When testing brokers with multiple pollers against MockWebServer, use a Dispatcher to route by path instead of a FIFO queue — otherwise the position poller and pending poller race for the next queued response:
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = when {
request.path.orEmpty().startsWith("/positions") -> MockResponse().setBody("[]")
request.path.orEmpty().startsWith("/orders") -> MockResponse().setBody("[]")
request.path.orEmpty().startsWith("/order") -> MockResponse().setBody(/* placeOrder response */)
request.path.orEmpty().startsWith("/modify-order") -> MockResponse().setBody(/* modify response */)
else -> MockResponse().setResponseCode(404)
}
}
This pattern is now used by both the Phase 26c and Phase 26d integration tests. Strongly recommend it for any new broker integration tests.
Flip-flag for snapshot transitions¶
When the test needs the venue's snapshot to change mid-flight (e.g. "pending appears then disappears"), use a mutable flag captured by the dispatcher:
var ordersHasTicket = false
server.dispatcher = object : Dispatcher() { /* reads ordersHasTicket */ }
broker.submit(req)
ordersHasTicket = true
Thread.sleep(300) // give the poller time to observe the pending
ordersHasTicket = false
// poller's NEXT tick sees disappearance → OrderCancelled
Known limitations¶
-
mt5-gateway endpoint dependency. The
/ordersand/modify-orderendpoints must exist on the gateway side for full Phase 26d functionality. If/ordersdoesn't exist (returns 404),MT5Client.getPendingOrdersreturns empty — the poller stays harmless but doesn't detect cancellations. If/modify-orderdoesn't exist,broker.modifyreturns rejected with HTTP error. Verify gateway capabilities before relying on these in production. -
OrderModificationcarriesnewQuantity,newLimitPrice,newStopPriceonly. NonewTakeProfitornewStopLoss(separate from trigger price) today.MT5Broker.modifyonly sends thepricefield. ExtendOrderModificationwhen a use case appears. -
No DSL surface for
MODIFY. Strategy authors writing Kotlin-DSL strategies can callSignal.Modify(orderId, changes)directly. Text-DSLMODIFY <stream> SET ...syntax is a future phase. -
TTL is per-broker-instance. If the daemon restarts, the
recentlyFilledTicketsmap is empty. A pending order that filled just before the restart and whose disappearance from/ordersarrives just after would be mis-classified as "external or gtd-expired" — an edge case but worth documenting. State recovery (Phase 7g) does its own reconciliation pass on startup that should catch this. -
Position modification (modifying SL/TP on an open position, not a pending order) is supported by MT5's
OrderModifybut not yet wired in qkt. The broker'smodifypath usespendingTicketsfor ticket lookup — extending it to position tickets is a small change but deferred.
References¶
- Spec:
docs/superpowers/specs/2026-05-12-phase26d-orders-percent-modify-design.md - Plan:
docs/superpowers/plans/2026-05-12-phase26d-orders-percent-modify.md - Code:
src/main/kotlin/com/qkt/broker/mt5/MT5PendingOrderPoller.kt— newsrc/main/kotlin/com/qkt/broker/mt5/MT5Broker.kt— modify override, pending poller wiring, TTL cachesrc/main/kotlin/com/qkt/broker/mt5/MT5OrderTranslator.kt— PERCENT branchsrc/main/kotlin/com/qkt/broker/mt5/MT5Client.kt— getPendingOrders, modifyOrdersrc/main/kotlin/com/qkt/broker/mt5/MT5WireTypes.kt— MT5PendingOrder, MT5OrderModificationsrc/main/kotlin/com/qkt/broker/mt5/MT5Protocol.kt— MODIFY capabilitysrc/main/kotlin/com/qkt/events/BrokerEvent.kt— OrderModified variant- Tests:
src/test/kotlin/com/qkt/broker/mt5/MT5BrokerIntegrationTest.kt,MT5OrderTranslatorTest.kt - Phase 26c (the fill-detection path this phase complements):
docs/phases/phase-26c-pending-fill-lifecycle.md