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
| State | How it looks |
|---|---|
| turning | step='closed', auto_wheel, stop_reason IS NULL, no step_state.rolled |
| claimed by a tick | locked_by, locked_until = now+2m |
| deferred | next_attempt_at = now+60s, attempt+1, and on the first deferral, deadline_at |
| rolled | step_state.rolled = true, nextCycleId — out of the selection forever |
| stopped | stop_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
- Deadline —
now > deadlineAt→ stopdeadline. - Is a conversion running on this subaccount → defer. Moved to the very front (C2): the
releasingstage takes collateral off before the next turn opens,getCollateralsreturns empty, and that yields a false “not enough balance” stop in exactly the ITM scenario the wheel exists for. - A Postgres failure → unconditional deferral, without
classifyDeriveError(I2): the mapper’s fallback class isfatal, and a one-off pool timeout used to produce a stop labelled “exchange refused” — lying about the cause. subaccount === null→throwpast every catch: that is data corruption, not an operational condition, and covering it with “exchange refused” would put a lie into the reporting.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).- A failed circle for THIS turn → stop
conversionFailed. - The showcase snapshot:
SnapshotNotReadyError→ defer (cold start),UnsupportedAssetError→ stopassetDelisted. getCollaterals:fatal→ stopexchangeRefused, 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).
| Key | When |
|---|---|
noSignal | no marked candidate of the required side |
tooSmall | both valuations zero, or qty < minimumAmount |
conversionFailed | this turn’s circle failed |
deadline | now > deadline_at |
subaccountStopped | openNext returned SUBACCOUNT_STOPPED |
exchangeRefused | classifyDeriveError = fatal |
assetDelisted | the 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):
| Condition | Offset from spot |
|---|---|
s24 ≥ 90 | 0% (ATM) |
s24 ≥ 80 && s6 ≥ 60 | 0% |
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’s365/dtemultiplier produces APRs in the hundreds of percent, which is a property of annualisation, not a computation defect. Partially covered byminDaysAhead: 3; - the showcase transport is REST polling, not Derive’s WebSocket (
SnapshotSourceexists for the future switch); - there is deliberately no authentication on the showcase or the gateway;
pausedhas no control interface;- signals have no expiry;
- clearing a stop is not implemented.