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 optionalotherwise: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):
- Steps run in file order.
- A step sees everything earlier steps mutated this cycle — live state.
- All matching steps run unless a
stopends the cycle. - 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.buys → cancel_excess_buys, place.buy → place_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.px → buy_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¶
- Decide the trigger(s). Grids live almost entirely on
book.updates; fills and lifecycle events are handled with dedicated groups. - Declare
remembers(§3) for anything the strategy remembers across cycles. - Open the cycle. First step usually prepares:
resolve.book,early.facts, then computes acycle.okgate: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)) - Guard everything with
cycle.ok. Every later decision step startsif cycle.ok and .... A cycle with bad inputs must not trade. - Publish branch flags with gate assignments, then branch on them:
Then a branch step reads only flags:
buy.pending = not $platform.has.pending.buy buy.count.zero = not ($platform.open.buy.count > 0 or $platform.has.tracked.buy)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 - Handle events early with
stop. Fill/lifecycle steps come first andstopso a cycle ends as soon as it has dispatched the event. - Use
otherwisefor the fall-through of a single decision. - Validate often:
dio strategy validate strategies/mine.strategy— it checks every action name/schema, every reference, trigger names, and duplicates. - 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.