Skip to content

Strategy scripts

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

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

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 (place, amend, cancel, compute, gate, ...). You call them by name. New verbs are added by engineers in the action registry — not by you.
  • A step can stop the cycle (stop) — 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 — no braces.

strategy <name>
version 1

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

tunable:              -- optional: typed tuning knobs (instance-overridable in config)
  ...

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

A minimal complete strategy:

strategy hello.grid
version 1

when book.updates:
  keep.low:
    place.buy(qty: $params.qty.dec, price: $price * 0.998, post_only: true)

Naming rule (the whole trick). Names are written with dots between words. Dots map to underscores, so keep.low is the step id keep_low, 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 (there is no per-instance override key in config.json):

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 for you — you do not write it yourself. Recognised phrases:

phrase fires on event fields on $event
when book.updates: a new top-of-book snapshot
when order.fills: (or when fill.happens:) an order filled fill.price, fill.qty, side, fill.order.id, realized
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

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 — ev_filled, 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 — live state.
  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 resolved book / venue lot

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.buyscancel_excess_buys, place.buyplace_buy). 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):

    compute.grid.price(ref: $price, lo: $params.grid.interval.lo,
                       hi: $params.grid.interval.hi, side: "below")
    place.buy(qty: $params.qty.dec, price: $local.buy.px, post_only: true)

Many actions return a value. Capture an output field with bind(<var>: <outfield>) inside the argument list:

    compute.grid.price(ref: $price, lo: $params.grid.interval.lo,
                       hi: $params.grid.interval.hi, side: "below",
                       bind(buy.px: price))
    place.buy(qty: $params.qty.dec, price: $local.buy.px,
              post_only: true, dedup_key: "buy:initial")

The bind clause maps your local name (buy.pxbuy_px) to the action's output field name (price). Once bound, buy.px is visible in later steps.

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

    place.sell(qty: $event.fill.qty, price: $local.sell.px,
               dedup_key: "sell:$event.fill.order.id")

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


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 — it checks every action name/schema, every reference, trigger names, and duplicates.
  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 — then 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

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.

Binding: config.json references a strategy by name ("strategy": "jacobs_ladder"). 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 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 — the extension surface stays exactly one place.