Diffusal

Series Creation

How option series are generated with expiry surfaces, strike ladders, and on-chain registration

This document explains how Diffusal generates its full options surface -- the expiry schedule, strike price selection, on-chain registration, and availability for trading. For a high-level overview, see Options Creation.


What is a Series?

A series is a unique options contract defined by four parameters:

ParameterTypeExample
pairIdbytes32keccak256("ETH-USDT")
strikeuint256 (WAD, 18 decimals)2500000000000000000000 ($2,500)
optionExpiryuint256 (unix seconds)1711612800 (2024-03-28 08:00 UTC)
isCallbooltrue = call, false = put

The seriesId is a deterministic hash of these four values:

seriesId=keccak256(abi.encodePacked(pairId,strike,optionExpiry,isCall))\text{seriesId} = \text{keccak256}(\text{abi.encodePacked}(\text{pairId}, \text{strike}, \text{optionExpiry}, \text{isCall}))

Supported Pairs

CategoryPairs
CryptoETH-USDT, BTC-USDT
CommoditiesCMD:GC-USDT (gold), CMD:SI-USDT (silver), CMD:BZ-USDT (oil)

Series Lifecycle

                createSeries()            expiry passes          settle()
  Non-existent ----------------> Active -----------------> Expired ---------> Settled
                                   |                                            |
                                   |  trades, quotes, positions                 |  locked, settlement
                                   |  open interest accrues                     |  price recorded
  • Active -- series is tradeable on the order book and via RFQ
  • Expired -- trading halted, awaiting settlement
  • Settled -- final settlement price recorded, positions can be closed

Expiry Surface

All options expire at 08:00 UTC. The expiry surface follows industry-standard conventions used by major derivatives exchanges.

Expiry Tiers

Today           +1d   +2d   +3d   +4d   +5d   +6d   +7d
  |              |     |     |     |     |     |     |
  Daily:         D1    D2    D3    D4    D5    D6    D7
                                          ^
                                          |
  Weekly:                          W1 (Fri)     W2 (Fri)     W3 (Fri)
                                                       ^
                                                       |
  Monthly:                               M1 (last Fri)   M2 (last Fri)   M3 (last Fri)
                                                                ^
                                                                |
  Quarterly:                                   Q1 (last Fri of Mar/Jun/Sep/Dec) ...
TierCountRuleExample Dates
Daily7Next 7 consecutive days at 08:00 UTCMar 31, Apr 1, ..., Apr 6
Weekly3Next 3 Fridays at 08:00 UTCApr 4, Apr 11, Apr 18
Monthly3Last Friday of next 3 months at 08:00 UTCApr 25, May 30, Jun 27
Quarterly3Last Friday of Mar/Jun/Sep/Dec at 08:00 UTCJun 27, Sep 26, Dec 26

Deduplication

When expiry dates overlap across tiers, the higher tier takes precedence:

Quarterly>Monthly>Weekly>Daily\text{Quarterly} > \text{Monthly} > \text{Weekly} > \text{Daily}

For example, if monthly-1 falls on a Friday that is also weekly-2, the expiry is labeled monthly-1 only. Each timestamp appears exactly once in the surface.

Minimum Buffer

The earliest valid expiry must be at least 65 minutes in the future:

  • 60 minutes: on-chain contract minimum (ensures time for TWAP price accumulation)
  • 5 minutes: headroom for RFQ auction latency

Expiry Kind Labels

Each expiry carries a kind label used throughout the system:

daily-1, daily-2, ..., daily-7
weekly-1, weekly-2, weekly-3
monthly-1, monthly-2, monthly-3
quarterly-1, quarterly-2, quarterly-3

Strike Ladder Generation

Strikes are generated using a graduated zone system that provides dense coverage near the money and wider spacing in the wings. Step sizes snap to human-readable "nice numbers" for clean strike prices.

Nice Number System

All step sizes are rounded to the nearest value in the set {1,2,2.5,5}×10n\{1, 2, 2.5, 5\} \times 10^n:

... 0.1  0.2  0.25  0.5  1  2  2.5  5  10  20  25  50  100  200  250  500  1000  2000  2500  5000  10000 ...

Algorithm:

  1. Compute magnitude: 10log10(rawStep)10^{\lfloor \log_{10}(\text{rawStep}) \rfloor}
  2. Normalize: normalized=rawStep/magnitude\text{normalized} = \text{rawStep} / \text{magnitude}
  3. Find closest value from {1,2,2.5,5,10,20,25,50}\{1, 2, 2.5, 5, 10, 20, 25, 50\}
  4. Result = closest ×\times magnitude

Example -- BTC at $70,700 with 0.7% step:

StepComputationResult
Raw step70,700×0.007=494.970{,}700 \times 0.007 = 494.9
Magnitude10log10(494.9)=10010^{\lfloor \log_{10}(494.9) \rfloor} = 100
Normalized494.9/100=4.949494.9 / 100 = 4.949
Nearest55 (distance 0.0510.051)$500

More examples:

InputMagnitudeNormalizedNearestOutput
494.91004.9495500
15.1101.51110
32.4103.242.525
60106.0550
700010007.055000
350010003.52.52500

Canonical Strike Filtering

Strikes displayed in the trading interface must pass a canonical check: at most 1 significant decimal digit after removing trailing zeros.

StrikeCanonical?
2000Yes
2100Yes
2125Yes
100.5Yes
2130.86No
70437.88No

Zone Configuration

Each expiry type uses exclusive zones with graduated step sizes. Zones do not overlap -- each zone owns a non-overlapping distance band from ATM. The upside range is wider than the downside (options skew).

Daily (2 zones)

                              Zone 1                                   Zone 2
                     step: 0.7% of spot                         step: 1.5% of spot

    -17% -------- -5% ============ ATM ============ +5% -------- +17%
                        <-- Zone 1 -->                     (x1.3 upside)
              <-------------- Zone 2 (exclusive) ------------->

Weekly (3 zones)

         Zone 3              Zone 2              Zone 1              Zone 2              Zone 3
      step: 3.0%          step: 1.5%          step: 0.7%          step: 1.5%          step: 3.0%

-30% ------- -15% ------- -5% ==== ATM ==== +5% ------- +15% ------- +30%
                                                                        (x1.4 upside)

Monthly (4 zones)

-60% ---- -30% ---- -15% ---- -5% == ATM == +5% ---- +15% ---- +30% ---- +60%
 Zone 4    Zone 3    Zone 2    Zone 1         Zone 1   Zone 2    Zone 3    Zone 4
 5.0%      3.0%      1.5%      0.7%           0.7%     1.5%     3.0%      5.0%
                                                                     (x1.5 upside)

Quarterly (5 zones)

-150% --- -60% --- -30% --- -15% --- -5% = ATM = +5% --- +15% --- +30% --- +60% --- +150%
 Zone 5    Zone 4   Zone 3   Zone 2   Z1          Z1      Zone 2   Zone 3   Zone 4    Zone 5
 10.0%     5.0%     3.0%     1.5%    0.7%        0.7%     1.5%     3.0%     5.0%      10.0%
                                                                                 (x2.0 upside)

Full Configuration

ExpiryZoneStep (% of spot)Band (downside)Band (upside)
Daily10.7%0 - 5%0 - 6.5%
21.5%5 - 17%6.5 - 22.1%
upside: 1.3x
Weekly10.7%0 - 5%0 - 7%
21.5%5 - 15%7 - 21%
33.0%15 - 30%21 - 42%
upside: 1.4x
Monthly10.7%0 - 5%0 - 7.5%
21.5%5 - 15%7.5 - 22.5%
33.0%15 - 30%22.5 - 45%
45.0%30 - 60%45 - 90%
upside: 1.5x
Quarterly10.7%0 - 5%0 - 10%
21.5%5 - 15%10 - 30%
33.0%15 - 30%30 - 60%
45.0%30 - 60%60 - 120%
510.0%60 - 150%120 - 300%
upside: 2.0x

Exclusive Zone Bands

Zones are designed with non-overlapping distance bands. This prevents outer zones from adding strikes in inner zones' territory, which would fragment liquidity across too many price points.

WRONG (overlapping):
  Zone 1:  |==========|           Zone 2:  |==================|
           ATM ----> 5%                    ATM -----------> 17%
                                           ^^^^ Zone 2 adds strikes here,
                                                fragmenting Zone 1's grid

RIGHT (exclusive):
  Zone 1:  |==========|           Zone 2:          |==========|
           ATM ----> 5%                           5% -------> 17%
                                  Zone 2 starts where Zone 1 ends

With non-multiple step sizes (e.g., Zone 1 = $10, Zone 2 = $25 for ETH), overlapping zones would create strikes like $2,125 between Zone 1's $2,120 and $2,130. This spreads the same market maker depth across more strikes, resulting in wider spreads and less depth per strike.

Concrete Examples

BTC @ $70,700, Daily

Zone 1 (0.7% step, 0-5% band):
  Step size: $500
  Downside max: $3,535

  Strikes (downside): $70,500  $70,000  $69,500  $69,000  $68,500  $68,000  $67,500
  Strikes (upside):   $71,000  $71,500  $72,000  $72,500  $73,000  $73,500  $74,000  $74,500
                                                                              (1.3x upside = $4,595)

Zone 2 (1.5% step, 5-17% band):
  Step size: $1,000
  Downside max: $12,019

  Strikes (downside): $67,000  $66,000  $65,000  ...  $59,000
  Strikes (upside):   $75,000  $76,000  $77,000  ...  $83,000

Total: ~35 strikes
$500 grid near ATM | $1,000 grid in wings

ETH @ $2,159, Daily

Zone 1 (0.7% step, 0-5% band):
  Step size: $10
  Strikes: $2,050  $2,060  $2,070  ...  $2,160  ...  $2,260  $2,270
           <-- $10 steps, dense near ATM -->

Zone 2 (1.5% step, 5-17% band):
  Step size: $25
  Strikes: $2,025  $2,000  $1,975  ...  $1,800
           $2,300  $2,325  $2,350  ...  $2,525
           <-- $25 steps, exclusive to zone 2 region -->

Total: ~25 strikes
$10 grid near ATM | $25 grid in wings
No $25-step strikes (like $2,125) contaminate the $10 grid

BTC @ $70,700, Quarterly

Zone 1: $500 steps   | 0-5%    | ~15 strikes near ATM
Zone 2: $1,000 steps | 5-15%   | ~14 strikes
Zone 3: $2,000 steps | 15-30%  | ~11 strikes
Zone 4: $5,000 steps | 30-60%  | ~8 strikes
Zone 5: $5,000 steps | 60-150% | ~28 strikes (2x upside = large range)

Total: ~76 strikes
Coverage from ~$28,000 to ~$177,000+

Output Characteristics

ExpiryTypical Strike CountRange
Daily25 - 50~35% of spot
Weekly35 - 60~60% of spot
Monthly45 - 70~120% of spot
Quarterly50 - 76~300% of spot

On-Chain Contract Rules

Series Registration

Series are created on-chain via createSeries(seriesId, params). This function is restricted to admin wallets.

Validation rules:

  1. seriesId must equal keccak256(abi.encodePacked(pairId, strike, optionExpiry, isCall))
  2. strike must be greater than zero
  3. optionExpiry must be at least 1 hour in the future (>= block.timestamp + minTimeToExpiry)
  4. Series must not already exist (reverts with SeriesAlreadyExists)
  5. Minimum premium validation for the pair's USDT expressibility

Series Data Structure

FieldTypeDescription
pairIdbytes32Trading pair identifier
strikeuint256Strike price (WAD, 18 decimals)
expiryuint256Option expiry timestamp
isCallboolCall (true) or put (false)
isSettledboolWhether settlement has occurred
settlementPriceuint256Final settlement price (WAD)

Emitted Events

When a series is created:

event SeriesCreated(
    bytes32 indexed seriesId,
    bytes32 indexed pairId,
    bool indexed isCall,
    uint256 strike,
    uint256 expiry
);

Permission Model

Owner (deployer)
  |
  +-- can add/remove Admins (2-step transfer)
  |
  v
Admins (protocol operator)
  |
  +-- createSeries() -- register new option series
  +-- forceSettle()   -- fallback settlement if oracle unavailable
  +-- setOperators()  -- grant operator role to trading contracts
  |
  v
Operators (OrderBook, RFQ contracts)
  |
  +-- updateOpenInterest() -- track position changes
  +-- getSeries()           -- validate series during trades
  |
  v
Users (traders)
  |
  +-- Cannot create series
  +-- Trade on any existing active series via OrderBook or RFQ

Only admin wallets can create series. Users interact with series exclusively through the trading contracts (OrderBook and RFQ), which validate series existence and tradability on every operation.


Settlement

After a series expires at 08:00 UTC, it enters the settlement process:

Expiry (08:00 UTC)
    |
    +-- Price oracle collects snapshots during the hour before expiry
    |     (requires 12+ snapshots over the 1-hour window for TWAP)
    |
    +-- Anyone calls settle(seriesId)   <-- permissionless
    |     |
    |     +-- Reads Time-Weighted Average Price (TWAP) from oracle
    |     +-- Records settlementPrice on-chain
    |     +-- Marks isSettled = true
    |     +-- Series is now locked (no new trades)
    |
    +-- FALLBACK: Admin calls forceSettle(seriesId, price)
          (used if TWAP is unavailable due to oracle downtime)

The TWAP-based settlement ensures the final price reflects sustained market conditions rather than a single spot observation. The 12-snapshot minimum prevents manipulation through sparse oracle submissions.

For more on the settlement process, see Options Settlement.


Frontend Display

The trading interface presents all active series in an options chain layout:

Options Chain (T-Matrix)

+------ CALLS ------+--- STRIKE ---+------ PUTS -------+
| Bid  Ask  IV  OI  |   $67,000    | Bid  Ask  IV  OI  |  <- OTM put
| Bid  Ask  IV  OI  |   $68,000    | Bid  Ask  IV  OI  |
| Bid  Ask  IV  OI  |   $69,000    | Bid  Ask  IV  OI  |
| Bid  Ask  IV  OI  |  [$70,500]   | Bid  Ask  IV  OI  |  <- ATM (highlighted)
| Bid  Ask  IV  OI  |   $71,000    | Bid  Ask  IV  OI  |
| Bid  Ask  IV  OI  |   $72,000    | Bid  Ask  IV  OI  |
| Bid  Ask  IV  OI  |   $73,000    | Bid  Ask  IV  OI  |  <- OTM call
+--------------------+-------------+--------------------+

Data Flow

The chain loads data in parallel for fast rendering:

  1. Pairs -- available trading pairs
  2. Expiries -- active expiry dates for the selected pair
  3. Markets -- series filtered to canonical strikes only
  4. Tickers -- best bid/ask, last price, 24h volume per series
  5. Oracle -- current spot price for ATM determination
  6. Volume -- aggregate volume analytics (optional)

This data is then transformed into the T-matrix:

  • Grouped by expiry timestamp
  • Sorted by strike (ascending)
  • Call and put sides paired per strike row
  • ITM/OTM classification based on spot price
  • ATM strike highlighted

Expiry Section Headers

Each collapsible expiry section displays:

  • Expiry date (e.g., "28 Mar 2026")
  • Days to expiry (DTE)
  • Strike count
  • Aggregate open interest (calls and puts)
  • Total volume

The nearest expiry is pre-loaded. Other expiries load on demand when expanded.


See Also

On this page