Monaco Protocol SDK v1.0.60
This release covers the consumer-facing changes since v1.0.59. A take-profit or stop-loss and the order it fires into are now linked in both directions: aTRIGGERED conditional order carries triggeredOrder — what its close actually did — and the close carries conditionalOrderId back to the trigger, so an exit in order history can be attributed without scanning the conditional list. The same work changes how a TRIGGERED CONDITIONAL row renders in getPaginatedOrders: it now mirrors its close instead of reporting a synthesized FILLED with filledQuantity: "0", which changes what a page of order history sums to. The cross risk-bucket preview drops selectedTradingPairIds — the field the server never priced anything from — and the SDK now rejects it rather than sending it. A new authenticated sdk.managedMarkets namespace gives an application’s configured maker wallet its assignment inventory, a fenced lease and a bounded quote refresh, and public market discovery now hides an operator-managed launch until it is live. If you build cross previews, sum fees or quantities over an order page, or blind-replace resting orders, read the upgrade notes before you bump.
Breaking
A cross risk-bucket preview no longer takes selectedTradingPairIds
sdk.marginAccounts.simulateRiskBucketOrderRisk (POST /api/v1/margin/risk-buckets/simulate-order-risk, gRPC MarginAccountsService.SimulateRiskBucketOrderRisk) no longer accepts the field for marginMode: "CROSS". The cross request is now { tradingPairId, marginMode: "CROSS", side, orderType, quantity, leverage?, price?, positionSide?, reduceOnly? } — the isolated form minus strategyKey. Passing selectedTradingPairIds throws ValidationError naming request.selectedTradingPairIds before any request is made, and the wire body no longer carries it. SimulateCrossRiskBucketOrderRiskRequest types the field ?: never, exactly as strategyKey already was, and TransferCollateralToCrossRiskBucketRequest made the same change to the same field one release earlier.
Cross scope is derived from trading: a pair joins the cross risk bucket when a cross order on it succeeds and, once its positions close, stays listed until a later successful cross order on another pair reconciles the bucket. The preview never priced anything from the caller’s list — the engine prices the positions it holds whatever the request declared — so the three rules the field carried (non-empty, containing tradingPairId, covering every pair with an open cross position) only ever produced 400s, which is why callers had taken to passing every margin pair.
The behaviour the server now allows is the inverse: a cross preview with no list, one whose list omitted tradingPairId, and one whose list omitted a pair with an open cross position all used to be rejected and now succeed, with unchanged preview figures. Raw REST and gRPC callers keep working — the wire field survives, marked deprecated, accepted and ignored, and sending it logs a server-side deprecation warning and has no other effect. It is still rejected for ISOLATED, as it always was, and that message now reads selectedTradingPairIds is not supported for ISOLATED risk buckets. The generated Rust REST request struct keeps the field with a deprecation note in its doc comment; the gRPC struct carries #[deprecated], so build it with ..Default::default() rather than naming the field, which trips rustc’s deprecated lint under -D warnings.
The response field of the same name is unchanged in type (?: string[]) and changed in meaning: from simulateRiskBucketOrderRisk it now reports the derived scope the preview ran against — the active cross bucket’s recorded pairs, every pair with an open position in it, and the previewed pair (the previewed pair alone before the first cross order) — never a caller-declared list. simulateParentMarginOrderRisk and simulateOrderRisk are unaffected; they never took the list.
The MCP simulate_risk_bucket_order_risk tool drops the field and its three refinements, declares positionSide the way the order tools do, and is now strict: selectedTradingPairIds — or any other unknown key — is refused with the key named rather than silently stripped. strategyKey stays ISOLATED-only.
Added
A take-profit / stop-loss and its close are linked in both directions
A conditional order fires on the mark price and then trades against the book, so the price it triggered at and the price its close filled at are independent numbers — nothing bounds one by the other, and they can coincide. Both sides are now readable from either end. Every read below needs an authenticated session and is owner-scoped: a caller only ever sees their own conditional orders and the closes they fired.ConditionalOrder gains triggeredOrder?: TriggeredOrderSummary, a newly exported interface — orderId, status, filledQuantity, and optional averageFillPrice, totalTakerFees, filledAt and terminalReason — read from the triggered order’s own row at request time. It is returned by getConditionalOrder (GET /api/v1/orders/conditional/{conditionalOrderId}), listConditionalOrders (GET /api/v1/orders/conditional) and under Order.conditional on getPaginatedOrders rows. It is present only once state is TRIGGERED and the close can be read: absent while waiting on the trigger, absent on a FAILED conditional, and absent on the conditional_orders WebSocket frames, snapshot and live alike. A FAILED conditional carries no summary because it never recorded a triggeredOrderId to join on. Whether a close row exists at all depends on where it failed: one the engine placed and the book then rejected persists as an order row carrying conditionalOrderId, while one refused before placement — a validation failure such as a bad quantity step — produces no order at all, and the conditional’s own failureReason is the only record of it.
status is copied verbatim from the close’s own row, so treat it as the full OrderStatus union and switch with a default branch rather than against a fixed list. What you will actually see: SETTLED once a fully filled leg has settled, and FILLED in the window before that — a filled order is a real persisted state on its way to SETTLED, not a value this field skips; SUBMITTED / PARTIALLY_FILLED for a LIMIT leg still resting; CANCELLED when the remainder was cut, which includes a MARKET or IOC leg that filled part of its quantity, so a CANCELLED close can carry a nonzero filledQuantity (read terminalReason); and EXPIRED for a LIMIT leg that lapsed.
Order.conditionalOrderId?: string is the reverse pointer: on an order the engine placed for a TP/SL trigger — including one the book then rejected — the conditional that fired it. A trigger refused before placement has no order row for this to sit on. It is served by the read APIs — getOrder and getPaginatedOrders, and their gRPC equivalents GetOrder / ListOrders — but never on a WebSocket frame: no order event and no snapshot row carries it, so a triggered close first seen through the socket acquires the field on the next read rather than never. It is absent on every order you placed yourself, on TWAP slice children (those carry parentOrderId), and on triggered closes written before the field existed: there is no backfill, so absence on an older close means unknown, not placed by hand. @0xmonaco/react’s useUserOrders carries it through as a REST-only field alongside terminalReason, expirationDate, positionSide and the fee aggregates.
Compare triggeredOrder.averageFillPrice with the conditional’s triggerPrice to see what the close actually paid. The two are independent numbers rather than one bounded by the other: the trigger fires when the mark crosses the level, the mark is a reference price rather than the book’s midpoint, and the worker samples it on a tick — so the mark can already be well past the level when the close is placed. A market close then pays the spread it crosses plus the depth its size consumes, which usually leaves the fill worse than the trigger, but nothing guarantees a minimum gap and a fast move can leave it better. Values for the same state are numerically equal to what getOrder returns, but the two reads are not guaranteed to see the same state: this summary is a repository read of the order row, while getOrder is cache-first and the read cache is fed by the live engine stream, so it can be ahead of the database by the persistor’s lag. A filledQuantity, averageFillPrice or fee total that disagrees between the two is usually that lag, not a bug — read getOrder when you need the close’s very latest state. Even for the same state the printed scale can differ, so never compare the raw strings with ===: compare as exact decimals, normalizing the scale or using a decimal library, rather than through Number(), which is inexact.
ConditionalOrder, TriggeredOrderSummary and ListConditionalOrdersResponse are now registered in the wire-coverage tripwire, so a future field on the conditional read model fails tsc until the hand-written type carries it.
See Trigger Price Is Not Fill Price.
Managed maker assignments
sdk.managedMarkets is a new authenticated namespace with three methods, matched by REST endpoints and by the gRPC monaco.api.managed_markets.ManagedMarketsService. It drives the maker side of an operator-managed market launch: the wallet the operator configured for that application claims a fenced lease and refreshes a server-bounded quote set, rather than placing orders itself.
All three need a wallet session — delegated-agent sessions are refused with
403, permanently rather than as a rollout state — but the maker requirement is narrower than that: claimLease and refreshQuotes are launch-scoped and need a session matching both the configured maker user and the application, while listAssignments takes any authenticated wallet and filters by it, so a non-maker gets an empty page rather than an error. ownerId is a process fence, not authorization — it decides which of your own processes holds the lease, and grants nothing.
generation and quoteRevision are uint64 fences carried as exact decimal strings in JSON and as uint64 in protobuf. Never round-trip them through Number. On an unknown quote outcome, reconcile through claimLease before advancing the revision rather than retrying the quote.
Quote intents are server-derived end to end: a fixed preallocated risk bucket, server-chosen prices and fees, and no caller price, quantity or identity override. They never fund accounts.
See sdk.managedMarkets for the method reference and the error map.
Changed
A TRIGGERED conditional row mirrors its close
In getPaginatedOrders (GET /api/v1/orders, gRPC ListOrders), a CONDITIONAL row whose conditional has triggered now reports the order it fired into instead of a synthesized FILLED with filledQuantity: "0". quantity is the size the leg resolved to; filledQuantity, averageFillPrice, quoteVolume, filledAt, cancelledAt and expiredAt are the close’s own; and status is the close’s current status. The status filter applies the same rule, so status=SETTLED returns exactly the triggered rows whose close has filled and settled, and status=SUBMITTED includes triggered LIMIT legs still resting. A triggered row whose close cannot be read keeps the previous rendering.
The fee fields stay absent on the CONDITIONAL row — the close’s own row carries them, and conditional.triggeredOrder.totalTakerFees repeats the total — so summing fees over a page counts each exit once. Quantities are the opposite case: the close is also listed as its own MARKET or LIMIT row, so sum quantity or quoteVolume over those rows only, or the triggered pair double-counts.
A triggered leg can be cancelled but not replaced
sdk.trading.replaceOrder (PUT /api/v1/orders/{orderId}) and sdk.trading.batchReplace (POST /api/v1/orders/batch-replace; gRPC ReplaceOrder / BatchReplaceOrders) now refuse to replace the LIMIT leg a take-profit or stop-loss fired into — an order carrying conditionalOrderId — the way a TWAP slice child is refused. A replacement rests under a new order id the conditional order does not point at, which would sever the link in both directions and leave every read of the TP/SL reporting the exit as cancelled while the live replacement rested unattributed. The refusal is an INVALID_ORDER rejection naming the conditional order, and the original leg stays resting. Cancel the leg and place a new order instead; cancelOrder on a triggered leg is unchanged.
Public market data hides an operator-managed launch until it is live
GET /api/v1/market/pairs, the pair detail reads, GET /api/v1/market/screener and the public price reads exclude an operator-managed market until its durable runtime projection reaches live — including when isActive=false is passed explicitly, so a pending, warming or halted managed launch cannot be surfaced by asking for inactive markets. A pair with no launch row is a legacy instrument and keeps its existing visibility, so nothing that trades today changes.
Upgrade
selectedTradingPairIds from the request — it now throws ValidationError client-side instead of reaching the server, and the response field of the same name means the derived scope rather than an echo of what you sent. If you aggregate a page of order history, re-read the TRIGGERED CONDITIONAL rule above: fees are safe to sum over the whole page, quantities are not. And if you replace resting orders without inspecting them, handle the INVALID_ORDER refusal on a leg carrying conditionalOrderId — cancel-and-replace is the remedy there.
The rest is additive, and the two new fields have different histories. triggeredOrder is a join performed at request time on the conditional’s existing triggeredOrderId, which predates this release, so a TRIGGERED conditional from before the upgrade gains the summary too — the join deliberately accepts a close whose own reverse pointer is absent. conditionalOrderId is the one with no history: it is stamped going forward and never backfilled, so its absence on an older close means unknown rather than “not placed by a trigger”. Neither is carried on a WebSocket frame — read both from REST. sdk.managedMarkets is a new namespace no existing call touches. 403 is reserved for delegated-agent sessions; a wallet that is simply not the application’s configured maker sees an empty listAssignments page, and the launch-scoped calls answer 404 rather than confirming a launch it has no part in.
