Positions: from request to settlement
POST /positions → 202 prepare(): 8 checks, two independent size sieves
↓ cycles.create(step='funding')
funding get funds to the subaccount, confirm via the deposit history
↓
placing maker for 20 min between bid and mark → then taker unconditionally
↓
open survive until expiry, watch margin during the last hour
↓ 08:00 UTC
settling settlement price → debit from the exchange → ITM/OTM → start the conversion circle
↓
closed funds STAY on the subaccount (P1)The cycles table is state and queue at once
There is no separate queue: “a queue derived from state cannot disagree with it”. The table is named
cycles while the API calls the same thing positions (D8).
Key columns: step, step_state jsonb, instrument_name, option_type, strike, expiry_at,
qty_requested, qty_filled (the sum of fills is the position, P5), min_premium,
collateral_currency, collateral_amount, derive_order_id, cancel_requested, auto_wheel,
attempt, next_attempt_at, deadline_at, locked_by, locked_until, stop_reason.
The cycle’s id doubles as the order’s label at the exchange (P9).
Indexes:
cycles_due—(next_attempt_at) WHERE step NOT IN (terminal)cycles_active_instrument— unique(subaccount_id, instrument_name) WHERE NOT terminal: without it a second press of “open” would create a second positioncycles_wheel_due— for the wheel, see Auto-wheel and signals
step_state holds what must not live in process memory: a restart between sending and confirming
would otherwise move the same money twice. On a step transition the state is cleared.
The tick: two different protections
@Cron(EVERY_MINUTE, waitForCompletion, unrefTimeout), BATCH = 20.
Protection 1 — claimDue: UPDATE … WHERE … FOR UPDATE SKIP LOCKED sets locked_by and
locked_until (TTL 2 minutes). SKIP LOCKED holds concurrent selects apart inside the
transaction; locked_until does so after it closes — and it must close, because the step makes
network calls.
next_attempt_at IS NULL also passes the selection — that is what a cycle waiting on an external
event looks like.
Protection 2 — advisory lock 728_035_957_002: between selection and marking there is a window
for two instances, and the price of that window is two orders on one cycle — a doubled position
with the user’s money.
The driver trap, closed twice in this code. TypeORM’s Postgres driver returns
[rows, affectedRowCount] from query() for an UPDATE. Without destructuring, rows is always
length 2, toRow receives an array and a number, none of the “cycles” has a step, and the scheduler
silently advances nothing. In the log it looks like “took 2, advanced 0, failures 0” against an
empty database.
The handler is found by a linear scan on handler.step === cycle.step; if none matches it
continues silently (terminal steps never enter the selection, and a module-assembly test checks the
set is complete). One cycle failing does not touch its neighbours, and an unknown error is fatal by
default → StopService.
The handler set is assembled by hand as a list, not by scanning: the set of steps is the state machine, and it must be readable at a glance in one place.
funding
Branches on step_state.transactionId: no receipt → send(), receipt → poll().
send():
cancelRequested→cancelled. The only moment a cancellation is free — the money is still in the free balance.fxRatefrom the showcase snapshot, the same source the treasury uses. A second price source would create a second version of the truth, and reconciliation would see a divergence that does not exist.knownIds— the list of subaccounts before sending, and only when a new one is being created.provisioner.fund(...).- The receipt is written immediately. A crash between sending and recording would mean a second transfer on the next tick.
poll() reads the subaccount’s deposit history, because private/get_transaction does not
exist (404, verified 2026-08-21). An unrecognised status means keep waiting. A terminal non-success
means rejected.
Code and comment disagree. transaction.schema.ts states: “the funding step has its own deadline
and it will end the wait”. In FundingStep, deadlineAt is never set and never read — on an
unrecognised status the cycle will be polled every minute forever, incrementing attempt, which is
likewise never compared against a ceiling.
placing
Three states, distinguished by the cycle row. None of them waits inside itself: the twenty
minutes live in deadline_at, the minute until the next check in next_attempt_at.
| In the row | Action |
|---|---|
deriveOrderId === null | placeMaker() — re-request the quote, place post_only |
| deadline intact | checkFill() |
now >= deadlineAt | takeRemainder() — cancel and take the rest as taker, unconditionally |
Branch order: cancelRequested is checked after the “no order” branch. A cancellation that
arrives before the first placement does not prevent it — the tick places the maker order first, and
only the next one removes it.
Price
book = best bid from the BATCHED ticker
rfq = private/rfq_get_best_quote
best = max(book, rfq)The showcase cache is never used here. The display path may serve a snapshot; the decision path may not. A stale quote on the showcase is cosmetic; in a decision it is an option sold at the wrong price.
Why the batched ticker and not the per-instrument one — one of the most important findings.
Measured 2026-08-21: on the same instrument at the same moment, per-instrument public/get_ticker
returned best_bid_price: 0 while batched public/get_tickers returned b: 106.1 with size 122
against a mark of 112.5. Checked on five instruments. On 2026-08-17 they agreed — so this was a
change in exchange behaviour.
What it threatened: with minPremium > 0 every position would be rejected as PRICE_MOVED; with
zero, the maker order would go out at (0 + mark)/2, selling the option at half its fair price.
The per-instrument ticker is still needed for one thing only — the fee rates, which are not in the batch.
A zero or negative bid means no market, not a price of zero.
The maker price: (price + mark) / 2
A post_only order must not cross the spread, or the exchange turns it into a taker order and the
fee grows from $0.09 to $0.78 on 0.5 ETH — ruining exactly the number the test exists to measure.
The refusals are distinct: NO_MARKET (neither bid nor RFQ — no price exists) and PRICE_MOVED (a
price existed and fell below minPremium). To the user these are different pieces of news.
minPremium is not applied at the deadline (P4): a possible fill worse than hoped beats a
guaranteed absence of a position twenty minutes later.
recordFills
Each fill is its own event with its own trade_id (P5); a repeated pass is rejected by the
journal’s unique index rather than by a check in code. premiumGross = fill.price × fill.amount,
both numbers from the exchange’s response. fee comes straight from the response.
filled.isZero() means there is NO position. Measured 2026-08-24: an order signature is valid
for 600 seconds while the maker window lasts 20 minutes — so an order disappearing without a
fill is inside the normal path, not at its edge.
The old code moved the cycle to open with qty_filled = 0 and not a single option_sold: no
premium, no sale, collateral locked until expiry, and settlement arriving to close a position that
does not exist. The correct answer is to clear deriveOrderId and let the step place a new order —
without touching the deadline, which is absolute.
cancelOrders is private/cancel_all rather than per-order: a cycle cannot have two live orders
(cycles_active_instrument forbids it), and the shape of cancel_all’s response was captured from a
live call — a bare string "ok".
open
Does exactly two things: watches margin in the last hour before expiry, and moves the cycle to settlement. Polling every minute around the clock would be 1,440 calls a day per position for the sake of the one hour in which they mean anything.
The trigger is maintenanceMargin.lt(0). maintenanceMargin is a buffer in USD, not a
requirement. Live example 2026-08-15: value $19.74, initial $14.81, maintenance $15.80 — read
as a “requirement” this looks inverted, and the comparison would stay silent exactly when it should
shout.
The watcher only alerts, and that is not a simplification. Early buy-back was rejected by the spec (the ask runs 25–90% above the bid) and conversion is excluded. There is nothing available to prevent a liquidation. Calling it protection would be untrue, and an untruth in a name will one day make someone rely on it. The alert text repeats this to the user.
The second honest note is the channel: without Telegram credentials LoggingAlertSink.warn is
logger.warn and nothing else. Today nobody learns of an approaching liquidation unless they are
watching the log.
settling
The settlement price comes from public/get_option_settlement_prices. null is a normal answer —
the price appears some minutes after expiry; failing the cycle here would wake someone every morning
at 08:00 UTC.
The debit comes from private/get_option_settlement_history. Also null → wait: the price being
published and the settlement event appearing on the subaccount are not simultaneous.
The debit is read from the exchange, not computed (P6). Computing it as (spot − strike) × qty
is forbidden: our arithmetic will disagree with the exchange’s rounding, and reconciliation will alert
on every ITM position. The separate method exists precisely because the temptation to compute it is
strong and looks harmless.
itm = zero.lt(debit)
Three reasons for that exact expression:
- ITM is derived from the debit itself, not from comparing price to strike — that comparison would have to be written differently for a call and a put, while the debit answers identically for both.
zero.lt(debit)rather thandebit.gt(zero)—Moneyhas nogt.- A named variable rather than an inline expression: two consumers answer this question — the journal event and the conversion circle — and two computations will eventually disagree.
Where the debit comes from
The item’s shape is taken from Derive’s documentation
(api-reference/history/privateget_option_settlement_history, checked 2026-09-11):
settlement_value = intrinsic value × amount
amount signed size: NEGATIVE for a short positionThe product only ever sells options, so amount < 0, and everything else follows:
| Outcome | settlement_value | debit |
|---|---|---|
| ITM | negative | its absolute value |
| OTM | zero | zero |
The itm flag is built on that zero. A positive value would mean a long position, i.e. a credit —
and debitFrom throws on it rather than taking the absolute value: this product opens no long
positions, so such a response means the model has diverged, not that one call glitched.
The shape has not been confirmed by a live response — there has never been a settled option on the
account, and the first live settlement must be checked against the schema. Derive’s documentation has
already disagreed with reality in this project (F17 on fees; the vault address instead of the
token address for WBTC). But the schema is now strict: a missing settlement_value fails parsing
with a clear message instead of silently substituting a neighbouring field.
Historically the item was parsed with
passthrough()and the value pulled by trying candidates['settlement_value','value','amount','pnl','realized_pnl']with an unconditional absolute value. The happy path was correct — the right field comes first and is always present. The danger was in the fallbacks: third in the list wasamount, the position size, which is non-zero for any sold option — so had it ever been reached, every expiry would have read as ITM and started a conversion.
The event is option_settled, keyed {sub}:{instrument}:{expirySec}. Postings only on ITM:
+expense:settlement / −sub(USDC).
Funds stay on the subaccount (P1) whatever the outcome; there is no automatic return. To take
them out: POST /subaccounts/:asset/release, which refuses while a position is open
(POSITION_OPEN — the collateral is physically locked by the exchange) and while a circle is running
(CONVERSION_ACTIVE).
conversions.startTail(...) is called before closing the cycle and inside its own try/catch:
the settlement is already posted to the journal, and rolling it back because starting a circle failed
would be worse than a circle that the next tick will start.
Request validation
prepare() is shared between manual opening and the wheel’s roll — extracted rather than duplicated:
a divergence would mean automation can open what a human cannot, and vice versa.
| Check | Code | HTTP |
|---|---|---|
| instrument is in the current showcase snapshot | INSTRUMENT_NOT_IN_SHOWCASE | 404 |
| instrument minimum (exchange sieve) | AMOUNT_TOO_SMALL | 409 |
| instrument step (exchange sieve) | AMOUNT_NOT_ON_STEP | 409 |
| collateral is computable | COLLATERAL_UNAVAILABLE | 409 |
| profile minimum (product sieve) | PRODUCT_MIN_NOT_MET | 409 |
| profile step (product sieve) | PRODUCT_STEP_NOT_MET | 409 |
| subaccount not stopped | SUBACCOUNT_STOPPED | 409 |
| no conversion in progress | CONVERSION_ACTIVE | 409 |
Checking against the snapshot is not a lookup convenience: the snapshot is the only place where
an instrument has already passed threshold selection. Trading something that never passed selection
means bypassing the entire risk policy with one POST and an arbitrary ticker. optionType, strike
and expiryAt come from there too — values sent by the client would be a second source of truth.
CONVERSION_ACTIVE exists because a circle physically holds part of the collateral on Base while the
exchange does not know that and will show it as free. Reserve C8 covers the journal, but funding
draws on the exchange’s collateral, which the reserve does not touch.
Two independent size sieves
The exchange sieve applies to qty and reads minimum_amount/amount_step of the instrument
itself through public/get_instruments (cached 10 minutes — the call was measured at up to 35
seconds). Hardcoding them is forbidden by F17: the exchange may change the step without telling
anyone.
The product sieve applies to the computed collateral, not to qty — the key decision, because
one place then covers both strategies. A covered call’s collateral equals qty in the asset; a
cash-secured put’s is strike × qty in USDC. Checking qty would need a second branch for puts,
comparing contracts against dollars.
That same check closed a hole: before the profile existed there was no server-side check at all — the minimums lived as constants in the contracts package and were read only by the modal, so any request that bypassed the interface opened a position at a size the product does not sell.
The codes differ because to a person these are different pieces of news: the first cannot be fixed at all, the second is fixed by a different amount.
Profiles
| prod | dev | |
|---|---|---|
| ETH | min 0.5, step 0.1 | null |
| BTC | min 0.015, step 0.001 | null |
| USDC | min 1000, no step | null |
Shared by both: premiumTolerance 0.05, makerWindowMinutes 20 — deliberately, because if they
diverged, dev would stop exercising the behaviour that ships to production.
USDC has no step, and that is not an omission: a put’s collateral granularity is
strike × amountStep — at a strike of 2400 that is 24 USDC, and no fixed dollar step divides it. A
“multiple of 100” check would reject every put request.
dev is null in every row: the product minimum is lifted, so a live order is possible at 0.1
contracts instead of 0.5 ETH. The collateral ratio is untouched — P7 stays a hard invariant on
both profiles.
The profiles live in code: by project rule the env schema has no defaults, and the minimums table
would have become a dozen-plus required variables on every deployment. They differ by exactly one bit,
and TRADING_PROFILE is that bit.
TRADING_PROFILE is deliberately separate from NODE_ENV: you must not be forced to turn on
production logging in order to get cheap minimums. Paper mode is gone (removed 2026-08-27), so the
profile is the only way to make a live run cheap.
Size and collateral
cash-secured put → { currency: 'USDC', amount: strike × qty }
covered call → { currency: asset, amount: qty } ETH and BTC onlyCALL_COLLATERAL_ASSETS = {ETH, BTC} is the intersection of what the bridge carries and what the
exchange accepts as collateral. Absence is not an error but a reason to show the card with a
disabled button (D11).
One-to-one is a product definition, not a margin consequence (P7). Measured 2026-08-20: PM2 would have allowed selling a call on 0.5 ETH against collateral of 0.2 ETH. Taking the size from margin would mean selling partially naked calls under the name of covered ones. Margin permits more than the product permits.
The collateral currency is not the subaccount’s asset: the subaccount is opened for asset and funded
with the collateral currency. Confusing them means recording money into an account for a currency that
is not there.
Fees
Rates come from the ticker (F17); hardcoding is forbidden:
notional = indexPrice × qty
byNotional = notional × (taker|maker)FeeRate
byPremiumCap = premium × markPriceFeeRateCap
fee = (taker ? baseFee : 0) + min(byNotional, byPremiumCap)The cycle itself never computes a fee — fill.fee, the exchange’s own number, goes into the
journal (P6).
option_sold postings are two pairs, four legs: +gross sub:USDC / −gross income:premium and
+fee expense:derive_fee / −fee sub:USDC. “Net” is never stored as its own number — it is the
difference between two postings.
PlatformFee is consumed by no step; see Platform services.
Cancellation
DELETE /positions/:id — the endpoint does not cancel anything, it only sets cancel_requested;
the tick cancels under the lock.
The step condition lives inside the UPDATE itself, not in a check before it: between a check and
a write the cycle could reach open, and the mark would land on an already-open position.
404 for someone else’s cycle, not 403: a 403 would confirm that the cycle exists, making other
people’s identifiers distinguishable by probing. 409 ALREADY_FILLED is kept distinct from
not_found — merging them would tell someone whose request had just filled that no such request
exists.
Cancellation is possible only on funding and placing. Funds that already reached the subaccount
are not returned automatically — ReleaseService does that on request.
POST /positions answers 202, not 201: the position does not exist yet, and a 201 would be a lie
about an open position.
What has been verified live
| Verified | Not verified |
|---|---|
Order body accepted (post_only, limit 9999, subaccount 70021), 2026-08-24 | A live fill — never observed |
| PM2 accepts where SM rejected, 2026-08-24 | A full cycle through to closed — never completed |
| Order signature lives 600 seconds, 2026-08-24 | Settlement-history item shape — taken from documentation 2026-09-11, unconfirmed live |
get_open_orders returns an object, not an array | Trade-history item shape (M9) |
cancel_all returns a bare "ok" | A valid RFQ quote (is_valid: true) |
| The margin buffer, 2026-08-15 | withdraw with negative USDC — observed on Derive’s UI 2026-08-31 (trimmed to the margin limit), never measured by our own call |
| Batched-versus-single ticker divergence, 2026-08-21 | placeOrder as a method (the measurement used raw private/order) |