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 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, so state is live.
- 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 |
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¶
- 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, which checks every action name/schema, every reference, trigger names, and duplicates. The Action glossary tells you which names the runtime actually handles. - 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.