Skip to content

Strategy script language

A strategy is a file. Each asset group in config.json names its primary strategy in strategy.name ("strategy": "jacobs_ladder" in the legacy flat layout); the engine loads strategies/<name>.strategy, validates it, and compiles it to its internal step/action form at startup. See configuration for the group that binds a strategy to a symbol.

This page is the language reference. If you want to wire a strategy into a running engine, covering where to put the file, how to validate, and how to restart, see Authoring a strategy.

New to the language? Read Your first strategy first. It builds a small overlay in ten minutes. The Action glossary lists the action names the engine actually handles and the older names it does not.

The .strategy script is a small, dotted language. It compiles to the same internal representation the engine has always run, so authoring a strategy no longer means writing OCaml. The JSON form (strategies/<name>.json) is the compiled artifact emitted by dio strategy compile.


1. Mental model

A strategy is a sequence of steps. Each step does one check and, if the check passes, runs one or more actions. Steps run in file order, top to bottom, once per cycle.

        every trigger (book tick, fill, order event)
                 │
                 ▼
   step  ── if <condition>? ── pass? ──▶ a list of actions
   step  ── if <condition>? ── pass? ──▶ a list of actions
   step  ...
  • Conditions are plain boolean expressions over engine-provided facts.
  • Actions are the engine's built-in trading verbs (order placement, cancellation, reservations, grid phases). You call them by name. New verbs are added by engineers in the action registry.
  • A step can stop the cycle (stop), in which case everything after it is skipped for this trigger. This is how the engine models "early return".

You never write low-level plumbing: the engine already did that. You pick from a fixed vocabulary of triggers, facts, and actions, and combine them with and / or / not.


2. File layout

Blocks are written with a label, a colon, then an indented body, and no braces.

strategy <name>
version 1

remembers:            -- optional: your persistent per-symbol working variables
  ...

tunable:              -- optional: typed tuning knobs (defaults in the file)
  ...

when <trigger>:       -- grouped steps
  <step label>:
    ...

A minimal complete strategy. This one is an overlay; it keeps a spot buy below the market. A primary strategy starts from the shipped grid instead, because it has to own the whole buy/sell cycle.

strategy hello_overlay
version 1

when book.updates:
  context:
    market.facts
  keep.bid:
    if $platform.spot.bid > 0.0:
      order.replace(market: "spot", side: "buy", qty: 0.0002,
                    price: $platform.spot.bid * 0.90,
                    tif: "ALO", dedup_key: "hello:buy")

Naming rule (the whole trick). Names are written with dots between words. Dots map to underscores, so keep.bid is the step id keep_bid, cancel.excess.buys is the action cancel_excess_buys, and book.updates is the trigger book_update. Names are never split on whitespace: cycle.ok is one token, which is why indentation and spacing can never change meaning.

Names that belong to the engine are references: $platform.*, $event.*, $params.*, $local.*, $signal.*, $price, $now. Dots past the namespace also map to underscores, so $platform.price.nan and $platform.price_nan mean the same fact. Names that belong to you (remembered variables and let bindings) are written bare: cycle.ok lowers to $state.cycle_ok.

Comments run to end of line: -- like this, // like this, or # like this.


3. remembers: your working variables

Declare per-symbol mutable variables the strategy tracks across cycles. Types:

type meaning
bool a flag
int a counter
float a number
float? a number that may be absent (e.g. "the last time X happened")
string a short label
buy_intent? a tracked buy order (token + price)
sell_intent? a tracked sell order (token + price)
reserve_policy a protected-profit policy value
remembers:
  phase        : int
  last.signal  : float?
  trailing     : bool
  tracked.buy  : buy_intent? persist

Add the word persist to keep the variable across restarts. A variable is read by its bare name (phase, last.signal) and written by an assignment (§6). Under the hood these are $state.<name> with underscores.


4. tunable: tuning knobs

Typed parameters declared in the strategy file. Defaults are embedded in the file; changing them means editing the file and restarting the engine. Primary strategies have no per-instance override key in config.json. The built-in carry overlay is the exception: its overlay entry supplies a fixed set of params keys. A custom overlay should use its own tunable defaults and leave params out.

The available kinds are:

kind usage refs
float a number $params.p
int a whole number $params.p
bool a flag $params.p
string a label $params.p
decimal_str a quantity kept as text for the venue (e.g. "0.00025") $params.p.f (float), $params.p.dec (original string)
range a [lo, hi] pair $params.p.lo, $params.p.hi
enum one of a fixed list $params.p
tunable:
  qty           : decimal_str = "0.00025"
  grid.interval : range       = [0.16, 0.16]
  sell.mult     : range       = [0.98, 0.98]
  mode          : enum ["spot", "ladder"] = "spot"

Parameters are always addressed through $params.* (the engine appends .f, .dec, .lo, .hi where relevant, which map to the underscores it expects). The tunable: block is for declaration and validation.


5. when ...: triggers

A when <phrase>: group wraps steps that should run on a particular event. The trigger guard is injected into every step in the group, so you do not write it yourself. Recognised phrases:

phrase fires on event fields on $event
when book.updates: a new top-of-book snapshot None
when order.fills: (or when fill.happens:) an order filled price, qty, side, order_id, market, is.perp, is.buy
when any.order.event: every order-status kind (a/c/rej/amend/...) order_id, status, result
when order.is.<kind>: one status kind: filled, cancelled, acknowledged, amended, failed, rejected, amendment.skipped, amendment.failed, cancel.cleanup order_id, status, result
when balance.updates: a new balance snapshot asset, total, available, age
when oracle.publishes: capital oracle published signals

On a fill, is.perp and is.buy are 1.0 or 0.0; compare them with > 0.5. market is "perp" or "spot". A perp buy on a short is a close.

Most strategies want one orchestration group:

when book.updates:
  prepare:
    ...
  buy.leg:
    ...
  sell.leg:
    ...

For "the same handler on every lifecycle kind", use the any-event group:

when any.order.event:
  ev:
    apply.order.event
    stop

The label ev expands to one step per kind, such as ev_filled and ev_cancelled, each requiring its own event, identical to writing them separately.


6. Steps

A step is a label (a name, then :). Its body is one of:

  • a list of actions (always run when the trigger fires), with an optional trailing stop; or
  • a single if <condition>: block, with an optional otherwise: block.
  prepare:
    reset.venue
    read.book
    cycle.ok = $platform.price.nan == false

  skip.nan.price:
    if $platform.price.nan:
      stop

  cancel.if.too.many:
    if cycle.ok and buy.active and buy.count.positive:
      cancel.excess.buys
    otherwise:
      cycle.facts

Semantics (identical to the engine's):

  1. Steps run in file order.
  2. A step sees everything earlier steps mutated this cycle, so state is live.
  3. All matching steps run unless a stop ends the cycle.
  4. Conditions are side-effect-free. Only actions change anything.

Gate assignments

A line name = <condition> inside a step writes a remembered bool variable. This is how you publish a decision once and branch on it later:

    cycle.ok = not $platform.price.nan and
               not ($platform.check.stale.balance and
                    ($platform.asset.balance.nan or $platform.quote.balance.nan))

Later steps read it as cycle.ok. Under the hood this is the set_gate action.

let bindings

let name = <expression> computes a value visible below it in the same step. Read it back with its bare name (it lowers to $local.name).


7. Conditions

7.1 Operators

Arithmetic: + - * / Comparison: == != < <= > >= Boolean: and or not (you may also write ! for not) Literals: numbers, true, false, "text".

7.2 References: the engine's number/flag vocabulary

Your own variables are bare; everything from the engine is a $ reference:

syntax meaning
$price current top-of-book reference price
$now current time in seconds
$event.<field> field of the triggering event (table in §5)
$state.<name> a remembered variable (cycle.ok also works)
$params.<p>... a parameter (§4)
$local.<name> a let binding (name also works)
$signal.<name> an oracle signal
$platform.<fact> an engine-provided fact (§7.3)

7.3 Platform facts

These are computed by the engine each cycle; you only read them. $platform.* (the engine stores these with underscores; dots are normalised for you):

fact meaning
price.nan / asset.balance.nan / quote.balance.nan a needed input is missing this cycle
check.stale.balance venue requires fresh balances
maker.fee.set / fee.refresh.due maker fee known / refresh is due (every 1024 cycles)
oracle.halted capital oracle published INACTIVE
tif.recovery.pending / tif.recovery.since TIF-recovery latch + its timestamp
has.pending.buy a buy request is in flight
has.tracked.buy / inflight.cancel.buy / inflight.amend.buy resting-buy bookkeeping flags
open.buy.count / has.recent.amend.buy / amend.has.sell buy-leg posture
buy.price / buy.qty / buy.quote.needed / buy.available fresh-buy plan (from buy.place.plan)
buy.balance.ok quote balance covers the buy
buy.capital.low quote cannot fund a buy (latched until it recovers)
buy.crossing a resting/evicted sell would cross the buy (wash protection)
buy.quote.nan / buy.cooldown / buy.inflight fresh-buy branch blockers
quote.balance.stale balance snapshot is stale
remaintain.expired.sells venue re-places expired rungs (Alpaca-style)
sell.missing.empty every persisted sell level is back on the book
just.filled.buy / resuming.after.balance / buy.attempted / sell.pushed cycle flags
has.active.sell / sell.place.should sell-leg posture
balance.fresh base balance inside the sweep freshness window
bid / ask / lot.qty perp top of book / venue lot (after market.facts)
spot.bid / spot.ask spot-pair top of book
symbol / base.coin / now spot pair, base coin, cycle timestamp
mark / oracle perp mark and oracle price
funding.apr / premium / funding.valid annualized hourly funding, perp premium, feed freshness
perp.valid / perp.qty / perp.entry position snapshot freshness, signed quantity, average entry
perp.margin / perp.leverage / perp.liq position margin used, leverage, liquidation price
perp.account.value / perp.maintenance / margin.ratio account equity, maintenance margin, equity/maintenance
min.liq.reserve free USDC this entry needs to keep its short solvent at min_liq_mult × mark
carry.reserved venue-wide total of every overlay's quote reservation (margin plus pending spot buys)
accumulated.balance / spot.base.total / usdc.free read-only accumulated target, spot base total, tradeable USDC

7.4 Compound guards that are not plain expressions

form means
is.none(<ref>) the reference holds no value (e.g. is.none(tracked.buy))
is.some(<ref>) the reference holds a value
pending("<key>") an order with this dedup key is outstanding
engine(<flag>: <bool>) an engine/account gate, e.g. engine(capital.halted: true)
capacity(<name>: <expr>) a platform-answered capacity check, e.g. capacity(quote.gte: $price * q)
cooldown.elapsed(since: <ref>, seconds: <expr>) enough time has passed since a timestamp
order(<field>: <value>) open-order posture (via platform)
signal(<name>: <pred>) oracle/external signal value

These combine freely with and / or / not:

    if is.some(tracked.buy) and
       not pending("buy:initial") and
       cooldown.elapsed(since: last.amend.at, seconds: 30):
      ...

8. Actions

Actions are named with dots; underscores are inserted for you (cancel.excess.buys → cancel_excess_buys, order.place → order_place). Arguments are always named (name: value); positional arguments are rejected. The engine validates argument names against the action's registered schema at load time.

Argument values are either literals (numbers, true/false, "text") or expressions (anything with operators or references). A let binding names an intermediate value in the current step:

    let buy.px = $price * (1.0 - $params.offset)
    order.replace(market: "spot", side: "buy", qty: $params.qty,
                  price: $local.buy.px, tif: "ALO",
                  dedup_key: "buy:initial")

Some actions return an output field, which you can capture with bind(<var>: <outfield>). The wired order actions do not return a value you need, so a simple strategy can skip bind. The older compute.* actions that used it are reserved; see the Action glossary.

Effectful actions (place/amend/cancel) require a dedup_key so the engine can refuse duplicates:

    order.place(market: "perp", side: "sell", qty: $event.qty,
                price: $local.sell.px, tif: "ALO",
                dedup_key: "sell:$event.order_id")

Template strings interpolate $ references inline, with no concatenation operator is needed.

8.1 Overlay actions

An overlay is a second strategy file bound from the overlay section of an asset group (or the overlay object inside a flat trading entry) in config.json. It runs in the same asset domain as the primary strategy. It is cycled by the asset's normal book/event cycle plus perp top-of-book, position, and funding updates; there is no timer-based overlay loop. These actions are generic; the overlay file owns the policy:

action meaning
market.facts Refresh the market/perpetual facts in §7.3. Call it before an overlay step reads them.
order.place(market: "perp"\|"spot", symbol?, side, qty, price?, tif?, reduce_only?, post_only?, dedup_key) Place a generic order. tif is IOC, GTC, or ALO; price omitted means market. reduce_only is passed to the venue. symbol optionally overrides the entry's instrument.
order.cancel(order_id, market?, dedup_key) Cancel a generic overlay order.
order.replace(market, symbol?, side, qty, price, tif?, reduce_only?, post_only?, dedup_key) Place or amend the overlay's resting order at the requested price/size. The engine consults the venue's open orders, so no order id is needed. A qty of 0 cancels the matching resting order.
quote.reserve(amount) / quote.release Hold quote as a senior claim; the primary grid's available quote is reduced by the total reservation.
reserve.base(qty) Move base into the reserved ledger so the primary grid cannot sell it. A negative amount releases; the ledger clamps at zero.

These are the order verbs wired for custom logic. Use them from an overlay. The primary grid has its own fill routing; an order tagged by these actions is an overlay order, so the grid will not track its fills. The older place.buy, place.sell, amend.buy, cancel.all, and cancel.order names are registered but not handled by the current runtime. Use the order.* actions above. The Action glossary has the full list, including the other reserved names.

DIO_OVERLAY_DRY_RUN=1 logs intended overlay orders and reservations without sending or mutating them.

8.2 A short position, start to finish

A perpetual short is one position per hedge coin. Open it with a perp sell and close it with a reduce-only perp buy. The spot base is a separate leg; selling spot does not close the short, and covering the short does not sell the base.

    -- open or add; the bid is what a seller can hit
    if $platform.perp.qty >= -0.0001 and
       $platform.usdc.free >= $platform.min.liq.reserve:
      order.replace(market: "perp", side: "sell", qty: $params.qty,
                    price: $platform.bid, tif: "GTC",
                    reduce_only: false, dedup_key: "hedge:entry")

    -- cover: keep the reduce-only buy resting at the exit
    if $platform.perp.qty < -0.0001 and $platform.perp.entry > 0.0:
      order.replace(market: "perp", side: "buy", qty: 0.0 - $platform.perp.qty,
                    price: $platform.perp.entry * 0.995, tif: "GTC",
                    reduce_only: true, dedup_key: "hedge:exit")

reduce_only belongs on the buy. The venue rejects a size that would take the position past flat, so a stale quantity cannot flip the short long. order.replace keeps one order per dedup_key: it amends when price or size moves and cancels at qty: 0. order.place is the one-shot version, and defaults to IOC; order.replace defaults to ALO.

Read the position from $platform.perp.*. perp.qty is signed (negative is short), perp.entry is the average entry, and perp.margin, perp.leverage, perp.liq and margin.ratio come from the venue snapshot. A fill event carries is.perp and is.buy, so a perp buy fill is the close; re-hedge in that event, not on the next book tick, if the strategy must stay covered.

On a unified account, a short is collateralised by USDC, not by the spot base. min.liq.reserve is the free USDC this entry needs to survive to min_liq_mult times the mark. It is sized to the max_ratio_pct short, not the position that happens to be open, so the next add does not raise the requirement. quote.reserve publishes it as a senior claim; the primary grid cannot spend it. carry.reserved is the venue-wide total across overlays, and is a solvency signal for new exposure, not a gate for restoring an existing hedge.

Funding is an input, not a gate. funding.apr is positive when a short is paid and negative when it pays; the engine publishes the sign and leaves the decision to the strategy.


9. How to construct a strategy: the recipe

  1. Decide the trigger(s). Grids live almost entirely on book.updates; fills and lifecycle events are handled with dedicated groups.
  2. Declare remembers (§3) for anything the strategy remembers across cycles.
  3. Open the cycle. First step usually prepares: resolve.book, early.facts, then computes a cycle.ok gate:
    prepare:
      resolve.book
      early.facts
      cycle.ok = not $platform.price.nan and
                 not ($platform.check.stale.balance and
                      ($platform.asset.balance.nan or $platform.quote.balance.nan))
    
  4. Guard everything with cycle.ok. Every later decision step starts if cycle.ok and .... A cycle with bad inputs must not trade.
  5. Publish branch flags with gate assignments, then branch on them:
        buy.pending = not $platform.has.pending.buy
        buy.count.zero = not ($platform.open.buy.count > 0 or $platform.has.tracked.buy)
    
    Then a branch step reads only flags:
      buy.place.send:
        if cycle.ok and buy.active and buy.pending and
           not buy.should.cancel and buy.count.zero and
           not $platform.buy.capital.low and $platform.buy.balance.ok:
          buy.place.send
    
  6. Handle events early with stop. Fill/lifecycle steps come first and stop so a cycle ends as soon as it has dispatched the event.
  7. Use otherwise for the fall-through of a single decision.
  8. Validate often: dio strategy validate strategies/mine.strategy, which checks every action name/schema, every reference, trigger names, and duplicates. The Action glossary tells you which names the runtime actually handles.
  9. Keep math in actions. Price rounding, lot sizes, min-notional and min-order-size floors live in the engine. If a computation is missing, ask an engineer to register it as a new action (and add it to the glossary), after which every strategy can use it.

10. Worked example: jacobs_ladder.strategy

The ladder strategy the engine ships is a complete, real example: strategies/jacobs_ladder.strategy:

-- Jacobs Ladder grid — the written-out decision procedure.
strategy jacobs.ladder
version 1

remembers:
  cycle.ok: bool
  buy.active: bool
  buy.pending: bool
  buy.should.cancel: bool
  buy.count.zero: bool
  buy.count.positive: bool

-- every order event routes to the shared handler and ends the cycle
when any.order.event:
  ev:
    apply.order.event
    stop

when book.updates:

  prepare:
    init.venue.state
    prepare.recovery
    resolve.book
    early.facts
    cycle.ok = not $platform.price.nan and
               not ($platform.check.stale.balance and
                    ($platform.asset.balance.nan or $platform.quote.balance.nan))

  skip.nan.price:
    if $platform.price.nan:
      stop

  cleanup:
    expire.amend.cooldowns
    evict.ghost.orders

  sync:
    scan.open.orders

  fee:
    if not $platform.maker.fee.set or $platform.fee.refresh.due:
      refresh.maker.fee

  mark.stale:
    if not $platform.price.nan and $platform.check.stale.balance and
       ($platform.asset.balance.nan or $platform.quote.balance.nan):
      mark.stale.cycle

  buy.gate:
    if cycle.ok:
      expire.tif.recovery
      cycle.facts
      buy.active = not ($platform.oracle.halted and
                        not ($platform.tif.recovery.pending and
                             $now - $platform.tif.recovery.since < 900))
      buy.pending = not $platform.has.pending.buy
      buy.should.cancel = $platform.open.buy.count > 1 and
                          not $platform.inflight.cancel.buy and
                          not $platform.inflight.amend.buy and
                          not $platform.has.recent.amend.buy
      buy.count.zero = not ($platform.open.buy.count > 0 or $platform.has.tracked.buy)
      buy.count.positive = $platform.open.buy.count > 0 or $platform.has.tracked.buy

  buy.cancel:
    if cycle.ok and buy.active and buy.pending and buy.should.cancel:
      cancel.excess.buys

  buy.place.plan:
    if cycle.ok and buy.active and buy.pending and
       not buy.should.cancel and buy.count.zero:
      buy.place.plan
  ... (the remaining buy/sell steps follow the same shape)

when book.updates: inserts event == "book_update" into each step's condition, exactly the "event": "book_update" key in the JSON. Each line in a step body is one action in the step's then list; if/otherwise become the step's condition/else; stop ends the cycle. The when any.order.event: group expands to the nine ev_* steps. dio strategy compile emits the JSON form; dio strategy validate and dio strategy replay --candidate are the equivalence gates.


11. Tools

The dio binary ships a built-in strategy editor server plus one-shot CLI commands. For in-editor highlighting and live validation (VS Code install, the dioStrategy.binary setting, troubleshooting), see Editor support (VS Code).

command purpose
dio strategy validate <file> static check of a .strategy or .json file (names, schemas, refs, triggers, duplicates). Exit 0 = ok.
dio strategy compile <file.strategy> [-o out.json] lower script → the engine's representation; optionally emit the JSON render for inspection/diffing.
dio strategy diff <a> <b> compare two recorded traces.
dio strategy replay [--candidate] <trace> replay a recorded trace through the reference or the compiled strategy; equivalence is the release gate.
dio strategy lsp Language Server Protocol server over stdio; drives the VS Code extension's inline validation.

Binding: config.json references a strategy by name in strategy.name ("strategy": "jacobs_ladder" in the legacy flat layout). The loader tries strategies/<name>.strategy, then strategies/<name>.json; a mismatch between the filename and strategy <name> fails at startup.


12. Role split

role writes owns
Trader .strategy files: remembers, tunable, triggers, steps, gates the decision procedure: when to buy/sell/amend/cancel, at what prices
Engineer OCaml action registry + platform accounting the verbs and the invariants: capacity, ghost detection, freshness, reservation, dedup, venue floors
Engine None interpreting, compiling, validating, hot-path guarantees

New capability for traders = an engineer registers one action in the registry. Every existing and future .strategy file can then call it, which is the extension surface stays exactly one place.