Skip to Content
EngineeringAuto-wheel and signals

Auto-wheel and signals

The wheel has no entities of its own

It is a decider on top of the cycles table: it takes a closed turn with auto_wheel set and either opens the next one or marks the chain stopped. It creates no tables.

POST /positions (autoWheel: true) → cycles.create(step='funding') ↓ the positions tick funding → placing → open → settling ↓ SettlingStep ├─ conversions.startTail(...) the conversion circle starts BY ITSELF (C1) └─ cycles.advance(step='closed') ↓ the row enters cycles_wheel_due WheelScheduler → WheelRunner.decide → positions.openNext → cycles.roll the new turn starts directly in 'placing'

The roll starts at placing, not funding: the collateral is already physically at the exchange, and a second funding would send the same amount again. The shared insert SQL is parameterised by step so that a divergence between the manual and automatic paths is syntactically impossible.

The roll is one transaction. A crash between two writes would produce a second turn on the next tick, and the unique index would only save us if the instrument were the same — with a shifted showcase the wheel picks a different one, and the user ends up with two positions bought with their money.

The wheel’s state lives in three fields

StateHow it looks
turningstep='closed', auto_wheel, stop_reason IS NULL, no step_state.rolled
claimed by a ticklocked_by, locked_until = now+2m
deferrednext_attempt_at = now+60s, attempt+1, and on the first deferral, deadline_at
rolledstep_state.rolled = true, nextCycleId — out of the selection forever
stoppedstop_reason from a closed vocabulary, next_attempt_at = NULL

The cycles_wheel_due index was recreated to add step_state->>'rolled' IS NULL to its predicate (I6): without it the index permanently retained the most common outcome of all — a successful roll.

The index is wider than the query. It covers ('closed','rejected','cancelled') while claimWheelDue filters on step = 'closed' only. A chain that broke on rejected (PRICE_MOVED, NO_MARKET) or cancelled is not picked up by the wheel at all. Formally not a bug — such turns have a stop_reason, which is filtered out earlier — but the discrepancy is real.

The tick

@Cron(EVERY_MINUTE), lock 728_035_957_006, BATCH = 20.

WHEEL_ENABLED is checked BEFORE taking the lock, so a disabled wheel does not hold it or confuse the log. The fact that it is off is logged once in the constructor, not every minute: that is a configuration state, not an event.

The flag is parsed explicitly with z.enum(['true','false']): z.coerce.boolean() looks at string non-emptiness, so 'false' would become true.

The decision: order of checks

  1. Deadlinenow > deadlineAt → stop deadline.
  2. Is a conversion running on this subaccount → defer. Moved to the very front (C2): the releasing stage takes collateral off before the next turn opens, getCollaterals returns empty, and that yields a false “not enough balance” stop in exactly the ITM scenario the wheel exists for.
  3. A Postgres failure → unconditional deferral, without classifyDeriveError (I2): the mapper’s fallback class is fatal, and a one-off pool timeout used to produce a stop labelled “exchange refused” — lying about the cause.
  4. subaccount === nullthrow past every catch: that is data corruption, not an operational condition, and covering it with “exchange refused” would put a lie into the reporting.
  5. subaccount.deriveSubaccountId === null → defer. Unlike the previous branch this one is not an error: for a closed turn the state is impossible by construction, but the type permits it, so the code defers rather than asserting (wheel.runner.ts:131).
  6. A failed circle for THIS turn → stop conversionFailed.
  7. The showcase snapshot: SnapshotNotReadyError → defer (cold start), UnsupportedAssetError → stop assetDelisted.
  8. getCollaterals: fatal → stop exchangeRefused, anything else → defer.

The turn’s side comes from the collateral, not from the previous option’s type (W2)

The conversion circle after an ITM expiry already leaves the opposite asset, so the reversal comes for free — one branch instead of two. The comparison is by valuation (amount × markPrice), not by presence: a call’s premium arrives in USDC, so both currencies are present at once, and that is normal. Both zero → stop tooSmall.

The signal filters, APR ranks

candidates.filter(hasSignal && type === side) empty → stop noSignal → sort: apr DESC, expiryAt ASC, strike ASC → [0]

The signal is a hard filter: if no marked candidate of the required side exists, the wheel stops rather than rolling into “something”. APR is only a ranking within the marked ones. The tie-break is deterministic, because a run must be reproducible.

The selection threshold is deliberately not re-checked in the runner: the showcase drops non-passing candidates when the snapshot is built, so everything that reaches here has already cleared MIN_APR, MIN_MARK_RATIO and the delta corridor. A second sieve would mean two places holding one rule.

Size and the premium floor

It sells the entire available balance, with no reserve held back: margin utilisation at full collateralisation is 10–25%, so “a reserve would protect against something that does not happen”.

For a put, the division by strike happens before rounding down, so strike × qty ≤ balance by construction.

The premium floor (W7): mark = premium / bidToMark, minPremium = mark × minMarkRatio. Identical to markPrice × 0.85; it cannot be written through markPrice because the candidate DTO has no such field. The meaning: the wheel will not accept a fill after which the candidate would stop being shown to a human — by the definition of the threshold, not by a separately tuned tolerance.

Stopping

Level 1 — the turn, with a closed vocabulary of reasons

You cannot count why the wheel stops from a free-text string (W9).

KeyWhen
noSignalno marked candidate of the required side
tooSmallboth valuations zero, or qty < minimumAmount
conversionFailedthis turn’s circle failed
deadlinenow > deadline_at
subaccountStoppedopenNext returned SUBACCOUNT_STOPPED
exchangeRefusedclassifyDeriveError = fatal
assetDelistedthe asset was removed from the showcase

The wheel sets its own deadline (C3)

On the first deferral: 20:00 UTC of the current day, or the next day if that has passed. A repeat deferral does not touch an already-set deadline — otherwise a stuck circle would push it back forever.

Why this was necessary: a closed turn’s deadline_at is always null — the field is cleared on placing → open, where it meant the 20-minute maker window rather than the wheel’s daily deadline. Without self-setting, the check would never have fired.

Level 2 — the subaccount

StopService.raise: the stop_raised event first, the status second, the alert third and in its own try/catch — “the alert notifies a human, the stop protects the money, and the second matters more”.

Level 3 — the master switch

WHEEL_ENABLED, defaulting to false. The engine spec’s §7 precondition: the wheel is only turned on after a manual cycle and a conversion circle have each run live at least once. At the time the engine was merged, neither had.

Clearing a stop is implemented nowhere. stop_reason is cleared by not one line of code, and there is no transition of subaccounts.status back to 'active' anywhere. The only way to resume is to open a new position manually or to edit the row in the database. The endpoint for lifting it “lives in an admin panel that does not yet exist”.

What happens to the funds on a stop

Nothing: they stay on the subaccount (P1), and there is no automatic return.

Signals

POST /webhooks/signals?token=… → 204. A second endpoint, GET /signals/state, returns the state map without authentication.

The guard compares the token from the query string against WEBHOOK_SECRET with timingSafeEqual over an HMAC — the HMAC is there only to equalise lengths so the length does not leak.

Table signal_state, PK (asset, timeframe), at most 4 rows: 2 assets × 2 timeframes. Checks: timeframe ∈ ('6h','24h'), direction ∈ ('up','down'), level ∈ (10,20,30,40,60,70,80,90), asset ∈ ('ETH','BTC').

The levels mean different things per timeframe: 24h carries strength-zone boundaries (10/20/30/70/80/90), 6h carries direction confirmation (40/60). A level 40 on 24h is rejected.

Storage is an in-memory Map, warmed on onModuleInit.

Signals have no expiry. updated_at is written and read but checked nowhere. The last value received stays in force indefinitely. A silent TradingView leaves the showcase and the wheel acting on a stale signal rather than falling back to “no signal”.

Matching rules

Top to bottom, first match wins. Each rule marks two strikes: the target and the next one out of the money.

Calls (require direction === 'up' on both timeframes):

ConditionOffset from spot
s24 ≥ 900% (ATM)
s24 ≥ 80 && s6 ≥ 600%
s24 ≥ 80+4%
s24 ≥ 70+6%

Puts mirror it: s24 ≤ 10 → 0%; s24 ≤ 20 && s6 ≤ 40 → 0%; s24 ≤ 20 → −4%; s24 ≤ 30 → −6%.

Strikes are taken from the already filtered candidates (after the delta corridor and thresholds), not from the full catalogue. “The next OTM” means the next among those that passed selection, not the next one on the exchange.

The user and the automation see different signal cards. The frontend (limitSignalToShortestExpiry) keeps the signal only on the shortest expiry for each (type, strike) pair. The wheel does not do this — it takes the highest APR among all marked candidates, including far expiries.

paused

The column suppresses all of an asset’s signals. No endpoint and no line of code ever sets it — only a manual UPDATE in the database.

When there is no signal

  • the showcase: every candidate carries hasSignal: false, and the candidates are still shown;
  • the wheel: stop noSignal — it stops for good rather than waiting.

“Display may serve a cache; a decision never may”

This rule lives in PlacingStep: the showcase cache is never used in a decision. A stale quote on the showcase is cosmetic; in a decision it is an option sold at the wrong price.

The wheel does use the snapshot — but only to choose the instrument and compute minPremium; it does not fix an execution price. The roll places the turn straight into placing, and PlacingStep fetches the live quote and checks it against min_premium.

For the showcase’s own mechanics — the refresh loop, the thresholds, the two transports — see Market data.

What is implemented and what is not

Covered by tests: the whole roll / defer / stop contour (22 scenarios), claim concurrency against a live database (SKIP LOCKED, atomicity of the roll, re-claiming an already-rolled turn), showcase assembly, the gateway, the guard, and signal upserts.

Not implemented:

  • the wheel is off in production (WHEEL_ENABLED=false);
  • no lower bound on dte — the product owner has not decided. A short expiry’s 365/dte multiplier produces APRs in the hundreds of percent, which is a property of annualisation, not a computation defect. Partially covered by minDaysAhead: 3;
  • the showcase transport is REST polling, not Derive’s WebSocket (SnapshotSource exists for the future switch);
  • there is deliberately no authentication on the showcase or the gateway;
  • paused has no control interface;
  • signals have no expiry;
  • clearing a stop is not implemented.