Trade director

The trade director is a pre-trade gate. The engine consults it for every order a strategy sends and for every order the engine itself generates for a strategy (a continuous roll’s legs), before the order reaches the simulator or the broker. You configure it with an ordered list of rules: caps on value-at-risk, capital deployed, directional and group exposure, open positions, margin, drawdown and liquidity, a volatility overlay, a top-N selection by order conviction, and operational controls such as a reducing state, a restricted list, a stale-data gate, an order-size check and an entry-rate limit. The same decision code runs in a backtest and in a live run; only the sources of its inputs differ.

Use it when the strategy should not be the only thing standing between a signal and the account:

  • to hold a portfolio to limits the strategy does not know about (at most six holdings, at most two metals, margin under 15% of equity);
  • to stop new entries after a drawdown, for the rest of a month or until you say so;
  • to rank the entries of one decision instant and take only the strongest;
  • to catch an order that is plainly wrong (a quantity or notional far beyond anything the strategy should send, a limit price far from the market);
  • to refuse entries when the data the decision rests on is missing or old.

The director only takes risk away. It refuses an order, or the part of an order that opens risk, and where a rule allows it, it trims that part to what fits. It does not enlarge an order, change a price, invent an order or a fill, or reallocate capital, and the part of an order that reduces a position passes every rule. A refusal reaches the strategy as an ordinary Denied update whose reason begins with a director_* token; a trim reaches it as the order’s normal acceptance update, with the accepted quantity and a reason that begins director_trimmed. Every decision is written to a decision log, one JSON record per line.

A run without a director is unchanged byte for byte: no file is written, no header field is added and no strategy’s handshake changes. The design, with its rationale and the decisions behind it, is docs/trade-director-spec.md; this chapter describes the director as built.

Director quick start

This example takes the SMA-crossover demo, smacross, which goes long one contract when its fast average crosses above its slow one and short one contract on the cross below, and makes it long-only with at most one new entry in any 30 days. It needs the adapters installed and a marketfeed archive that holds ES, as in Your first command.

Save the director as director.json:

{
  "director": {
    "version": 1,
    "rules": [
      {"rule": "max-directional-exposure", "symbol": "fut:XCME:ES", "max-short-quantity": 0},
      {"rule": "entry-rate", "max": 1, "window": "720h"}
    ]
  }
}

version is always 1. The first rule caps the short quantity of the ES holding at zero; the second allows at most one opening in any rolling 720 hours. Run the strategy with it:

bin/algo run --strategy bin/smacross --symbol fut:XCME:ES --interval 1d \
  --start 2025-01-01T00:00:00Z --end 2025-07-01T00:00:00Z \
  --director director.json --results-dir results

A backtest runs order lifetime v2 by default, which the director needs, and a director needs somewhere to write its decision log, here the run’s results directory.

What the operator sees. Each refusal is a line on standard error, beside the strategy’s own log lines (the start of the run, without the strategy’s fill lines):

[smacross] INFO: smacross: fast SMA(3) crossed above slow SMA(8); buying 1 fut:XCME:ES
[smacross] INFO: smacross: fast SMA(3) crossed below slow SMA(8); selling 2 fut:XCME:ES
[smacross] INFO: smacross: fast SMA(3) crossed above slow SMA(8); buying 1 fut:XCME:ES
algo: order "smacross_1737064800000000000" (fut:XCME:ES) from instance fut:XCME:ES denied: director_entry_rate: rule=2 requested=1 count=1 max=1 window=720h0m0s
[smacross] INFO: smacross: fast SMA(3) crossed below slow SMA(8); selling 1 fut:XCME:ES
algo: order "smacross_1738188000000000000" (fut:XCME:ES) from instance fut:XCME:ES denied: director_directional_limit: rule=1 requested=1 dimension=short-quantity measure=1 max=0

The first buy opened a long of one. The cross below sent a sell of two: one to close the long, one to open a short. The director let the close through and trimmed the short away, so the strategy went flat. The next buy, nine days after the first entry, found the entry-rate budget spent; the sell after it, from flat, would have opened a short.

What the strategy sees. The refused buy’s update is Denied, with the reason the operator line shows: director_entry_rate: rule=2 requested=1 count=1 max=1 window=720h0m0s. The trimmed sell is not denied. Its first update is the ordinary acceptance, with Quantity 1 instead of 2 and this reason:

director_trimmed: requested=2 accepted=1 reducing=1 opening_requested=1 opening_accepted=0 cause=director_directional_limit rule=1

The decision log. results/run-fut-xcme-es-1d/director-decisions.jsonl holds a snapshot record for each decision instant, a decision record for each order and a closing footer. This is the trimmed sell’s decision, pretty-printed here (each record is one line in the file):

{
  "schema": "algolang.director/1", "kind": "decision", "sequence": 4,
  "event_time": "2025-01-09T14:30:00Z", "instant_sequence": 2, "phase": "admission",
  "run_id": "run-fut-xcme-es-1d", "owner_id": "fut:XCME:ES", "snapshot": 3,
  "candidate_sequence": 0, "client_id": "smacross_1736433000000000000", "attempt": 1,
  "origin": "strategy", "stage": "director", "symbol": "fut:XCME:ES", "side": "sell",
  "physical": "ESH25", "base_currency": "USD",
  "requested_quantity": 2, "reducing_quantity": 1, "accepted_quantity": 1,
  "reason_code": "director_trimmed", "cause_code": "director_directional_limit", "rule": 1,
  "reason": "director_trimmed: requested=2 accepted=1 reducing=1 opening_requested=1 opening_accepted=0 cause=director_directional_limit rule=1",
  "rule_results": [
    {"ordinal": 1, "rule": "max-directional-exposure", "status": "refuse",
     "requested": 1, "accepted": 0, "code": "director_directional_limit",
     "detail": "rule=1 requested=1 dimension=short-quantity measure=1 max=0",
     "dimensions": [{"name": "short-quantity", "before": 0, "requested_after": 1, "limit": 0}]},
    {"ordinal": 2, "rule": "entry-rate", "status": "not_evaluated"}
  ],
  "config_hash": "80cf3e43bbf85bb57c517182e25050493c4f8c744b6b6779092f44ea0ff6e768"
}

The order asked for 2, of which 1 reduces the long; rule 1 judged the opening part of 1, refused it (a short quantity of 1 against a cap of 0), and rule 2 did not judge it, because nothing was left to judge. The physical key names the contract the continuous series was trading, ESH25. The snapshot record the decision points to ("snapshot": 3) holds the book it was judged against: a long of one ESH25 contract, marked at 5,944.75. The decision log describes every record.

The report. The run’s text report ends with a director: block, and algo report results prints it again later:

  director:    config 80cf3e43bbf8 | log director-decisions.jsonl (33 records, sha256 3dfca806c09c)
               candidates 17: 7 accepted, 5 trimmed, 5 refused | rechecks 0 | refused after acceptance 0
               refused by: director 5
               reasons: director_accepted 7, director_directional_limit 2, director_entry_rate 3, director_trimmed 5
               binding rules: 1 max-directional-exposure 7, 2 entry-rate 3

Seventeen orders reached the director over the half year: smacross sent fifteen, and the other two were the close and the reopen of a continuous roll, which the director decides too. Seven passed untouched (the two roll legs among them), five sells were trimmed to their closing part, and five orders were refused. result.json holds the same summary as data (Reports).

Director concepts

A few ideas run through every rule: which part of an order the director judges, when it decides, in what order the rules run, and what it counts.

Opening and reducing parts

The director judges only the part of an order that opens risk. The part that reduces a position passes every rule, so a close, a protective stop and the closing half of a reversal are not stopped by a cap, a drawdown stop, a reducing state or a missing input. A cancellation does not reach the director at all.

An order is split by the position it meets. A sell against a long of 3 for 5 reduces 3 and opens a short of 2; a buy against the same long opens all of it. The other working orders on the same side come first: a sell’s reducing part is what the position could still sell down to flat after every other working sell filled, and a buy’s likewise. So a second exit against a position that a protective stop already covers has no reducing part left; it is a close conflict (Closes that compete).

A holding is one strategy instance’s position in one logical symbol (a continuous series counts as one holding however many contracts it rolls through). A holding is potentially open when any order in which its pending orders could fill would leave it non-zero, counting the position, every working, held and queued order, and the orders already accepted at the same instant. Pyramiding into an open holding does not take another slot. A count already above its cap stops only an increase.

Decision instants and batches

The director decides at a decision instant: one strategy response (every order returned from one OnBar, timer or lifecycle turn), one cohort of held orders the race guard releases together, or one batch the engine generates (a roll’s legs). The orders of one instant are decided as a batch:

  1. Each order is validated. A conviction out of range or of the wrong sign gets director_invalid_conviction. A quantity below 1 gets director_invalid_order, and so does a bracket child the director cannot model (Composite orders).
  2. With top-n-conviction configured, the openings are ranked by absolute conviction. Ties go to the logical symbol, then the label, the attempt and the response order. An absent conviction ranks with zero.
  3. Each order is decided in turn: in response order, or, with top-n-conviction, the reductions first and then the openings in rank order. Each later order sees the capacity the earlier ones took. An order that the race guard or the admission limits refuse takes nothing.

So two orders that each fit alone can compete: with max-open-positions 1, the first of two new entries in one response takes the slot and the second is refused (E7). A refused order is not kept for a later instant; the strategy decides whether to send it again.

The phase order

Every rule belongs to one of four phases, and the configuration must list its rules in phase order: gates, then multipliers, then constraints, then the selection. Within a phase the order is yours, and it decides which rule a refusal names when several would refuse.

PhaseRulesWhat they do
gatereduce-only-state, restricted-instruments, stale-dataRefuse an opening outright, before anything is measured.
multiplierdrawdown-ladder, volatility-multiplierScale every opening down by one multiplier.
constraintmax-order-size, entry-rate, max-directional-exposure, max-open-positions, max-group-positions, max-deployed-value, max-deployed-percent, max-margin, max-peak-drawdown, max-period-drawdown, max-var, liquidity-capCompare a measure of the book with the opening against a cap.
selectiontop-n-convictionTake the best-ranked openings of the instant, at most once per configuration.

Each rule receives the opening the rules before it left, so the accepted quantity is the smallest any trimming rule allows. A rule may appear more than once (two max-period-drawdown rules, one monthly and one quarterly); each instance has its own ordinal, its position in the rules array counted from 1, which every reason and log record names as rule=N.

Reject, trim and scale

A rule’s action says what it does with an opening that breaks it:

  • reject, the default of every rule that is not a multiplier: the opening part is refused whole.
  • trim, for the rules that allow it (max-directional-exposure, max-deployed-value, max-deployed-percent, liquidity-cap): the opening is cut to the largest quantity that fits.
  • scale, the only action of the two multipliers, which is also their default.

An order whose opening part is refused or cut, but which has something left, is not denied. It is submitted with the accepted quantity and its original type and prices, and its first update carries that quantity, with a reason that leads with the trim. Only an order with nothing left is Denied. So the quick start’s reversal, its opening refused, still closed the long.

Minimum size. A trimmed opening must keep at least min-quantity (default 1), rounded down to quantity-step, and, with a min-notional above 0, a notional (its quantity times one unit’s reserve value, below) of at least min-notional; otherwise it is refused with director_below_minimum, citing the rule that trimmed it. An opening the rules did not trim is not held to either minimum. From the trim test of the design’s E6, where min-quantity is 2 and only one unit fits:

director_below_minimum: rule=1 requested=3 permitted=1 min-quantity=2 limit=director_deployed_value

Missing inputs fail closed

A rule that needs an input it does not have refuses the opening before it compares anything: director_input_missing when the reading is absent, director_input_stale when the engine withheld it as older than its freshness limit, and director_warming_up when a history is not yet long enough. There is no warm-up exception and no default: a missing mark is not taken as zero, a missing margin is not taken as free. The reason names what was missing, for example director_input_missing: rule=1 requested=3 inputs=mark:ES,point-value:ES, and Troubleshooting lists every token. A reduction passes whatever is missing.

Only the inputs a configured rule reads are required. A configuration of counts alone (max-open-positions, max-group-positions, top-n-conviction, the directional counts) reads no prices, no equity and no history, and its snapshots carry no instrument readings. A rule whose input no source can supply yet is refused when the run starts, rather than refusing every opening for a missing input (Live runs).

The comparison tolerance

A cap is breached only when the measure is above it: equality passes. The drawdown stops and the ladder’s bands are the exception, reached when the loss equals or passes them.

Every money and fraction comparison the director makes (a measure against its cap or against the book before the order, a trimmed opening’s notional against min-notional, a drawdown against its stop or a ladder band, and the ladder’s scaled quantity) uses one convention: two amounts within a relative 1e-9 of each other, scaled by the larger magnitude or 1, are equal, so amounts equal on paper compare equal. A fraction bound is compared as its product with the reference, without dividing: an opening of exactly 29 passes a max-fraction of 0.29 of an equity of 100, although 0.29 × 100 is 28.999999999999996 in binary, and a loss of 6,001.14 from a mark of 100,019 reaches a max-fraction of 0.06, although the quotient is 0.05999999999999999. A measure or bound too large to represent refuses the opening with director_input_missing, naming the measure: overflow:deployed, overflow:margin, overflow:bound, overflow:notional, overflow:var, overflow:risk, overflow:volatility or overflow:liquidity.

What counts against a cap

Every cap is measured on the book after the opening, and the book holds more than positions:

  • the position;
  • every working order, every order the race guard holds and every order a live transport has queued, each at the quantity it could still fill;
  • the orders accepted earlier at the same instant.

Pending orders count in full as if they filled, on the side that hurts the measure: a working buy limit far below the market reserves its capital, its margin and its holding slot just as a filled position does. A pending reduction gets no advance credit (the position stays charged until the reduction fills), and a cancel request gives none either: the order counts until the cancellation is confirmed. In a backtest the simulator confirms a cancellation at once; a broker answers later (E7).

Capital and margin. The deployed notional D sums, over every holding’s physical instruments, the worst absolute quantity any fill order could reach (the position and every working, held and queued order, and the orders accepted earlier at the same instant) times the reserve price, the point value and the rate into the base currency. The reserve price is the largest of the last mark and the instrument’s working limit and stop prices, an order’s own included, times 1 + price-buffer (default 0.02), and at least one tick. So a buy limit above the market is charged at its limit. The reserved margin sums the same worst quantities times each contract’s initial margin, converted from its margin currency, so working orders are charged as if they filled. On a backtest the simulator supplies the marks, point values, margins and marked equity; the margin rule reads the venue’s margins under every --margin-model, off included, and does not change what the simulator does with them. The marked equity is read only when a rule’s bound is a fraction of it (max-deployed-percent, or max-margin with a max-fraction), a rule follows it (the three drawdown rules), or a stale-data rule checks its age (the account input).

Continuous series. On a continuous series, an order on the logical symbol, working or new, is counted on the series’ active contract, where it executes, so it nets with the position held there: a protective sell stop of 2 against a long of 2 is 2 contracts of capital and margin, not 4. After a roll it counts on the new contract. Its limit and stop prices are in the series’ adjusted frame, so for the reserve price they are translated into the raw frame of the contract it counts on, by that contract’s own adjustment, as the order itself is translated when it is sent; the order keeps its prices. At a roll’s cut the book counts the series’ orders on the outgoing contract in its frame, and the reopen’s book on the incoming contract in its. An order that names a dated contract prices in its raw frame already and is not translated.

Held and queued orders

An order the race guard holds (behind a pending cancellation, say), or a live transport queues, is decided again just before it is sent: the held orders the guard releases at one decision as one cohort, each queued submission at its dequeue. The rest of the book is taken as it is then, without the rechecked orders’ own reservations, and each is decided from the quantity last reported to the strategy. A recheck can keep, lower or refuse that quantity, but not raise it, and takes no second ranking slot, no second entry-rate slot and no second daily participation charge: the recheck of an order already charged faces a liquidity-cap rule’s position and open-interest bounds alone, and not volatility-multiplier, whose budget it shared when it was charged. A recheck that is an order’s first opening decision (a held or queued reduction whose position changed while it waited) faces both. A lowered one is sent with an update whose reason leads with director_trimmed; a refused one ends with Denied and the director’s reason. E9 shows a held entry refused at its release because equity fell while it waited.

Closes that compete

Two closes cannot spend the same position: a sell whose reducing part is smaller than the long position offers, because another working sell already reserves it, is a close conflict. If the same response cancels every close it conflicts with, it is held, with no hold timeout, until those cancellations are answered: confirmed, it is decided against the book without them; refused or unknown, it ends Denied with director_close_conflict. Without such a cancel it is Denied at once with director_close_conflict, naming the conflicting orders, and the existing close keeps protecting the position:

director_close_conflict: the close would share the reducing budget of stop/1; cancel it in the same response

To replace a protective stop with an exit, cancel the stop and send the exit in one response (E2).

Composite orders

In a backtest:

  • A bracket’s children, on the side opposite their parent, are contingent protection: they reserve nothing until the parent fills, and then at most the quantity the parent has filled, as one alternative. A trimmed parent lowers its children with it. A child that is nested, on the parent’s side, on another symbol or larger than its parent is refused with director_invalid_order (E12).
  • A combo reduces in whole spread units: the most for which every leg opposes its holding and stays within it. The rest of its units open risk on every leg’s holding, and a trim keeps the legs’ ratios.
  • Orders sharing an OCO group are exclusive alternatives, sharing one reducing budget, because the simulator settles the group atomically.

A live run refuses every combo and attached order with director_invalid_order, as its venue takes neither, and takes no OCO group as exclusive:

director_invalid_order: the live venue takes no combo or attached orders (GLE-532)

Continuous rolls

At a continuous roll the director decides the reopen between the legs: after the venue has accepted the close, before the reopen is sent. The close passes as a reduction and is not evaluated. The reopen is judged as a transfer, against the book as the roll leaves it: the outgoing contract’s position goes with its paired close and the bracket children that ride the reopen are taken out, so the two contracts are not charged together. It keeps the series’ logical symbol, so a reopen of no more than its close re-establishes the same holding and is decided without max-open-positions, max-group-positions and the counts and quantities of max-directional-exposure, whose risk caps still judge it (Historical risk), and without volatility-multiplier: the overlay limits new requested risk and does not rebalance a holding, and a reopen is whole or nothing, so any multiplier below 1 would flatten the holding at the roll. A larger reopen faces the overlay. A reopen of no more than its close is not judged by max-order-size or entry-rate either, as it is not judged by the holding counts: it re-establishes a holding the strategy may have built with several orders. restricted-instruments and stale-data still judge it.

Every reopen faces each liquidity-cap rule’s position and open-interest bounds alone, against the incoming contract’s own volume and open interest: it is not a new entry for the day, so the daily bound does not judge it and it is not charged. The reducing state still refuses it as it refuses any opening, and every other rule compares it with the book before the roll. For max-deployed-value, max-deployed-percent and max-margin that book holds the series’ working orders on the outgoing contract, with the position they protect, so the reopen is refused only when the incoming contract costs more and takes the book above a cap. A roll reopens whole or not at all, so a reopen a rule would trim is refused. The drawdown rules judge the reopen with the equity observed at the cut; when a timer later than the cut, both due before the same bar, was observed first, the equity at the cut is gone, so those rules fail closed and the reopen is refused with director_input_missing (input=equity), its reason giving the cut, the later observation’s instant (cut=, observed=) and the cause: the roll’s cut is decided after a later observation. This is rare, and the next observation restores the rules.

Both legs get a decision record in phase roll with "origin": "engine", under the legs’ labels (roll/<symbol>/<cut>/close and .../open). A refused reopen does not end the run: the position closes on the outgoing contract and is not reopened. The strategy gets a Denied update under the open leg’s label whose reason leads with the director’s token; each bracket child that was to ride the reopen is lost as a denied roll replacement is, with a Denied update and then its rejection cancel; and the close reaches it as a fill, so its position goes flat. The lifecycle trace records the open leg as rejected, and its summary counts it in roll_legs_refused. The operator gets a line on standard error. From a liquidity-cap run with "action": "trim" (below), whose trimmed ES holding could not be reopened whole at the March 2024 roll:

algo: roll ESH24->ESM24: the trade director refused the reopen of 1204 on ESM24 for instance fut:XCME:ES: director_liquidity: rule=1 requested=1204 permitted=11 a roll reopens its position whole or not at all; the position closed on ESH24 and was not reopened (GLE-532)

Corporate actions and expiries

A corporate action or a contract expiry’s settlement changes the book without an order, so the director refuses nothing there. When the book it leaves is above a configured count, group, directional or monetary cap, each such cap gets a breach record (the rule, its dimension, the measure and the cap; a monetary cap’s measure_value and cap_value, in the base currency) in phase corporate_action or expiry, once the action’s EFFECTIVE notice or the expiry’s events have reached the strategy, and the operator a line on standard error. A cash dividend changes no quantity, so it is not checked.

The e9equity demo buys 100 shares on its first bar. Run on the XLE ETF across its 2:1 split of 5 December 2025 with {"rule": "max-directional-exposure", "symbol": "eq:ARCX:XLE", "max-long-quantity": 150}, the split turns the 100 shares into 200:

algo: trade director for instance eq:ARCX:XLE: after corporate action CA_SPLIT on eq:ARCX:XLE at 2025-12-05T00:00:00.000000000Z the book is above a configured cap: director_directional_limit: rule=1 dimension=long-quantity measure=200 max=150 (nothing is refused; GLE-532)

The holding stays; only a further buy would be refused.

The director and the admission limits

The admission limits (--max-order-quantity and the others; The admission policy) run after the director and check the accepted quantity. A refusal there is recorded as an outcome linked to the director’s decision (The decision log). Under a director, an order the director finds wholly reducing is not counted against --max-working-orders or --max-symbol-working-orders, and takes no place in the submission rate budget. The working-order limits check the current state, so the exemption follows each decision: a held order whose release only reduces is released with the limits full, even if it opened risk when it was held, and one whose release opens risk (its position changed while it waited) counts against them. A queued order let in under the exemption counts against them at a dequeue that opens risk. The rate budget is a one-time charge: an order takes its place at the first decision that opens risk, its admission or such a release or dequeue, and is refused when the budget is full; an order that has taken its place does not take another, whatever its later decisions. A trimmed order the race guard holds gets the guard’s held: text after ; in its reason.

Configuring the director

A director is one JSON object: some settings that apply to every rule, the groups the rules may name, and the rules themselves.

Where a director comes from

A director is configured with a director object, either in a run-config file (at the global level, in a strategies entry or in a run; the most specific level replaces the others whole) or on the command line with --director FILE or --director-json, which take the outer {"director": {...}} value and replace every run’s director. On the command line and in a run-config file, // and # line comments are allowed.

In a run-config file the director key holds the director object itself. This file gives one run the global director and another its own, and runs a third without any:

{
  "global": {
    "strategy": "bin/smacross",
    "bar-interval": "1d",
    "date-from": "2025-01-01",
    "date-to": "2025-07-01",
    "data": {"adapter": "marketfeed"},
    // Every run inherits this director unless it names its own.
    "director": {
      "version": 1,
      "rules": [
        {"rule": "max-open-positions", "max": 1},
        {"rule": "entry-rate", "max": 2, "window": "720h"}
      ]
    }
  },
  "runs": [
    {"symbol": "fut:XCME:ES", "name": "ES"},
    // A run's director replaces the inherited one whole: this run has
    // the margin cap and neither of the global rules.
    {"symbol": "fut:XCME:NQ", "name": "NQ",
     "director": {"version": 1, "rules": [{"rule": "max-margin", "max-value": 40000}]}},
    // No director at all for this run.
    {"symbol": "fut:XCEC:GC", "name": "GC", "director": {"enabled": false}}
  ]
}

bin/algo run --config runs.jsonc --results-dir results runs the three; the ES and NQ runs each write their own director-decisions.jsonl and print a director: block, and the GC run writes and prints nothing of the director’s. {"director": {"enabled": false}}, with no other key, replaces an inherited director with none. On the command line the same object goes inside the outer value:

bin/algo run ... --director-json '{"director":{"version":1,"rules":[{"rule":"max-open-positions","max":4}]}}'

The command-line flags

FlagMeaning
--director FILEA JSON file holding {"director": {...}}, with // and # comments allowed. With --config it replaces every run’s director.
--director-json JSONThe same value inline, for a single run without a file. Not with --director.
--director-log PATHWhere the decision log goes. Default: director-decisions.jsonl in the run’s results directory, or <ledger>.director.jsonl beside a live run’s ledger. With --config it must contain {run}.
--director-control FILEA file of owner control events (resume, reduce, reset-peak, reset-ladder), read once when the run starts (Owner control events). {run} is replaced by the run’s ID. Needs a director.

{run} in --director-log and --director-control is replaced by each run’s ID. With --config, --director-log must contain it, so no two runs share one log:

algo: --director-log with --config must contain {run}, so the runs never share one decision log

A director needs order lifetime v2 and a decision log. A backtest runs v2 unless it names --lifetime v1; a live run defaults to v1, so a live run with a director must name --lifetime v2. A backtest needs --results-dir or --director-log, a live run a ledger or --director-log:

algo: engine: a trade director needs order lifetime v2 (--lifetime v2); a v1 run, a live run's default, has no per-attempt refusal for it to use (GLE-532)
algo: engine: a trade director needs a decision log: give --results-dir, or --director-log (GLE-532)

A configured run records the configuration’s hash in result.json as Director; a run without one records nothing, so its artefacts are byte-identical to before.

The director object

KeyTypeDefaultMeaning
versionintegerrequiredAlways 1.
enabledbooleantruefalse, as the only key, replaces an inherited director with none.
scopestring"instance"The only scope: one strategy instance and its account. "account" is refused (shared accounts are GLE-360).
base-currencystringthe run’sThree upper-case letters. Omit it to inherit the run’s base currency; a different one refuses the run.
clockobjectUTC datesThe account clock that dates periods and daily budgets: {"kind": "utc"} or {"kind": "exchange-trade-date", "symbol": "fut:XCME:ES"} (The account clock).
unknown-groupstring"reject"reject or allow: whether a group rule refuses an opening whose membership of its group cannot be established, or takes the holding as outside the group (Groups and selectors).
price-buffernumber ≥ 00.02The reserve price that values deployed capital and order notional is multiplied by 1 + price-buffer.
min-quantityinteger ≥ 11The smallest opening a trim may leave.
quantity-stepinteger ≥ 11Trims and the ladder round down to a multiple of this.
min-notionalnumber ≥ 00The smallest notional a trim may leave; 0 is none.
freshnessobjectnoneAge limits for live readings: account and margin (Go durations; 30 seconds each by default on a live run). marks and fx are refused: they are not applied yet.
groupsobjectnoneNamed instrument groups (Groups and selectors).
rulesarrayrequiredThe rules, in phase order, at least one.

Every money amount is in the run’s base currency, the currency of its capital (Currencies), so base-currency only confirms it:

algo: engine: director.base-currency EUR differs from the run's base currency USD (omit it to inherit the run's; GLE-532)

The configuration is decoded strictly: an unknown key, a duplicate key, a value of the wrong type or out of range, or rules out of phase order refuse it, naming the key and the rule’s position. For example:

algo: --director bad.json: director: director.rules[1] (max-open-positions): unknown key "max-count"
algo: --director bad.json: director: director.rules[2] (reduce-only-state): phase gate is out of order after phase constraint

The parser is engine/director (ParseConfig, ParseSettings), and the design’s vocabulary of the eighteen rules and the director object is docs/trade-director-spec.md §7 and §10.

Groups and selectors

A group is a named list of instruments, by roots (a futures root such as fut:XCEC:GC matches its dated contracts and its continuous series) and by exact symbols; give at least one non-empty list. Group IDs are lower case, start with a letter and may contain digits and single hyphens (metals, rates-long). A symbol is written in its canonical form and matched without regard to case. An instrument may belong to several groups, and one that matches none is ungrouped.

{
  "director": {
    "version": 1,
    "groups": {
      "metals": {"roots": ["fut:XCEC:GC", "fut:XCEC:SI", "fut:XCEC:HG"]},
      "etfs": {"symbols": ["eq:ARCX:GLD", "eq:XNAS:TLT"]}
    },
    "rules": [
      {"rule": "max-group-positions", "group": "metals", "max": 2},
      {"rule": "max-directional-exposure", "group": "etfs", "max-long-count": 1}
    ]
  }
}

Rules name instruments in these ways:

  • max-directional-exposure takes exactly one of symbol and group;
  • max-order-size takes exactly one of symbol, group and "all": true;
  • restricted-instruments takes a list of selectors, each a symbol, a root or group:ID (a bare group ID is refused, so a group cannot be mistaken for a symbol);
  • max-group-positions and top-n-conviction’s group-max name groups by ID.

A group a rule names must be defined in groups. A rule scoped to one group needs membership only for that group: an ungrouped crude oil order is simply not a metals order. Membership can be undecided only for a group defined by roots, when the instrument’s root is unknown. Under "unknown-group": "reject", the default, max-group-positions, a max-directional-exposure or max-order-size group selector and a group-max quota then refuse the opening with director_input_missing (input=membership group=ID), and count such a holding in the group; under allow the holding is taken as outside the group. restricted-instruments is a compliance control and refuses an undecided identity whatever unknown-group says (inputs=membership:ID).

The account clock

The director’s clock dates each event for the period rule and the daily liquidity budget: UTC civil dates by default, or with {"kind": "exchange-trade-date", "symbol": "fut:XCME:ES"} the trade dates of that root’s exchange calendar, so an evening session belongs to the next day’s date (E4: the first mark of the session that opens at 17:00 Chicago on 31 August belongs to 1 September and to September’s period). A bar is dated by its last instant, just before its close, so a bar closing at a session’s end belongs to that session; a timer’s instant and a roll’s cut are dated by themselves, so a timer at a session’s first instant belongs to the new session. Dates do not move backwards. Between two sessions the last trade date holds until the next session begins. The calendar’s spans carry daylight saving time, so the boundary moves between 22:00 and 23:00 UTC with it.

The run fetches that calendar for the director alone when a period rule or a liquidity-cap daily bound needs it (or uses the run’s own, when it was asked for), without pushing it to the strategy or changing the simulator; a run that cannot get it is refused, and an instant the calendar does not cover has no date, so the period rule refuses openings with director_input_missing (input=date): it does not fall back to UTC dates. An observation without a date still holds the current period to its stop, but its equity cannot become the next period’s baseline, which is then unknown (input=baseline) until the period after. A daily bound dates each opening it charges by the same clock, and a decision at an instant without a date refuses its openings with director_input_missing (inputs=trade-date). A symbol that names no exchange calendar root refuses the run when a period rule or daily bound would read it.

The rules

The director evaluates eighteen rule kinds, and the engine adds one latch of its own, rule 0. Each rule is off unless configured, and its thresholds are yours to choose: no rule has a built-in limit. All monetary parameters and measures are in the run’s base currency, fractions use 1.0 for 100%, and quantities are shares, contracts or whole combo units. The sections below follow the phase order.

RulePhaseWhat it limitsCode
reduce-only-stategateEvery opening while the state is reducing.director_reducing
restricted-instrumentsgateOpenings on listed symbols, roots or groups, on a side.director_restricted
stale-datagateOpenings whose readings are old, or whose mark jumped.director_stale_data
drawdown-laddermultiplierOpening size, by the worst drawdown band reached.director_drawdown_ladder
volatility-multipliermultiplierOpening size, so the book’s volatility stays within a target.director_volatility
max-order-sizeconstraintOne order’s opening quantity, notional and price distance from the mark.director_order_size
entry-rateconstraintOpenings accepted in a rolling window.director_entry_rate
max-directional-exposureconstraintLong and short counts, quantities and risk of a symbol or group.director_directional_limit
max-open-positionsconstraintPotentially open holdings.director_open_positions
max-group-positionsconstraintPotentially open holdings in one group.director_group_positions
max-deployed-valueconstraintGross deployed notional.director_deployed_value
max-deployed-percentconstraintGross deployed notional as a fraction of equity.director_deployed_percent
max-marginconstraintReserved initial margin, as a value and a fraction of equity.director_margin_limit
max-peak-drawdownconstraintOpenings after a loss from the equity high-water mark (latched).director_peak_drawdown
max-period-drawdownconstraintOpenings after a loss since the start of a day, week, month, quarter or year.director_period_drawdown
max-varconstraintHistorical value-at-risk of the account.director_var_limit
liquidity-capconstraintPosition and daily entries against volume, and position against open interest.director_liquidity
top-n-convictionselectionOpenings selected at one instant, ranked by conviction.director_not_top_n
The engine’s latch—Every opening after a failed log write or restore, until the owner resumes.director_reducing (rule 0)

Every rule accepts the common key action, limited to the actions it permits (Reject, trim and scale); the tables below list it for the rules that can trim or scale, and the others are reject only. An opening a rule trims is held to the director’s min-quantity, quantity-step and min-notional.

The examples below come from backtests of the SMA-crossover demo on the ES continuous future, as in the quick start, and of the rankedcross demo, a multi-symbol crossover that sets conviction (Handling refusals and trims), with bin/algo run --strategy bin/rankedcross and the symbols, capital and Qty each example gives. Unless an example says otherwise, the rankedcross runs also had --inputs Fast=5,Slow=20; the six futures are fut:XCME:ES, fut:XCME:NQ, fut:XCEC:GC, fut:XCEC:SI, fut:XNYM:CL and fut:XCBT:ZN, and the eleven add fut:XCEC:HG, fut:XNYM:NG, fut:XCBT:ZB, fut:XCME:EUR and fut:XCME:JPY. Records are trimmed with … where they repeat the obvious.

reduce-only-state

An operational switch: in reducing state every opening is refused, and reductions proceed. The state starts as initial and changes only by the owner’s reduce and resume control events (Owner control events); nothing changes it automatically.

ParameterTypeDefaultMeaning
initialactive or reducingactiveThe state the run starts in.
resumeexplicitexplicitHow it leaves reducing state: only by the owner’s resume.
{"director": {"version": 1, "rules": [
  {"rule": "reduce-only-state", "initial": "reducing"}
]}}

Example. Started in reducing state, smacross on ES from January to April 2025 had all eight of its orders refused; each was an opening, because the first was refused and the strategy stayed flat:

director_reducing: rule=1 requested=1 state=reducing

With "initial": "active" and an owner’s reduce on 3 February, the reversal three days later (a buy of 2 against a short of 1) was trimmed to its closing part (director_trimmed: … cause=director_reducing rule=1), a sell from flat on 21 February was refused, and the owner’s resume on 3 March made entries possible again. That run is in Owner control events. A resume of this rule does not clear a drawdown stop or the engine’s latch: each has its own.

Live: works as in a backtest; it reads nothing from the account.

restricted-instruments

A restricted list: refuses an opening on any of its selectors, on the side given.

ParameterTypeDefaultMeaning
selectorsarray of stringsrequiredSymbols, roots, or group:ID. Duplicates are removed.
sidebuy, sell or bothbothThe side of the opening it refuses.

A symbol selector matches the instrument’s symbol, its logical symbol (a continuous series’) and its root, so a root restricts its dated contracts and its continuous series; a group selector matches the instrument’s groups. It is a compliance control, so an identity it cannot establish fails closed whatever unknown-group says: an instrument whose root is unknown, or whose membership of a selected group is undecided, is refused with director_input_missing (inputs=root:SYM, inputs=membership:ID). A restriction does not cancel a resting entry, and a reduction on a restricted instrument passes.

{"director": {"version": 1, "rules": [
  {"rule": "restricted-instruments", "selectors": ["fut:XCME:ES"], "side": "sell"}
]}}

Example. No new shorts in ES: each of smacross’s seven reversals in the first half of 2025 was trimmed to the sell that closed its long. The decision’s rule result names the selector and the contract:

{"ordinal": 1, "rule": "restricted-instruments", "status": "refuse", "requested": 1, "accepted": 0,
 "code": "director_restricted", "detail": "rule=1 requested=1 selector=fut:XCME:ES side=sell symbol=ESH25"}

A sell from flat would have been refused whole, with that detail as its reason after director_restricted:.

Live: works as in a backtest; it needs no market data.

stale-data

Refuses an opening whose readings are older than max-age, or whose mark moved more than max-jump from the mark before it.

ParameterTypeDefaultMeaning
max-agedurationrequiredThe oldest a reading may be, in event time (5m, 24h). Equality passes.
inputsarrayrequiredAny of marks, fx, account, margin.
max-jumpnumber > 0noneThe largest move of a mark from the one before it, as a fraction.

The rule ages each reading to the decision instant in event time: the marks of the instruments the order opens (marks), the rates of their currencies (fx), the account’s equity (account) and its margin reading (margin). A reading dated after the instant is invalid, not fresh. A market closed since its last bar has an old mark, so an opening on it is refused. With max-jump a mark that moved more than that fraction from the mark before it (floored at one tick) is refused. The mark before it is put into the mark’s unit first, by the price ratio of each corporate action whose ex-date falls between the two closes (at or after the earlier, before the later), so a 2:1 split is no jump and a real move across it still is.

At a continuous roll’s reopen the incoming contract has no bar before its cut, so it is valued through the served seam from the outgoing contract: its mark and previous mark are the outgoing contract’s, moved by the seam’s gap (scaled by its ratio on a ratio-adjusted series), and its mark time the outgoing mark’s. A roll with no real move passes, and the deployed caps reserve the reopen at the seam. After the cut the incoming contract’s previous mark is its own: the synthetic cut bar at the served price, then its own bars. A missing reading, one without a time, or a missing previous mark under max-jump, refuses openings with director_input_missing: there is no warm-up exception. On a backtest the marks are the simulator’s bar closes, the rates the run’s FX marks, and the equity and margin readings are the simulator’s at the instant.

{"director": {"version": 1, "rules": [
  {"rule": "stale-data", "inputs": ["marks"], "max-age": "96h", "max-jump": 0.02}
]}}

Example: a jump. Over 2025 this refused the opening part of three of smacross’s reversals, on days ES closed more than 2% from the day before. On 28 March 2025 the close fell from 5,741.25 to 5,602.50:

rule=1 requested=1 input=mark:ESM25 dimension=jump mark=5602.5 prev-mark=5741.25 max=0.02

Example: an age. rankedcross over the eleven futures in 2024, under "inputs": ["marks"], "max-age": "1h". The strategy decides at ES’s close, when every series that closed with it is fresh; but a roll’s reopen is valued at the outgoing contract’s last mark, which can be an hour or two days old, so the reopens were refused and the positions left flat:

director_stale_data: rule=1 requested=1 input=mark:CLH24 age=1h0m1s max-age=1h0m0s

Choose max-age with the slowest series’ session in mind.

director_input_stale is a different refusal: it names a reading the engine withheld as older than its inherited freshness limit (on a live run, the account summary’s net liquidation, equity, and initial margin, margin), whichever rule needed it. No mark or rate is withheld yet.

Live: the account and margin inputs are the account summary’s, aged from the boundary that accepted it (The live account). A live run reads no marks yet, so the marks input refuses every live opening with director_input_missing; it reads no FX rates, so a live run with the fx input is refused when it starts.

drawdown-ladder

Scales every opening by the multiplier of the worst drawdown band the equity has reached since the last reset.

ParameterTypeDefaultMeaning
bandsarray of {"drawdown", "multiplier"}requireddrawdown in (0, 1), strictly increasing; multiplier in [0, 1], not increasing.
recoveryexplicitexplicitA band holds until the owner’s reset-ladder.
actionscalescaleThe only action.

The drawdown is the peak loss as a fraction of the high-water mark (Equity and drawdown state). An opening of q under multiplier m becomes the floor of q × m, one more when the product is within the tolerance of the next whole number (so 100 at 0.29 is 29, and 100 at 0.285 is 28), rounded down to quantity-step. A result of 0 is refused with director_drawdown_ladder; one below min-quantity or min-notional with director_below_minimum, citing the ladder; otherwise the order is trimmed (director_trimmed, cause director_drawdown_ladder). A band with multiplier 0 refuses every opening. Reductions are not scaled, and positions and working orders are not resized.

{"director": {"version": 1, "rules": [
  {"rule": "drawdown-ladder", "bands": [
    {"drawdown": 0.03, "multiplier": 0.5},
    {"drawdown": 0.06, "multiplier": 0.25},
    {"drawdown": 0.1, "multiplier": 0}
  ]}
]}}

Example. rankedcross on ES with Qty=4 and USD 1,000,000 of capital, 2024 to 2025. Each change of band is a state record; the first band was reached on 6 May 2024 with the high-water mark at 1,124,850 (the record’s band counts the bands reached, from 1):

director_state_changed: rule=1 kind=drawdown-ladder from=active to=active
"rule_state": {"rule": 1, "kind": "drawdown-ladder", "hwm": 1124850, "period": "", "baseline": 0,
               "has_baseline": false, "latched": false, "band": 1, "reducing": false}

The next day a reversal from short 4 to long 4 asked for 8: 4 to close, 4 to open. At multiplier 0.5 the opening became 2:

director_trimmed: requested=8 accepted=6 reducing=4 opening_requested=4 opening_accepted=2 cause=director_drawdown_ladder rule=1

In September the second band, 0.25, made a one-lot opening 0.25, which floors to zero:

director_drawdown_ladder: rule=1 requested=1 multiplier=0.25 scaled=0

That refusal fell on roll reopens, which are whole or nothing, so the holding closed at the roll and was not reopened. A ladder is a heavy brake on a strategy that trades few units.

Live: refused when the run starts: the drawdown rules follow equity adjusted for deposits and withdrawals, which a live run cannot read yet (GLE-543).

volatility-multiplier

Scales every opening of a batch by one multiplier, so the book’s annual volatility stays within a target fraction of equity.

ParameterTypeDefaultMeaning
targetnumber > 0requiredThe target annual volatility, as a fraction of marked equity.
lookbackinteger ≥ 2060Completed sessions in the covariance window.
annual-sessionsinteger ≥ 1252Sessions in a year, to annualise daily figures.
actionscalescaleThe only action.

The multiplier is m = min(1, max(0, (target × E − Rbase) / Rnew)), computed once for the whole batch, when the batch is made, and applied to every opening that reaches it. The book is the account before the batch: every position and every working, held and queued order. The pool is each opening the rules before the overlay leave above 0. Rbase is the filled book’s daily volatility from the sample covariance of its instruments’ unit values (close × point value × rate) over the last lookback aligned completed-session changes, plus, for each instrument, the larger of its pending buys and sells times its own daily sigma, with no credit for diversification, so the removal of a hedge is covered. Rnew sums each pooled opening’s quantity times its instrument’s daily sigma. Both, and the budget target × E, are annual amounts in the base currency (sqrt(annual-sessions) annualises the daily ones). The opening is rounded down to quantity-step and held to the minima as a trim is; one scaled to 0 is refused.

No order is enlarged, and a pooled opening that a later rule refuses still counts in Rnew, so its share of the budget goes unused. The overlay shares the budget among the openings requested at one instant, so an order takes its share once: the release or dequeue of an order already charged is not scaled again, and a roll’s reopen of no more than its close is not scaled at all. A reduction does not reach the overlay: a multiplier of 0 leaves a close, and the reducing part of a reversal, unchanged. The overlay needs a positive marked equity (equity) and a full window: a session missing for any instrument removes its two changes for all of them, and the window reaches back for more, up to 2 × lookback sessions, before the rule warms up (director_warming_up: rule=1 requested=4 have=41 need=60). Its inputs are the whole book’s and the whole pool’s, so one instrument without history or a point value, held or pooled, refuses every opening of the batch with director_input_missing (history:SYM, point-value:SYM, fx-history:CUR), not only its own. The decision log’s snapshot record gives the overlay’s figures (volatility).

{"director": {"version": 1, "rules": [
  {"rule": "volatility-multiplier", "target": 0.10}
]}}

Example. rankedcross over the eleven futures with Qty=2 and USD 1,000,000, 2024 to 2025, with a 10% target. On 8 February 2024 the book already ran at an annual 78,748 of a budget of 103,057, and a gold short of 2 would add 50,620, so the multiplier was 0.48 and 2 × 0.48 floored to zero:

director_volatility: rule=1 requested=2 multiplier=0.4802213103966153 base=78748.28981970725 new=50620.067568879975 budget=103057.125 scaled=0

The batch’s snapshot carries the same figures, with the window it used:

"volatility": [{"rule": 1, "available": true, "have": 60, "need": 60, "multiplier": 0.4802213103966153,
                "base": 78748.28981970725, "new": 50620.067568879975, "budget": 103057.125}]

Over the two years the overlay trimmed 47 orders and refused 61.

Live: reads the history as max-var does and the account summary’s net liquidation as E. A live run reads no FX rates, so a foreign-currency instrument refuses every opening of the batch (fx-history:<CUR>).

max-order-size

A fat-finger check of each order as the strategy sent it: its opening quantity, its notional and how far its limit and stop prices are from the mark.

ParameterTypeDefaultMeaning
symbol, group or allselectorrequired, exactly oneThe instruments it covers; "all": true covers every instrument.
max-quantityinteger ≥ 1noneThe largest opening quantity.
max-notionalnumber > 0noneThe largest opening notional.
price-collarnumber ≥ 0noneThe largest distance of a limit or stop price from the mark, as a fraction.

At least one of max-quantity and max-notional is required. The rule is reject only: it measures the order’s whole opening part, even when an earlier rule or the drawdown ladder cut it, so a sizing error is refused, not trimmed into an acceptable order. A combo is measured leg by leg (each leg’s ratio times the spread units, at the leg’s own notional). The notional is the opening quantity times one unit’s reserve value (the reserve price, the point value and the rate into the base currency). The collar compares a limit or stop price with the instrument’s mark: |price − mark| against price-collar × max(|mark|, tick), so a mark at zero is floored at one tick; a market order has no price to collar, and a combo’s legs carry none.

Configured, the rules must cover every instrument an order opens: an instrument no max-order-size rule selects is refused. Only the bounds a rule sets need inputs: max-quantity alone needs no mark. Several rules can give different instruments different bounds.

{"director": {"version": 1, "rules": [
  {"rule": "max-order-size", "symbol": "fut:XCME:ES", "max-quantity": 2, "max-notional": 750000, "price-collar": 0.05},
  {"rule": "max-order-size", "all": true, "max-quantity": 10}
]}}

Examples. Each ran with a single max-order-size rule. With "all": true, "max-quantity": 2, rankedcross on ES sending 3 contracts at a time:

director_order_size: rule=1 requested=3 symbol=ESH24 dimension=quantity measure=3 max=2

With "all": true, "max-notional": 250000, smacross’s single ES contract (one contract at about 6,000 points, times 50, plus the 2% price buffer):

director_order_size: rule=1 requested=1 symbol=ESH25 dimension=notional measure=303692.25 max=250000

With "symbol": "fut:XCME:NQ", "max-quantity": 2, on a run that trades ES:

director_order_size: rule=1 requested=1 uncovered=ESH25

A limit order more than 5% from a mark of 100:

director_order_size: rule=1 requested=1 symbol=A dimension=limit-collar price=105.5 mark=100 max=0.05

A stop is checked the same way, as dimension=stop-collar.

Live: the max-quantity bound works today. max-notional needs marks, which a live run does not read yet, so it refuses every live opening with director_input_missing; price-collar needs a mark only for an order with a limit or stop price, so it refuses those and passes market orders. From a live run on the IBKR emulator with max-quantity 5:

director_order_size: rule=1 requested=10 symbol=AAPL dimension=quantity measure=10 max=5

entry-rate

At most max openings accepted in any rolling window.

ParameterTypeDefaultMeaning
maxinteger ≥ 0requiredOpenings allowed in the window. 0 refuses every opening.
windowdurationrequiredThe rolling window, such as 1m or 24h.

The rule counts the openings accepted in (t − window, t], t the decision instant: one per order whatever its quantity, a trimmed one included, and the openings accepted earlier in the same response. An order takes its slot at most once, at the first decision that gives it an opening part, usually when it is first held or sent; a cancellation does not give it back, and the release of a held order or the dequeue of a queued one is then neither charged nor refused for rate. An order held or queued as wholly reducing was decided with nothing charged: a close held for a conflict (it shares another close’s reducing budget, and waits for that close’s cancellation), a reduction held behind its label’s earlier attempt, or a reduction a live run’s transport queued. If it opens risk when it is released or dequeued (its position changed while it waited, for example by another order’s fill), that is the opening’s first decision, which entry-rate judges and charges. An order already charged is not charged again, whatever its later decisions: the rule counts orders, not quantities. A refused order takes nothing, nor does a reduction. Within one response the count is conservative: an opening the director accepted counts against the later openings of the same response even if the race guard or the admission limits then refuse it, as its reservation does for the rest of the instant; the history records only the openings actually held or sent.

{"director": {"version": 1, "rules": [
  {"rule": "entry-rate", "max": 1, "window": "720h"}
]}}

Example. One entry in any 30 days: of smacross’s first-half 2025 orders on ES, four entries were refused and five reversals lost their opening part:

director_entry_rate: rule=1 requested=1 count=1 max=1 window=720h0m0s

count is the openings already in the window. The history starts empty when a run starts.

Live: works today, and a live run keeps the history over a restart (Restart and recovery). A restart that cannot restore it refuses openings with director_input_missing (inputs=entry-history) until a whole window has passed.

max-directional-exposure

Long and short limits over one symbol or one group: holding counts, quantities and risk.

ParameterTypeDefaultMeaning
symbol or groupselectorrequired, exactly oneWhat it measures.
max-long-count, max-short-countinteger ≥ 0noneLong or short holdings in the selection.
max-long-quantity, max-short-quantityinteger ≥ 0noneLong or short quantity; with a symbol selector only.
max-long-risk, max-short-risknumber ≥ 0noneLong or short risk in the base currency.
risk-lookbackinteger ≥ 2060Completed sessions behind each instrument’s daily sigma.
actionreject or trimrejecttrim cuts the opening to what fits.

At least one cap is required. A holding counts as long when it could end long and as short when it could end short; one with opposing pending entries can count as both. The risk of a side is each row’s long or short quantity times its instrument’s daily sigma over risk-lookback sessions, summed in the base currency (Historical risk): a one-session gross figure with no credit for diversification, not a stop loss. Counts are compared first, then quantities, then risk, long before short, and the first that breaks refuses.

{"director": {"version": 1,
  "groups": {
    "indices": {"roots": ["fut:XCME:ES", "fut:XCME:NQ"]},
    "metals": {"roots": ["fut:XCEC:GC", "fut:XCEC:SI", "fut:XCEC:HG"]}
  },
  "rules": [
    {"rule": "max-directional-exposure", "group": "metals", "max-long-count": 1, "max-short-count": 1},
    {"rule": "max-directional-exposure", "group": "indices", "max-long-risk": 15000, "max-short-risk": 15000, "action": "trim"}
  ]
}}

Example. rankedcross over the eleven futures, Qty=3 and USD 1,000,000, 2024 to 2025. With a copper short already held, a gold short was refused by the metals count:

director_directional_limit: rule=1 requested=3 dimension=short-count measure=2 max=1

The decision records each dimension the rule measured:

"dimensions": [{"name": "long-count", "before": 0, "requested_after": 0, "limit": 1},
               {"name": "short-count", "before": 1, "requested_after": 2, "limit": 1}]

On the index risk cap, an NQ reversal on 7 May 2024 asked for 6: 3 to close a short and 3 to open a long. Three would have taken the long risk to 17,338 against 15,000, so the trim kept 2, at 13,515:

director_trimmed: requested=6 accepted=5 reducing=3 opening_requested=3 opening_accepted=2 cause=director_directional_limit rule=2
{"name": "long-risk", "before": 5867.0079181876235, "requested_after": 17338.33677594041,
 "accepted_after": 13514.560490022815, "limit": 15000}

The quick start’s cap of 0 short contracts on ES, and E3, show the quantity caps, with reject and with trim.

Live: the counts and quantities work today. The risk caps read the history of completed sessions; a live run reads no FX rates, so a foreign-currency instrument refuses openings (fx-history:<CUR>).

max-open-positions

The number of potentially open holdings.

ParameterTypeDefaultMeaning
maxinteger ≥ 0requiredHoldings allowed. 0 forbids opening a new holding.

A holding counts while any fill could leave it non-zero, so working entries, held and queued orders and the orders accepted earlier at the same instant all take slots; several entries into one holding take one. A reduction’s slot stays taken until fills make the holding flat. A count already above the cap stops only an increase. Reject only.

{"director": {"version": 1, "rules": [{"rule": "max-open-positions", "max": 4}]}}

Example. rankedcross over the six futures, with its default inputs (Fast=10, Slow=40), 2024 to 2025 and a cap of four: on 4 October 2024 four holdings were open and a ten-year note short would have been the fifth:

director_open_positions: rule=1 requested=1 count=5 max=4
"dimensions": [{"name": "open-positions", "before": 4, "requested_after": 5, "limit": 4}]

Live: works today; it reads nothing from the account.

max-group-positions

The number of potentially open holdings in one group.

ParameterTypeDefaultMeaning
groupgroup IDrequiredA group defined in groups.
maxinteger ≥ 0requiredHoldings allowed in the group.

Counted as max-open-positions counts, over the group’s members; a holding in two groups takes a slot in each. Without top-n-conviction the slots go in response order; with it, in rank order (E10). Reject only.

{"director": {"version": 1,
  "groups": {
    "indices": {"roots": ["fut:XCME:ES", "fut:XCME:NQ"]},
    "metals": {"roots": ["fut:XCEC:GC", "fut:XCEC:SI"]}
  },
  "rules": [
    {"rule": "max-open-positions", "max": 4},
    {"rule": "max-group-positions", "group": "metals", "max": 1},
    {"rule": "top-n-conviction", "max": 2}
  ]
}}

Example. This was the configuration of the six-futures run of max-open-positions, with one metal at a time: with gold held, a silver short was refused by rule 2. The decision lists the groups the holding belongs to:

{"schema":"algolang.director/1","kind":"decision", … ,"client_id":"fut:XCEC:SI_1719262800", … ,"symbol":"fut:XCEC:SI","side":"sell","physical":"SIN24","groups":["metals"], … ,"requested_quantity":1,"reducing_quantity":0,"accepted_quantity":0, … ,"reason":"director_group_positions: rule=2 group=metals requested=1 count=2 max=1", …}

Live: works today.

max-deployed-value

The gross deployed notional D, in the base currency.

ParameterTypeDefaultMeaning
max-valuenumber ≥ 0requiredThe largest D.
actionreject or trimrejecttrim cuts the opening to what fits.

D is the gross notional of the book after the opening, every position and pending order at its worst quantity and reserve price (What counts against a cap). It is neither margin nor risk: a futures contract counts its full underlying value. An accepted exit does not finance an entry in the same instant.

{"director": {"version": 1, "rules": [
  {"rule": "max-deployed-value", "max-value": 300000}
]}}

Example. One ES contract was worth a little over 300,000 in the first half of 2025, with the 2% buffer, so smacross’s entries were refused when ES was above about 5,880:

director_deployed_value: rule=1 requested=1 deployed=303692.25 max=300000

E6 shows the trim and the minimum quantity.

Live: a live run reads no marks yet, so it refuses every live opening (director_input_missing, inputs=mark:<SYM>).

max-deployed-percent

D against a fraction of marked equity E.

ParameterTypeDefaultMeaning
max-fractionnumber ≥ 0requiredThe largest D / E; above 1 for a leveraged book.
actionreject or trimrejecttrim cuts the opening to what fits.

D > max-fraction × E breaches. Equity that is unavailable, zero or negative refuses every opening.

{"director": {"version": 1, "rules": [
  {"rule": "max-deployed-percent", "max-fraction": 1, "action": "trim"}
]}}

Example. rankedcross on ES with Qty=5 and USD 1,000,000: five contracts were worth 1,223,490, so the first entry was trimmed to four, 978,792 of the 1,000,000 allowed:

director_trimmed: requested=5 accepted=4 reducing=0 opening_requested=5 opening_accepted=4 cause=director_deployed_percent rule=1
{"ordinal": 1, "rule": "max-deployed-percent", "status": "trim", "requested": 5, "accepted": 4,
 "code": "director_deployed_percent",
 "dimensions": [{"name": "deployed-percent", "before": 0, "requested_after": 1223490, "accepted_after": 978792, "limit": 1000000}]}

The dimension’s figures are money: D and the bound max-fraction × E. A roll that would have reopened four contracts at a higher price was refused, since a reopen is whole or nothing:

director_deployed_percent: rule=1 requested=4 permitted=3 a roll reopens its position whole or not at all

A refusal names the equity: director_deployed_percent: rule=1 requested=100 deployed=40000 equity=70000 max-fraction=0.5 (E9).

Live: refuses every live opening for want of marks, as max-deployed-value does.

max-margin

The reserved initial margin, against a value and a fraction of equity.

ParameterTypeDefaultMeaning
max-valuenumber ≥ 0noneThe largest buffered margin.
max-fractionnumber ≥ 0noneThe largest buffered margin as a fraction of E.
margin-buffernumber ≥ 00.10The margin is multiplied by 1 + margin-buffer before it is compared.

At least one bound is required. The reserved initial margin sums every row’s worst quantity times its initial margin, so working orders count as if they filled; the rule compares (1 + margin-buffer) × margin with max-value and with max-fraction × E, each when set. Reject only. It reads the venue’s margins under every --margin-model, off included, and does not change what the simulator does with them. Maintenance margin is logged, not compared.

{"director": {"version": 1, "rules": [
  {"rule": "max-margin", "max-value": 50000},
  {"rule": "max-margin", "max-fraction": 0.15, "margin-buffer": 0}
]}}

Examples. ES’s initial margin was 46,080 a contract in 2025, 50,688 with the 10% buffer, so a cap of 50,000 refused every entry:

director_margin_limit: rule=1 requested=1 margin=50688.00000000001 max=50000 bound=value

The rule result also carries the maintenance margin it would reserve:

{"ordinal": 1, "rule": "max-margin", "status": "refuse", "requested": 1, "accepted": 0,
 "code": "director_margin_limit", "detail": "rule=1 requested=1 margin=50688.00000000001 max=50000 bound=value",
 "dimensions": [{"name": "margin-value", "before": 0, "requested_after": 50688.00000000001, "limit": 50000}],
 "maintenance": 33225}

In the futures portfolio of Recipes, a 15% cap on margin to equity bound fifteen times; bound=fraction and max is the bound in money:

director_margin_limit: rule=8 requested=1 margin=151675.7 max=147672.375 bound=fraction

E8 shows the rule across a roll.

Live: reads the account summary’s initial margin plus the modelled increments of pending orders (Live margin). An equity or an option has no approved margin schedule, so an opening on one is refused. From a live run on the IBKR emulator:

director_input_missing: rule=1 requested=1 inputs=margin:AAPL

max-peak-drawdown

An entry stop on the loss from the equity high-water mark. Once reached it latches: entries stay refused until the owner resets it, whatever the equity does next.

ParameterTypeDefaultMeaning
max-valuenumber ≥ 0noneThe largest loss from the mark, in money.
max-fractionnumber in [0, 1)noneThe largest loss as a fraction of the mark.
equitymarkedmarkedThe equity it follows. closed is refused: it has no source yet.

At least one bound is required; reaching either latches the stop (loss >= max-value, loss >= max-fraction × mark). How the mark and the loss are kept is in Equity and drawdown state. The owner’s reset-peak clears the stop and sets the mark to the current equity. Reject only.

{"director": {"version": 1, "rules": [
  {"rule": "max-peak-drawdown", "max-fraction": 0.1}
]}}

Example. smacross on ES through 2025 with USD 100,000. The mark started at the capital and rose to 128,687.50; on 31 March the loss passed 10% of it and the stop latched, a state record:

director_state_changed: rule=1 kind=max-peak-drawdown from=active to=director_peak_drawdown
"rule_state": {"rule": 1, "kind": "max-peak-drawdown", "hwm": 128687.5, "period": "", "baseline": 0,
               "has_baseline": false, "latched": true, "band": 0, "reducing": false}

Every later opening was refused, the refusal giving the loss now; the fraction is for information and decides nothing:

director_peak_drawdown: rule=1 requested=1 state=latched loss=22012.5 fraction=0.15131465887609555 max-fraction=0.1

The latch held through a recovery. With an owner’s reset-peak effective 2 June (Owner control events), the mark moved to that day’s equity, 123,462.50, and entries resumed until the stop latched again on 2 September, from a mark of 133,312.50.

Live: refused when the run starts (GLE-543).

max-period-drawdown

An entry stop on the loss since the start of the current day, week, month, quarter or year. It clears when the next period begins.

ParameterTypeDefaultMeaning
periodday, week, month, quarter or yearrequiredThe period. Use one rule per period.
max-valuenumber ≥ 0noneThe largest loss since the period began.
max-fractionnumber in [0, 1)noneThe largest loss as a fraction of the period’s starting equity.
equitymarkedmarkedAs for the peak stop.

The loss is measured from the period’s baseline, the equity observed just before its first event, so a gap at a session’s open belongs to the new period; it is not a trailing peak within the period. The periods follow the director’s clock, so with an exchange clock an evening session belongs to the next trade date (E4).

{"director": {"version": 1,
  "clock": {"kind": "exchange-trade-date", "symbol": "fut:XCME:ES"},
  "rules": [
    {"rule": "max-period-drawdown", "period": "month", "max-value": 5000},
    {"rule": "max-period-drawdown", "period": "quarter", "max-fraction": 0.08}
  ]
}}

Example. smacross on ES through 2025 with a monthly stop of 5,000 on the CME trade-date clock (the monthly rule alone, without the quarterly one above). Each new month is a state record carrying the period and its baseline; April 2025 began at 113,862.50, and on 22 April the stop latched:

{"schema":"algolang.director/1","kind":"state","sequence":28,"event_time":"2025-04-22T21:00:00Z", … ,"reason":"director_state_changed: rule=1 kind=max-period-drawdown from=active to=director_period_drawdown","state":"director_period_drawdown","from_state":"active","rule_state":{"rule":1,"kind":"max-period-drawdown","hwm":0,"period":"2025-04","baseline":113862.5,"has_baseline":true,"latched":true,"band":0,"reducing":false}, … }

On 1 May it cleared, with May’s baseline at 92,637.50. A latched stop refuses with the loss now, which may have shrunk below the bound by the time an order arrives; the latch, not the current loss, decides. From October:

director_period_drawdown: rule=1 requested=1 state=latched loss=4750 fraction=0.051764064841302276 max-value=5000

Live: refused when the run starts (GLE-543).

max-var

The historical value-at-risk of the whole account with the opening.

ParameterTypeDefaultMeaning
max-valuenumber ≥ 0requiredThe largest VaR.
confidencenumber in (0.5, 1)0.99The loss quantile, at most six decimal digits.
horizon-sessionsinteger 1 to 201The length of each scenario, in sessions.
lookbackinteger ≥ 100250Scenarios, at least ceil(1 / (1 − confidence)).

The rule builds lookback overlapping horizon-sessions-session scenarios from completed sessions (Historical risk) and values every position and working order at both ends of its reachable quantity in each, so a pending order counts on the side that loses. The VaR is the loss at the confidence quantile, computed in integer arithmetic from the confidence’s digits (ceil(0.99 × 250) is the 248th smallest loss). An increase above the cap is refused, with the VaR, the VaR before the order and the tail mean (the average of the losses at and beyond the quantile) in the reason. A reduction passes even when it removes a hedge, and its decision logs the VaR before and after. Reject only.

{"director": {"version": 1, "rules": [
  {"rule": "max-var", "max-value": 6000, "confidence": 0.99, "horizon-sessions": 1, "lookback": 250}
]}}

Example. One ES contract’s one-day 99% VaR over the 250 sessions before each decision ran from about 6,300 to 10,450 in 2025, so a cap of 6,000 refused every entry:

director_var_limit: rule=1 requested=1 var=6325 before=0 tail-mean=7595.833333333333 max=6000

The rule result gives the same figures, the tail mean beside the dimension:

{"ordinal": 1, "rule": "max-var", "status": "refuse", "requested": 1, "accepted": 0,
 "code": "director_var_limit", "detail": "rule=1 requested=1 var=6325 before=0 tail-mean=7595.833333333333 max=6000",
 "dimensions": [{"name": "var", "before": 0, "requested_after": 6325, "limit": 6000}],
 "tail_mean": 7595.833333333333}

In a portfolio before is the book’s VaR without the order. In the futures recipe a gold short would have taken the VaR from 14,264 to 19,467 against 18,000:

rule=9 requested=1 var=19467.49999999994 before=14263.750000000033 tail-mean=24374.583333333285 max=18000

Until the history is long enough the rule warms up. The IBIT ETF began trading in January 2024, so in June 2024 it had 83 of the 250 sessions (rankedcross on eq:XNAS:IBIT with Qty=100):

director_warming_up: rule=1 requested=100 have=83 need=250

E11 shows the quantile and the tail mean on a constructed history.

Live: reads the history of completed sessions, from the run’s bars and the director’s warm-up read at the start, and adds the account summary’s cash in each currency other than the base. A live run reads no FX rates, so a foreign-currency instrument or cash balance refuses openings (fx-history:<CUR>); max-var values the whole account, so it serves only an account holding base-currency instruments and cash.

liquidity-cap

Each instrument’s position and daily entries against its average daily volume, and its position against its open interest.

ParameterTypeDefaultMeaning
adv-lookbackinteger ≥ 520Completed sessions in the average daily volume.
max-position-advfraction in (0, 1]noneThe largest worst quantity after the opening, as a fraction of ADV.
max-daily-entry-advfraction in (0, 1]noneThe largest sum of the trade date’s openings, this one included, as a fraction of ADV.
max-open-interestfraction in (0, 1]noneThe largest worst quantity as a fraction of the contract’s open interest.
actionreject or trimrejecttrim finds the largest opening that satisfies every bound.

At least one bound is required. The average daily volume (ADV) of an instrument is the mean of its last adv-lookback volumes, a closed or missing session skipped, reaching back no more than twice that many sessions; fewer warm it up (director_warming_up: rule=1 requested=60 symbol=ESM26 have=12 need=20). The volume is the contract’s or the share’s own, not a continuous series’ (Historical risk). A combo is judged per leg instrument, its ratios summed. The bounds are checked in this order, each breach refused with director_liquidity:

  • position (max-position-adv): the instrument’s worst quantity after the opening (every position and working order, and the batch’s earlier accepted orders) against the fraction times ADV, refused only when the opening raises it, so a reversal within it passes;
  • daily (max-daily-entry-adv): the openings charged to the instrument on the account trade date, the batch’s earlier ones and this one, against the fraction times ADV;
  • open interest (max-open-interest): the worst quantity, as for position, against the fraction times the contract’s open interest as last published before the decision, refused only when the opening raises it.

An ADV of 0, or an open interest of 0, admits no opening that raises the measure. An opening is charged to the day once, at the first decision that gives it an opening part (its admission, or the release or dequeue of an order wholly reducing until then), as it is first held or sent, and nothing is refunded: an opening that stays unfilled, or is cancelled, still counts for its day. A reduction is not charged. The trade date is the director’s clock.

An equity has no open interest, so a max-open-interest bound refuses every equity opening. The rule takes no selector of instruments, so within one director the bound applies to every opening, equities included; to bound futures alone, give the runs or strategies that trade equities a director without it (a director object can be set per strategy and per run in a run-config file). A backtest with a max-open-interest bound is refused when the data adapter connects if the adapter does not serve STATISTICS on the stdio-pb-v1 wire, which alone carries them (engine: director.rules[N] (liquidity-cap): max-open-interest reads each contract's open interest from the data adapter's STATISTICS, but the data adapter advertises no STATISTICS; ...); such a run prefers that wire.

{"director": {"version": 1, "rules": [
  {"rule": "liquidity-cap", "max-position-adv": 0.001, "max-daily-entry-adv": 0.0015, "action": "trim"}
]}}

Example. rankedcross on ES asking for 3,000 contracts at a time (Qty=3000, with USD 1,000,000,000 of capital so that only this rule binds), with a position cap of 0.1% of ADV. ES trades over a million contracts a day, so most entries were trimmed to about a thousand; the first, in January 2024, to 1,126:

director_trimmed: requested=3000 accepted=1126 reducing=0 opening_requested=3000 opening_accepted=1126 cause=director_liquidity rule=1

A refusal names the bound, the contract, the quantity and the ADV:

director_liquidity: rule=1 requested=60 dimension=position symbol=ESM26 quantity=60 adv=100 max=0.5
director_liquidity: rule=1 requested=10 dimension=daily symbol=ESM26 quantity=30 adv=100 max=0.25
director_liquidity: rule=1 requested=60 dimension=open-interest symbol=ESM26 quantity=60 open-interest=100 max=0.5

A roll judges the reopen against the incoming contract’s own volume, which before the roll is a fraction of the outgoing one’s, so the trimmed holding was refused at each roll and left flat (Continuous rolls). And on an equity, a max-open-interest bound refuses every opening (rankedcross on eq:ARCX:SPY with Qty=100, in the second half of 2024):

director_input_missing: rule=1 requested=100 inputs=open-interest:eq:ARCX:SPY

Live: refused when the run starts: a live run has no validated volume or open-interest source yet (GLE-545). The refusal is engine: director.rules[N] (liquidity-cap): a live run has no validated volume or open-interest source yet (spec §7), so the rule cannot be evaluated; remove it from a live configuration (GLE-532).

top-n-conviction

Takes the best-ranked openings of one decision instant, in total, per side and per group, ranked by absolute conviction.

ParameterTypeDefaultMeaning
maxinteger ≥ 0requiredOpenings selected at one instant, in total.
max-long, max-shortinteger ≥ 0noneOpenings selected per side.
group-maxobject of group ID to integer ≥ 0noneOpenings selected per group.

The rule is the selection phase, last in the configuration and at most once. With it, the director previews each opening against the book before the batch and drops those no rule would admit, ranks the rest by the absolute value of the order’s conviction (absent counts as zero; ties go to the logical symbol, then the label, the attempt and the response order), and then decides them in that order: each takes the capacity the better-ranked ones left, and the quotas are the last check. An opening that fits every cap but misses a quota is refused with its rank, the quota and its score. The quotas are simultaneous ceilings: with max 3 and two per side, two longs and one short, or one long and two shorts. Reductions take no slot, and the reducing part of a reversal survives its opening’s refusal. A refused opening is not kept for a later instant.

{"director": {"version": 1,
  "groups": {"metals": {"roots": ["fut:XCEC:GC", "fut:XCEC:SI", "fut:XCEC:HG"]}},
  "rules": [
    {"rule": "top-n-conviction", "max": 3, "max-long": 1, "max-short": 1, "group-max": {"metals": 1}}
  ]
}}

Example. rankedcross over the eleven futures, which sets each order’s conviction from the gap between its averages, with max 1: on 8 January 2024 three shorts crossed at once. ES had the widest gap (conviction −0.036) and took the one slot; the euro, ranked second, and the thirty-year bond, third, were refused:

director_not_top_n: rank=2 quota=total max=1 score=0.020883971113431977 rule=1
director_not_top_n: rank=3 quota=total max=1 score=0.015598429928529436 rule=1

The decision record carries the conviction and the rank:

{"schema":"algolang.director/1","kind":"decision", … ,"client_id":"fut:XCME:EUR_1704751200", … ,"side":"sell","physical":"EURH24", … ,"conviction":-0.020883971113431977,"rank":2,"reason_code":"director_not_top_n", … }

With the configuration above, the side quotas bind instead (quota=short max=1, quota=long max=1), and a group quota names the group (quota=group:metals).

Live: works today: conviction travels on the order on both wires.

The engine’s reducing latch

The engine has one latch of its own, rule 0, which no configuration names. It is set when a live decision log cannot be written, when a live restart cannot restore the director’s state, and when a restart’s rebuilt book disagrees with the ledger (Restart and recovery). While it is set every opening is refused, ahead of every configured rule, and reductions proceed:

director_reducing: rule=0 requested=1 state=reducing

The decision’s rule results list it first, as engine-latch:

{"ordinal": 0, "rule": "engine-latch", "status": "refuse", "requested": 1, "accepted": 0,
 "code": "director_reducing", "detail": "rule=0 requested=1 state=reducing"}

Only the owner’s resume of rule 0 clears it, and a resume of a reduce-only-state rule does not. A backtest does not set it: a backtest whose log cannot be written fails instead.

Historical risk

max-var, the risk caps of max-directional-exposure, volatility-multiplier and liquidity-cap read a history of completed sessions of the account’s calendar anchor, which the engine builds from the run’s own data once per completed session, and again when a roll or a corporate action becomes known, and puts in every decision until the next of them. The anchor’s sessions are its exchange root’s trading days, or, for a UTC clock, every date on which at least one instrument of the run trades by its root’s exchange calendar, so a weekend is not a session.

Closes. An instrument’s value on a session is its last bar’s close inside that trade date’s span by its own root’s calendar; a day its exchange is closed carries the day before, and a trading day without a bar is a missing observation, which the rules work around by reaching back further (up to twice the lookback) and otherwise refuse with director_warming_up: rule=1 requested=1 have=199 need=250. A history reads no session after the newest completed one. A roll or a corporate action is known from its cut or ex-date, the instant the engine performs it, and one whose instant is later is not used: an equity’s earlier closes are put into its current unit by the known corporate actions (an ex-date at or after a close applies to it), from its raw closes also on a split-adjusted series (adj=split, adj=splitdiv), whose served closes are already in the unit of the run’s end, splits after the instant included (a bar without a raw close is a missing observation), and a continuous series’ dated contract uses its own raw closes where the archive has them, earlier sessions translated from the contract then active through the known rolls. A served composite holds no bar of a roll’s incoming contract before its cut, so at the cut, the roll’s reopen included, the incoming contract is valued through the roll from the outgoing contract’s closes: moved by an additive gap it risks what the outgoing contract did, and the reopen passes the risk caps where the holding did. Each foreign currency’s rate is read at each session’s end, and the account’s foreign cash is valued as an exposure to it.

The warm-up. The data before the run’s start is read for the director alone, through the same requests the strategy’s warm-up uses (it reaches neither the strategy nor its warm-up); a read that fails or comes back short leaves the rules warming up, and a date outside an exchange calendar’s coverage counts as missing, not closed. A backtest reads its exchange rates from 14 days before its start, so an instrument or a cash balance in a foreign currency has no value on the sessions before then: the rules warm up for it until enough sessions with a rate have ended, about lookback sessions into the run for max-var and risk-lookback for a risk cap.

Missing history. A missing input refuses an opening with director_input_missing naming it: risk-sessions (no history: an exchange calendar the run cannot get), history:SYM (an instrument without data), fx-history:CUR (a currency without rates), cash, cash:CUR or cash:rowN (live: the account summary’s cash is unknown, a currency’s balance cannot be read, or the cash row at index N, from 0, has no currency code). A live run reads no rates (The live account), so a foreign-currency instrument or a foreign cash balance refuses max-var openings with fx-history:CUR; an account holding only base-currency cash and instruments is valued. A continuous roll’s reopen faces max-var and the risk caps, but not the directional counts and quantities. The value-at-risk at a book event, and the measures of a passing decision in the log, are not recorded yet.

Volumes. With a max-position-adv or max-daily-entry-adv bound the history also holds each physical instrument’s traded quantity per completed session, read for the director alone; without one nothing reads or fetches volume. A plain series gives its symbol’s volume from its own bars, the warm-up’s and the run’s, summed over the trade date’s span by the symbol’s exchange calendar; a symbol with several series counts its first series alone, so no trade is counted twice. A futures contract’s volume is its own: a continuous series’ composite holds only the active contract’s span, so for each contract the series trades the run reads the contract by its dated symbol (fut:XCME:ES:M26 for ESM26), through the series’ bar spec, session and interval, from the volume window before the contract’s first bar in the composite to the run’s end, once a contract, and a continuous series’ volume does not stand in for the contract ordered. A read that fails leaves the contract without volume, so the rule refuses its openings (director_input_missing: rule=1 requested=60 inputs=volume:ESM26), and the run notes it on standard error. An equity’s volume is put into current shares: marketfeed serves raw volume, the shares traded that day, on a raw and on a split-adjusted bar alike, so the director multiplies a day’s volume by the share ratio of each split whose ex-date is at or after the day’s last bar and is known at the instant. A 2:1 split doubles every volume before it from its ex-date’s instant on, and not before. A day the exchange is closed is not a day of zero volume, and a trading day without a bar is a missing observation, not zero.

Open interest. With a max-open-interest bound, the open interest is read at the run’s start, for the director alone, from the data adapter’s STATISTICS, for each dated contract the run trades (a dated futures series, and each contract of a continuous series by its own symbol, once, from its earliest bar in any series); only with the bound, and neither the strategy nor the simulator sees it. A venue publishes a session’s open interest the next morning and restates it later, and marketfeed stamps each record with the session it describes (ts_ref) and its publication (ts_recv). A decision reads the records published before its instant, dated as a strategy’s statistics series dates them (ts_recv, else ts_event, else ts_ref), and of those the one for the latest session, its latest revision. A contract with none refuses its openings with director_input_missing (inputs=open-interest:SYM), and a read that fails is noted on standard error and does the same. A roll’s reopen is judged by the incoming contract’s own open interest.

Equity and drawdown state

The three drawdown rules follow the marked equity, and the director keeps their state, the reducing state and its own latch from one instant to the next. With a drawdown rule configured, the engine gives it the simulator’s marked equity at every event boundary before a decision: after each bar is simulated (before held orders are rechecked and the strategy decides), at a timer’s instant and at a continuous roll’s cut. The equity is read without side effects, so it records no FX failure.

  • High-water mark and peak loss. Each max-peak-drawdown and drawdown-ladder rule starts its high-water mark at the run’s capital, as the report’s drawdown starts its peak, and raises it with every higher equity. The peak loss is the mark less the equity (0 when the equity is higher) and its fraction the loss divided by the mark, computed as the report computes its drawdown and drawdown percent. The director observes after every bar, so where several bars close at one instant it can see an intermediate equity the report’s curve, which keeps the instant’s last, does not.
  • Periods. A max-period-drawdown rule measures the loss from its period’s baseline: the equity observed just before the period’s first event, so a gap at a session’s open belongs to the new period. The first period’s baseline is the capital. Periods are named 2026-09-01 (day), 2026-W36 (ISO week, Monday start; 1 January 2027 is in 2026-W53), 2026-09, 2026-Q3 and 2026.
  • Thresholds. A stop or band is reached when its measure equals or passes it (loss >= max-value, loss >= max-fraction × mark), under the comparison tolerance, so a loss equal to its bound on paper reaches it and one short of it by more than the tolerance does not, and "max-value": 0 stops entries at the first observation. The fraction= a latched stop’s refusal reports is the loss divided by the mark, for information; it decides nothing. A fraction needs a positive reference: with a mark or baseline of zero or below, a fraction rule refuses openings with director_input_missing (input=hwm or input=baseline), while a max-value still applies.
  • Latches and bands. A peak stop, once reached, refuses entries until an owner resets it, even after the equity makes a new high; a period stop clears when the next period begins; a ladder keeps the worst band it reached until an owner resets it. A reset (reset-peak, reset-ladder; see Owner control events) sets the rule’s high-water mark to the current equity. Recovery is explicit: no rise in equity clears a peak stop or a band. A live run keeps all of this over a restart (Restart and recovery).
  • Missing inputs. An instant without the equity (before the first observation, or when a rate the equity needs is missing) refuses openings under every drawdown rule with director_input_missing (input=equity). "equity": "closed" refuses the run: closed equity has no source yet.

The latches change nothing already working: reaching a stop or a band sends no cancel and no order and leaves every reservation as it is; only later openings are refused.

Each change of a drawdown rule’s condition is a state record. The first observation moves every drawdown rule from director_input_missing to active; a stop that latches moves to its code; a period rule writes one record at each new period, from=active to=active when nothing else changed, with the period and its baseline in rule_state:

director_state_changed: rule=1 kind=max-peak-drawdown from=director_input_missing to=active
director_state_changed: rule=2 kind=max-period-drawdown from=active to=active
director_state_changed: rule=1 kind=max-period-drawdown from=director_period_drawdown to=active

When a roll’s cut is processed after a timer later than it (both due before the same bar), the equity at the cut is no longer known, so the reopen is decided with every drawdown rule refusing openings with director_input_missing (input=equity) rather than with a later instant’s state. A live run refuses the three drawdown rules at configuration: they follow equity adjusted for external cash flows, and a live run has no source of deposits and withdrawals yet (Live runs).

The worked examples

The design’s twelve worked examples, E1 to E12, are the director’s release test: engine/director_parity_test.go runs each through a backtest and, where it applies, an emulated live run on the IBKR emulator, and checks every update the strategy receives and every decision record (the table in Status and limits shows which run on which path). The fixtures are small and exact: unless an example says otherwise, the bars are one-minute AAPL bars from 14:30 UTC on 2 January 2026, the account holds USD 100,000, buffers are those given, and only the named rules are configured.

Each example lists the strategy’s updates as status quantity reason and the decision log as the test summarises it: a decision as decision PHASE LABEL/ATTEMPT STAGE CODE accepted=N, then each rule that judged it as rORDINAL:STATUS, with its measures as :NAME=BEFORE>REQUESTED-AFTER>ACCEPTED-AFTER/LIMIT (the accepted figure is absent when nothing was accepted); a state record as state TIME rule=N FROM>TO, with PERIOD@BASELINE for a period rule; and a snapshot’s equity as equity=E version=N.

E1: top-N ties and absent conviction

Four new entries arrive at one instant, on four symbols: A buys with conviction 0.8, B sells with −0.8, C buys with none, D buys with an explicit 0. Holdings may number four, but only three openings may be selected.

{"director": {"version": 1, "rules": [
  {"rule": "max-open-positions", "max": 4},
  {"rule": "top-n-conviction", "max": 3}
]}}

The scores are 0.8, 0.8, 0 and 0. The symbol breaks both ties, so A, B and C are selected, taking the holding count to 1, 2 and 3; D would fit the count at 4 but misses the quota. An absent conviction does not rank below an explicit zero.

a: working 1
b: working 1
c: working 1
d: denied 1 director_not_top_n: rank=4 quota=total max=3 score=0 rule=2

decision admission a/1 director director_accepted accepted=1 r1:pass:open-positions=0>1>1/4 r2:pass
decision admission d/1 director director_not_top_n accepted=0 r1:pass:open-positions=3>4>4/4 r2:refuse

Backtest and live give the same updates and records.

E2: protective orders during a drawdown stop

An ES long of 2 (point value 50) is bought at 5,000; equity then falls from 100,000 to 80,000, past a 10% peak stop, with holdings capped at one.

{"director": {"version": 1, "rules": [
  {"rule": "max-open-positions", "max": 1},
  {"rule": "max-peak-drawdown", "max-fraction": 0.1}
]}}

At the third bar the strategy sends a protective sell stop for 2 and a new buy entry; at the fourth it cancels the stop and sends a market sell for 2. The stop is wholly reducing, so it passes the full slot count and the latched stop; the entry is refused by the stop; the exit, sent with the stop’s cancellation, is decided once the cancellation is confirmed, which in a backtest is at once:

long: working 2
stop: working 2
entry: denied 1 director_peak_drawdown: rule=2 requested=1 state=latched loss=20000 fraction=0.2 max-fraction=0.1
exit: working 2

state 2026-01-02T14:33:00Z rule=2 active>director_peak_drawdown
snapshot admission 2026-01-02T14:33:00Z equity=80000 version=3
decision admission stop/1 director director_accepted accepted=2
decision admission entry/1 director director_peak_drawdown accepted=0 r1:pass:open-positions=1>1>1/1 r2:refuse

Live, the broker answers the cancellation later, so the exit is held and then rechecked (held 2 held: waiting for the pending cancellation of order "stop" attempt 1 (sell AAPL), then working 2; the variant runs on AAPL, since a live run refuses the drawdown rules). Without the cancellation the exit is refused at once, and the stop keeps protecting the position: denied 2 director_close_conflict: the close would share the reducing budget of stop/1; cancel it in the same response.

E3: stop-and-reverse against a short cap

A long of 3, and a rule that allows at most one short contract.

{"director": {"version": 1, "rules": [
  {"rule": "max-directional-exposure", "symbol": "AAPL", "max-short-quantity": 1}
]}}

The strategy sends a sell stop for 5: 3 reduce, 2 would open a short of 2, above the cap. The opening part is refused and the stop goes out for 3, with its type and trigger unchanged. With "action": "trim" the opening is cut to 1 and the stop goes out for 4:

S (reject): working 3 director_trimmed: requested=5 accepted=3 reducing=3 opening_requested=2 opening_accepted=0 cause=director_directional_limit rule=1
S (trim):   working 4 director_trimmed: requested=5 accepted=4 reducing=3 opening_requested=2 opening_accepted=1 cause=director_directional_limit rule=1

decision admission S/1 director director_trimmed accepted=3 r1:refuse:short-quantity=0>2/1
decision admission S/1 director director_trimmed accepted=4 r1:trim:short-quantity=0>2>1/1

No Denied and no fill is made for the discarded part.

E4: a month boundary in an evening session

The account clock is CME’s trade date. August began at 110,000. The evening session that opens at 17:00 Chicago on 31 August 2026 has trade date 1 September; before it, equity is 105,000, and its first mark is 103,000. The monthly stop is 3%.

{"director": {"version": 1,
  "clock": {"kind": "exchange-trade-date", "symbol": "fut:XCME:ES"},
  "rules": [
    {"rule": "max-period-drawdown", "period": "month", "max-fraction": 0.03},
    {"rule": "max-peak-drawdown", "max-fraction": 0.5}
  ]
}}

August’s stop latched at 20:00 UTC on 31 August (5,000 of 110,000, 4.5%). At 22:05, the session’s first bar, September began with a baseline of 105,000; the loss is 2,000, 1.9%, and the entry passes. On a UTC clock the same bar is still 31 August, the loss is 7,000 of 110,000, 6.4%, and the entry is refused:

exchange clock:
state 2026-08-31T20:00:00Z rule=1 active>director_period_drawdown 2026-08@110000
state 2026-08-31T22:05:00Z rule=1 director_period_drawdown>active 2026-09@105000
e2: working 1

UTC clock:
state 2026-08-31T20:00:00Z rule=1 active>director_period_drawdown 2026-08@110000
e2: denied 1 director_period_drawdown: rule=1 requested=1 state=latched loss=7000 fraction=0.06363636363636363 max-fraction=0.03

The peak stop follows its own mark: from 110,000 it would still see a loss of 7,000.

E5: live margin unavailable

A live account whose summary gives cash (100,000) but neither net liquidation nor initial margin. Margin may be at most 40% of equity.

{"director": {"version": 1, "rules": [
  {"rule": "max-margin", "max-fraction": 0.4}
]}}

The director cannot compute the margin or its denominator from cash, so it does not assume either is zero: the purchase is refused and nothing reaches the venue. A sell that reduces an existing long passes:

r: working 1
b: denied 1 director_input_missing: rule=1 requested=1 inputs=equity,margin

The summary’s account record names the missing fields, "unavailable": ["initial_margin", "net_liquidation"], beside its cash. A backtest always has equity and margin, so this example runs live only.

E6: trimming and a minimum quantity

Reserved notional is 70,000, the deployed cap 100,000 with trim, the next instrument costs 10,000 a unit, the price buffer is 0 and the minimum opening is 2.

{"director": {"version": 1, "price-buffer": 0, "min-quantity": 2, "rules": [
  {"rule": "max-deployed-value", "max-value": 100000, "action": "trim"}
]}}

A request for 5 would take the book to 120,000; 3 fit, exactly 100,000. In a second fixture the book already holds 90,000 and a request for 3 has room for 1, which is below the minimum of 2, so it is refused, citing the rule that limited it:

n: working 3 director_trimmed: requested=5 accepted=3 reducing=0 opening_requested=5 opening_accepted=3 cause=director_deployed_value rule=1
n: denied 3 director_below_minimum: rule=1 requested=3 permitted=1 min-quantity=2 limit=director_deployed_value

decision admission n/1 director director_trimmed accepted=3 r1:trim:deployed-value=70000>120000>100000/100000
decision admission n/1 director director_below_minimum accepted=0 r1:refuse:deployed-value=90000>120000/100000

E7: same-instant allocation

One holding at most, an empty book, no ranking. In one response the strategy sends a buy limit on A, then one on B.

{"director": {"version": 1, "rules": [{"rule": "max-open-positions", "max": 1}]}}

A takes the slot at once; B would make two and is refused. At the next instant A is still working, unfilled, and a new attempt on B is refused again: a working order holds its slot. A cancellation of A frees it only when it is confirmed: in a backtest the next B attempt passes; live, the attempt sent while the cancellation is pending is still refused, and the next one passes.

A: working 1
B: denied 1 director_open_positions: rule=1 requested=1 count=2 max=1
B attempt 2: denied 1 director_open_positions: rule=1 requested=1 count=2 max=1
B attempt 3 (after A's cancellation, backtest): working 1

decision admission B/1 director director_open_positions accepted=0 r1:refuse:open-positions=1>2/1
decision admission B/3 director director_accepted accepted=1 r1:pass:open-positions=0>1>1/1

E8: a continuous roll and group counting

Two contracts of an ES continuous series, in the group indices, with a group cap of one. The outgoing contract’s margin is 2,000 a contract, the incoming one’s 2,500, and margin is capped at 6,000.

{"director": {"version": 1,
  "groups": {"indices": {"symbols": ["@ES"]}},
  "rules": [
    {"rule": "max-group-positions", "group": "indices", "max": 1},
    {"rule": "max-margin", "max-value": 6000, "margin-buffer": 0}
  ]
}}

At the roll the close passes as a reduction. The reopen keeps the logical holding, so the group count stays one. It is judged as a transfer: the outgoing contracts’ 4,000 go with the close, so the reopen’s margin is 5,000, within 6,000 (charging both legs, 9,000, would wrongly refuse it). The protective orders the roll re-places follow. With a cap of 4,500 the reopen is refused; the close still happens, so the holding is left flat, and the re-placed protective orders are lost with it:

cap 6,000:
decision roll roll/@ES/2026-03-08T22:00:00Z/open/0 director director_accepted accepted=2 r2:pass:margin-value=4000>5000>5000/6000

cap 4,500:
roll/@ES/2026-03-08T22:00:00Z/open: denied 2 director_margin_limit: rule=2 requested=2 margin=5000 max=4500 bound=value; roll ESH26->ESM26: the position closed on ESH26 and was not reopened on ESM26
tp attempt 2: denied 2 roll ESH26->ESM26: replacement denied: the trade director refused the roll's reopen on ESM26: director_margin_limit: rule=2 requested=2 margin=5000 max=4500 bound=value

Live continuous runs are refused, so this example runs in a backtest only.

E9: held order rechecked

Equity is 100,000, 30,000 is deployed and the cap is half of equity. An entry worth 10,000 is held behind a pending cancellation; when it was first decided it took the book to 40%, so it was accepted.

{"director": {"version": 1, "price-buffer": 0, "rules": [
  {"rule": "max-deployed-percent", "max-fraction": 0.5}
]}}

Before its release equity falls to 70,000. The recheck removes the held order’s own 10,000 from the book, keeps the other 30,000, and finds 40,000 of 70,000, 57%: the order is refused and not sent. The recheck is a second decision record, linked to the first by prior, with the new equity version. In a second fixture equity falls only to 90,000: 40,000 of 90,000, 44%, passes; counting the held order twice, 50,000, would have refused it.

B attempt 2: held 100 held: waiting for the pending cancellation of order "B" attempt 1 (buy combo:B1@fut:XCME:ES:H26|S1@fut:XCME:ES:M26)
B attempt 2: denied 100 director_deployed_percent: rule=1 requested=100 deployed=40000 equity=70000 max-fraction=0.5

decision admission B/2 director director_accepted accepted=100 r1:pass:deployed-percent=30000>40000>40000/50000
snapshot release 2026-01-02T14:34:00Z equity=70000 version=15
decision release B/2 director director_deployed_percent accepted=0 prior=9 r1:refuse:deployed-percent=30000>40000/35000

The recheck takes no second entry-rate slot.

E10: metals with and without ranking

The metals group allows one holding. Gold arrives first with conviction 0.4, silver second with 0.9, each one contract, each within every other cap.

{"director": {"version": 1,
  "groups": {"metals": {"roots": ["fut:COMEX:GC", "fut:COMEX:SI"]}},
  "rules": [
    {"rule": "max-group-positions", "group": "metals", "max": 1},
    {"rule": "top-n-conviction", "max": 2}
  ]
}}

The test’s fixture names the roots fut:COMEX:GC and fut:COMEX:SI; in the marketfeed archive they are fut:XCEC:GC and fut:XCEC:SI, as elsewhere in this chapter. Without top-n-conviction the response order decides: gold takes the slot and silver is refused. With it, silver is decided first and takes the slot, and gold is refused by the group cap, not by the ranking, whose quota still has room:

without top-N: gold working 1; silver denied 1 director_group_positions: rule=1 group=metals requested=1 count=2 max=1
with top-N:    silver working 1; gold denied 1 director_group_positions: rule=1 group=metals requested=1 count=2 max=1

decision admission silver/1 director director_accepted accepted=1 r1:pass:group-positions=0>1>1/1 r2:pass
decision admission gold/1 director director_group_positions accepted=0 r1:refuse:group-positions=1>2/1

Ranking decides who gets scarce capacity; it does not enlarge it.

E11: VaR warm-up and a completed sample

A one-day 99% VaR over 250 scenarios, capped at 5,000, on one ES contract.

{"director": {"version": 1, "rules": [
  {"rule": "max-var", "max-value": 5000, "confidence": 0.99, "horizon-sessions": 1, "lookback": 250}
]}}

With only 200 complete scenarios the opening is refused as warming up; no zero-volatility assumption is made. With 250, the three largest losses are 4,900, 5,200 and 7,000; the quantile is the 248th smallest loss, 4,900, so the opening passes, and the tail mean is (4,900 + 5,200 + 7,000) / 3 = 5,700, above the cap: passing VaR is no promise about the loss. A history whose 248th loss is 5,100 is refused:

e: denied 1 director_warming_up: rule=1 requested=1 have=200 need=250
e: working 1
e: denied 1 director_var_limit: rule=1 requested=1 var=5100 before=0 tail-mean=5766.666666666667 max=5000

decision admission e/1 director director_accepted accepted=1 r1:pass:var=0>4900>4900/5000:tail=5700

E12: bracket parent and protective children

A flat account, one holding at most, a deployed cap of 50,000 and a price buffer of 25%. A buy limit for 2 ES (point value 50) at 300 carries a target at 340 and a stop at 280.

{"director": {"version": 1, "price-buffer": 0.25, "rules": [
  {"rule": "max-open-positions", "max": 1},
  {"rule": "max-deployed-value", "max-value": 50000}
]}}

The parent reserves one holding and its notional, 40,000 (its reserve price is the mark, 320, plus 25%). The children reserve nothing until the parent fills, and then only the filled quantity, as one alternative; they are not two extra shorts or two extra holdings. A second ES entry would take the book to 60,000 and an NQ entry would be a second holding:

p: working 2
e: denied 1 director_deployed_value: rule=2 requested=1 deployed=60000 max=50000
q: denied 1 director_open_positions: rule=1 requested=1 count=2 max=1

decision admission p/1 director director_accepted accepted=2 r1:pass:open-positions=0>1>1/1 r2:pass:deployed-value=0>40000>40000/50000

When the parent fills and the target fills, the stop is cancelled as its OCO alternative. A live run refuses the bracket (director_invalid_order: the live venue takes no combo or attached orders (GLE-532)) and, reading no marks, refuses the other entries with director_input_missing: rule=2 requested=1 inputs=mark:ES.

The strategy’s side

A strategy needs no change to run under a director: refusals arrive as the Denied updates lifetime v2 already sends, and a trim as the order’s normal acceptance. A strategy that wants the director to rank its entries sets a conviction on them, and one that wants to react to a refusal reads the update’s reason.

Order conviction

An order can carry the strategy’s conviction in it: an optional, signed number in [-1, 1] that top-n-conviction uses to rank the orders of one decision instant, taking the strongest first. It is the Conviction *float64 field of Order. nil, the default, means none, which is distinct from an explicit zero.

A strategy that sets it declares algolang.CapabilityOrderConviction, normally in SupportedCapabilities, and sets the field only when ctx.OrderConviction() reports the capability accepted:

func (s *Strat) SupportedCapabilities() []string {
    return []string{algolang.CapabilityLifetimeV2, algolang.CapabilityOrderConviction}
}

func (s *Strat) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
    o := algolang.MarketBuy(bar.Symbol, 1)
    if ctx.OrderConviction() {
        c := 0.6 // positive: a buy; a sell's conviction is zero or negative
        o.Conviction = &c
    }
    return []algolang.Order{o}, nil
}

The engine offers order-conviction-v1 on a lifetime-v2 run whose strategy runs on the pb or csv2 wire (Capability negotiation), with or without a director. A strategy that does not accept it keeps its InitAck byte for byte.

  • Values. A conviction is finite, in [-1, 1], non-negative on a buy and non-positive on a sell; zero, of either sign, is valid on both sides. Ranking uses its absolute value. It is the strategy’s own measure: the director does not calibrate it.
  • The SDK checks before sending. An order carrying a conviction on a run that did not accept the capability, or carrying an invalid one, refuses the run before anything of that decision is sent (Error code strategy_error), naming the order. The value is reported as it is, not normalised, and it is not silently dropped. Attached orders are checked too.
  • On the wire. On pb it is Order.signal.strength, with zero a set, empty signal. On csv2 it is an eleventh field on the order line: order,LABEL,ATTEMPT,SYMBOL,SIDE,QTY,TYPE,TIF,PRICE,STOP_PRICE,TRAIL[,CONVICTION]. An order without one is the ten-field line it was before. stdio-csv-v1 cannot carry it and refuses an order that has one.
  • The engine checks negotiation. An order (or an attached order) that arrives with a conviction from a strategy that did not accept the capability is denied before the venue, with the reason an order's conviction needs the order-conviction-v1 capability, which the strategy did not accept (GLE-532). Range and sign are the director’s to judge: an order that bypasses the SDK’s check with a bad value is refused with director_invalid_conviction: conviction=-0.5 side=buy.
  • Recorded. The lifecycle trace’s submit record carries conviction when the order has one (Order lifecycle trace), and so does the director’s decision record.

Handling refusals and trims

The rankedcross demo, strategies/demo/go/rankedcross, trades a moving-average crossover on every symbol of its run, decides once per close so that every entry of that close is in one response, gives each entry a conviction, and logs what the director did. make build builds it as bin/rankedcross, and the examples in this chapter that are not smacross’s are its runs. This is its source:

// Command rankedcross trades a moving-average crossover on every symbol of
// its run and ranks one close's entries by conviction for the trade director.
// It is the demo of the manual's Trade director chapter.
package main

import (
    "fmt"
    "math"
    "os"
    "strings")

import algolang
import indicators

type RankedCross struct {
    Fast int `algolang:"default=10,min=2,max=200,desc=Fast SMA period in bars"`
    Slow int `algolang:"default=40,min=3,max=400,desc=Slow SMA period in bars"`
    Qty  int `algolang:"default=1,min=1,max=100000,desc=Units per entry"`

    symbols []string
}

func (s *RankedCross) Name() string { return "Ranked crossover" }
func (s *RankedCross) Description() string {
    return "SMA crossover on every symbol, ranked by conviction"
}
func (s *RankedCross) Lookback() int { return s.Slow + 1 }

// SupportedCapabilities asks for order lifetime v2, which the director
// needs, and for conviction on orders.
func (s *RankedCross) SupportedCapabilities() []string {
    return []string{algolang.CapabilityLifetimeV2, algolang.CapabilityOrderConviction}
}

func (s *RankedCross) OnInit(ctx *algolang.Context, cfg algolang.InitConfig) error {
    for _, in := range cfg.Instruments {
        s.symbols = append(s.symbols, in.Symbol)
    }
    return nil
}

// OnBar decides once per close, on the primary series' bar, so every
// symbol's entry of that close is in one response: the director ranks
// them together.
func (s *RankedCross) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
    if !ctx.IsPrimary(bar) {
        return nil, nil
    }
    var orders []algolang.Order
    for _, sym := range s.symbols {
        series := ctx.Series(sym)
        if series.Len() < s.Slow+1 || !series.Fresh() {
            continue
        }
        closes := indicators.Closes(series)
        fast, slow := closes.SMA(s.Fast), closes.SMA(s.Slow)
        // Conviction: the fast average's distance from the slow one,
        // in units of 5%, clipped to [0, 1].
        strength := math.Min(1, math.Abs(fast.Value()-slow.Value())/slow.Value()/0.05)
        pos := ctx.Position(sym).Quantity
        var o algolang.Order
        switch {
        case fast.CrossesAbove(slow) && pos <= 0:
            o = algolang.MarketBuy(sym, int64(s.Qty)-pos)
        case fast.CrossesBelow(slow) && pos >= 0:
            o = algolang.MarketSell(sym, int64(s.Qty)+pos)
            strength = -strength // a sell's conviction is zero or negative
        default:
            continue
        }
        o.ClientID = fmt.Sprintf("%s_%d", sym, bar.CloseTime.Unix())
        if ctx.OrderConviction() {
            o.Conviction = &strength
        }
        orders = append(orders, o)
    }
    return orders, nil
}

// OnOrderUpdate reads the director's answer. A refusal is Denied with a
// reason that starts with a director_* token; a trim is the ordinary
// acceptance update, its Quantity the accepted quantity and its reason
// led by director_trimmed.
func (s *RankedCross) OnOrderUpdate(ctx *algolang.Context, u algolang.OrderUpdate) {
    switch {
    case u.Status == algolang.OrderStatusDenied && strings.HasPrefix(u.Reason, "director_"):
        code, _, _ := strings.Cut(u.Reason, ":")
        ctx.Logf("refused %s %s: %s", u.ClientID, u.Symbol, code)
    case strings.HasPrefix(u.Reason, algolang.DirectorTrimmed+":"):
        // The working view already holds the accepted quantity.
        ctx.Logf("trimmed %s %s to %d", u.ClientID, u.Symbol, u.Quantity)
    }
}

func main() {
    if err := algolang.Run(&RankedCross{}); err != nil {
        fmt.Fprintln(os.Stderr, "rankedcross:", err)
        os.Exit(1)
    }
}

The two import lines use the manual’s shorthand for the SDK and the indicator bridge (The smallest useful strategy); the demo’s file imports the packages by their paths. Run over several futures:

bin/algo run --strategy bin/rankedcross --interval 1d \
  --symbols fut:XCME:ES,fut:XCME:NQ,fut:XCEC:GC,fut:XCEC:SI,fut:XNYM:CL,fut:XCBT:ZN \
  --start 2024-01-01T00:00:00Z --end 2026-01-01T00:00:00Z \
  --director director.json --results-dir results

with the director of max-group-positions, it logs each refusal; with that of max-deployed-percent, on ES alone, each trim:

[rankedcross] INFO: refused fut:XCEC:SI_1719262800 fut:XCEC:SI: director_group_positions
[rankedcross] INFO: trimmed fut:XCME:ES_1704751200 fut:XCME:ES to 4

A few points the code relies on:

  • Match on the token, not the text. Only the leading token, up to the first :, is stable; the rest of the reason is for people. The tokens are the algolang.Director* constants (algolang.DirectorGroupPositions, algolang.DirectorTrimmed and the others); Troubleshooting lists them. Not every Denied is the director’s: the race guard, the admission limits and a live transport have their own tokens (duplicate_live_label, cap_*, transport_overload).

  • A refused order is gone. It did not reach the venue, no OrderCancel{Rejected} follows, and the director does not keep it. To try again, send a new attempt at a later decision; sending the same order under the same conditions gets the same answer. An order the director accepted can still be refused by the venue, and a venue’s denial is followed by one rejection cancel, as without a director.

  • A trimmed order is a live order. It works at the accepted quantity, fills of that quantity follow, and nothing is filled or cancelled for the discarded part. A trim arrives on the first update the strategy can see (Held, Submitting, Working or SubmissionUnknown; a live submission the transport queues is Held), with any reason of the update’s own after "; ":

    director_trimmed: requested=5 accepted=3 reducing=3 opening_requested=2 opening_accepted=0 cause=director_directional_limit rule=1; held: waiting for the pending cancellation of order "a" attempt 1 (sell AAPL)
    
  • A state change is silent. A drawdown stop, a ladder band or the reducing state reaches the strategy only through its later orders’ refusals; its record is in the log.

How a trimmed quantity is adopted

The SDK’s working view adopts the accepted quantity from the trim’s update, on either wire, before OnOrderUpdate runs: ctx.WorkingOrders() and ctx.Exposure(symbol) then show the order with Order.Quantity equal to the accepted quantity, and keep it on the updates after it, which carry no trim. A held or queued order lowered at its release or dequeue gets the same reason on the update that sends it, and the view moves again. So a strategy that sizes its next order from its working view and its position, as the crossover above does with ctx.Position, needs no bookkeeping of its own for trims: the reversal trimmed to its closing part leaves the position flat, and the next cross sends a plain entry.

Strategy feedback

The strategy learns of the director’s decisions through the update the v2 lifecycle already sends; no wire field is added for them. On the pb wire they are the OrderUpdate’s status, quantity, remaining_quantity and reason; on stdio-csv-v2 the update line’s STATUS, QTY, REMAINING and REASON fields, the reason escaped as every field is (% as %25, a comma as %2C, LF and CR). Both SDK wires carry the same text, and the conviction travels on the order: pb’s Order.signal, csv2’s eleventh order field, both behind the order-conviction-v1 capability.

These csv2 lines were recorded from the SMA-crossover demo on a one-minute AAPL fixture with a long-only director ({"rule": "max-directional-exposure", "symbol": "AAPL", "max-short-quantity": 0}) and --strategy-protocol csv. The engine offers both capabilities:

init,run-AAPL-1m,2026-01-02T14:30:00.000000000Z,2026-01-02T15:30:00.000000000Z,[AAPL:XNAS:equity:0.01:1:USD],[Fast:2|Slow:3],[lifetime-v2|order-conviction-v1],256

A refusal is an update with STATUS denied and nothing remaining; a trim is the order’s working update with the accepted QTY:

update,smacross_1767364500000000000,1,denied,AAPL,1,0,0,2026-01-02T14:35:00.000000000Z,,,,,director_directional_limit: rule=1 requested=1 dimension=short-quantity measure=1 max=0
update,smacross_1767364860000000000,1,working,AAPL,1,0,1,2026-01-02T14:41:00.000000000Z,sim-2,,,,director_trimmed: requested=2 accepted=1 reducing=1 opening_requested=1 opening_accepted=0 cause=director_directional_limit rule=1

The fields are update,LABEL,ATTEMPT,STATUS,SYMBOL,QTY,FILLED,REMAINING,TS,ORDER_REF,PARENT,OWNER,BROKER_CODE,REASON. The rankedcross demo, run the same way, accepts the conviction capability in its init_ack and sends the conviction as the eleventh order field:

init_ack,4,[],false,false,Ranked crossover,SMA crossover on every symbol; ranked by conviction,[Fast:int:10:2..200..|Slow:int:40:3..400..|Qty:int:1:1..100000..],[lifetime-v2|order-conviction-v1]
order,AAPL_1767364500,1,AAPL,sell,1,market,day,0,0,0,-0.0495049504950495
order,AAPL_1767364680,1,AAPL,buy,1,market,day,0,0,0,0.04975124378109452

The csv wires carry plain symbols only, so a futures strategy speaks pb.

Python and other languages

The repository has no Python SDK: its Python strategies speak stdio-csv-v2 directly (Other languages). Such a strategy uses the director as any csv2 strategy does: it reads the update line’s REASON (decoding the field first) and matches the leading token; to send a conviction it lists order-conviction-v1 in its init_ack capabilities beside lifetime-v2, when the engine’s init line offers it, and appends the value to its order lines.

The decision log

The decision log is the director’s audit: JSON lines of schema algolang.director/1, one record per line, each ending in LF, appended and not rewritten. Every order the director sees gets a record, appended to the log before anything it decided is sent: whichever stage refused it, the engine’s checks, the race guard or the director, it has one decision record, and every refusal after the director accepted (the admission limits’, the guard’s, the venue’s) an outcome record linked to it. Each batch starts with a snapshot record (the positions, reservations and readings it was decided against). A recheck is a new decision record whose prior is the decision it rechecks. A live run adds link records, a change of a drawdown or reducing state is a state record (director_state_changed), an owner control event a control record, a book event’s breach a breach record, a live account summary an account record and a live sidecar’s state a checkpoint record.

Where the log is written

  • Backtest: the log is director-decisions.jsonl in the run’s results directory. It is written under a temporary name and renamed when the run completes, ending with a footer record that holds the record count and the SHA-256 of the bytes before it, so an aborted run’s log cannot be mistaken for a complete one. Without --results-dir, --director-log PATH is required. If the log cannot be written, the run fails.
  • Live: the log is <ledger>.director.jsonl beside the ledger, the run’s sidecar, appended and synced before any order of the batch is sent. Without a ledger, --director-log is required. If an append fails, nothing the batch decided becomes visible: every opening part is refused with director_log_unavailable, the reducing parts are still sent, and the director latches its reducing state, whose state record is appended once the log takes a write again, ahead of anything later. While latched, every opening is refused with director_reducing (rule 0) until the owner’s resume of rule 0 clears it. An append that fails is cut back to where it began, so it leaves no partial line.

With --config, --director-log must contain {run}, which each run replaces with its ID, so no two runs share one log.

Keys and their order

Every record has schema, kind and sequence (1 for the first record of the log, +1 per record; a live sidecar continues after a restart). Every record but a footer has run_id and owner_id (the instance). The records of a batch also have event_time (the decision instant, an engine event time in UTC as RFC 3339; no wall-clock time is written), instant_sequence (+1 per batch), phase and, after the batch’s snapshot record, snapshot (that record’s sequence). A key that does not apply to a record is omitted. The keys of a record are written in one fixed order, the order of this table, so two runs that decide alike write the same bytes: a backtest repeated writes its log byte for byte.

KeyRecordsValue
schema, kind, sequenceevery onealgolang.director/1; the kind below; the record’s number
event_time, instant_sequence, phasea batch’sthe instant, the batch’s number and its phase (below)
run_id, owner_idall but footerthe run’s ID and the instance’s label
snapshotdecision, outcome, breach, state, control, accountthe sequence of the batch’s snapshot record; absent when the batch has none
priordecision, outcomethe decision a recheck rechecks, or an outcome refuses; 0, omitted, for a first decision
candidate_sequencedecisionthe order’s index in the strategy’s response (a recheck’s, in its cohort)
client_id, attemptdecision, outcome, linkthe order’s label and attempt
canonical_idlinkthe ledger’s canonical id of the attempt (live)
origindecisionstrategy, or engine for a roll’s leg
stagedecision, outcomethe stage that decided it (below)
symboldecision, outcome, linkthe order’s symbol
side, physical, limit_price, stop_pricea director decisionthe order as the director valued it: its side, the physical row it reserves on when not symbol (a continuous series’ active contract), and its limit and stop prices in that row’s venue frame, each when set
groups, groups_unknowna director decisionthe groups of the holding the order lands in, ascending; groups_unknown when a membership could not be established
base_currencysnapshot, decisionthe account’s currency
requested_quantity, reducing_quantity, accepted_quantitydecision (outcome: requested only)the quantity asked for, the part that reduces a position (absent when the director did not classify it) and the quantity accepted; 0 accepted is a refusal
convictiondecisionthe order’s conviction, when it has one
rankdecisionits rank under top-n-conviction, 1-based; absent without one
reason_code, cause_codedecision (outcome, breach, state, control: reason_code)the leading token of the reason (director_accepted, director_trimmed, a rule’s code, a guard, policy or venue token, director_state_changed, a control_* code); the binding cause of a trim or refusal
ruledecision, breach, state, controlthe binding rule’s ordinal (0, omitted, when none binds; rule 0 is also the engine’s reducing latch in a state record)
dimension, measure, cap, measure_value, cap_valuebreachwhat a breach measures, a count’s measure and cap, or a monetary one’s in the base currency
reasondecision, outcome, breach, state, controlthe text the strategy or the operator reads
rule_resultsa director decisioneach rule’s part in it (below)
state, from_state, rule_state, control, eventstate, controla state record’s condition after and before (active, or the code an opening gets) and its rule’s state after; a control event’s sequence; a control record’s outcome (applied, refused) and its event as the control file gave it
checkpointcheckpointthe director’s persisted state (live)
positions, reservationssnapshotthe book: owner, symbol, quantity; id, owner, symbol, side, remaining
instruments, rates, stale, equity_at, margin_atsnapshotthe readings and their versions (below)
equity, equity_versionsnapshoton a run that reads equity: the marked equity and the number of equity observations so far
account_version, margin, supplementssnapshoton a live run that reads the account: the summary’s version, its initial margin and the fill supplements reserved besides
daily, volatilitysnapshotwith a daily participation bound: date, charges, unknown; with volatility-multiplier: each overlay’s figures
accountaccountone live account summary (version, request, amounts, missing fields, receipt time)
config_hashsnapshot, decision, breach, state, control, checkpointthe configuration’s hash, which rule ordinals refer to
records, sha256footerthe count of the records before it and the SHA-256 of their bytes

Record kinds, phases and stages

Kinds.

  • snapshot opens a batch: the book it was decided against, and its readings.
  • decision is one candidate’s decision, or a recheck of one.
  • outcome is a refusal after the director accepted: the guard’s, the admission policy’s, the transport’s, the venue’s, or a write-ahead record the log could not take; and, in phase recovery, an intent a live restart releases.
  • link joins a live attempt to its ledger’s canonical id.
  • breach is a cap a corporate action or a contract expiry’s settlement left the book above: a count, group, directional or monetary cap, the VaR (dimension var) or a directional rule’s risk (long-risk, short-risk). Nothing is refused there.
  • state is one transition of a drawdown, reducing or latch state (director_state_changed).
  • control is one owner control event as the engine took it.
  • checkpoint is a live sidecar’s persisted state.
  • account is one live account summary the engine accepted.
  • footer closes a complete backtest log.

Phases. admission (a strategy response’s new orders), release (a cohort of held orders rechecked as the guard releases them), dequeue (a queued live submission rechecked as the transport sends it), hold (held orders ended without a release: by the guard, a hold timeout or a failed cancellation, or by the engine, a market-on-close order released at a timer’s instant; when a cohort is released at the same decision these records join its batch), shutdown (the held and queued orders the run’s end refuses), roll (a continuous roll’s legs), corporate_action and expiry (breaches), state, control, account and recovery.

Stages. A decision record’s stage is the first stage that decided the order: engine (the engine’s checks before a batch: a market-on-close order at a timer’s instant, a combo or a conviction the run cannot take, a composite order the director does not support, an order it cannot evaluate), guard (the race guard’s refusal) or director (the director’s own decision, accepted, trimmed or refused). An outcome record’s is the stage that refused the order after the director accepted it: guard, policy (the admission limits and the rate budget), log (director_log_unavailable), transport (a live transport’s full queue, or the run’s end while it was queued), venue, or engine for a held market-on-close order at a timer’s instant and a restart’s release.

Rule results

A director decision’s rule_results list every rule of the run’s configuration in order, the engine latch first when it refused. Each has ordinal, rule (the kind, or engine-latch for rule 0) and status: pass, trim, refuse, or not_evaluated for a rule that did not judge the order (a rule after a refusal, the selection rule in a recheck, the rules a roll’s reopen skips, and every rule for a reduction, which passes them all). A rule that judged it also has requested (the opening the rules before it left) and accepted (what it left), and, when it lowered the opening, code and, for a refusal, detail, the reason’s text after the token. Its measures are in dimensions, each name, before (in the book the rule compares with), requested_after (with the opening it was given), accepted_after (with the opening it left, absent when none) and limit:

RuleDimensions
max-open-positions, max-group-positionsopen-positions, group-positions: the counts
max-directional-exposureeach count and quantity cap (long-count, short-quantity, …), and long-risk, short-risk in the base currency when its history is available
max-deployed-value, max-deployed-percentdeployed-value or deployed-percent: the deployed notional against max-value or max-fraction × E
max-marginmargin-value, margin-fraction: the buffered reserved initial margin against each bound set; and the rule result’s maintenance, the reserved maintenance margin after the requested opening, logged and not compared (a backtest’s, when every row has a requirement; a live run’s broker reports its own in its account records)
max-varvar: the VaR against max-value; and the rule result’s tail_mean

A rule whose inputs were missing measures nothing. A reduction’s max-var result, though not evaluated, gives the VaR before and after it, so a reduction that removes a hedge shows the risk it adds; a roll’s close leg, which is not evaluated either, gives the same results against the book at the cut.

Readings

A snapshot record’s instruments are the instruments of the batch that have a reading, by symbol: symbol, logical (a continuous series’), currency, point_value, mark and mark_at (the mark’s event time, its version), prev_mark, initial_margin, maintenance_margin and margin_currency, and open_interest, each only when known. rates are the rates into the base currency, by currency (from, rate, as_of). stale names the readings the engine withheld as older than their freshness limit, and equity_at and margin_at are the times of the equity and margin readings. A configuration of count rules alone reads no instrument facts, so its snapshots carry none.

Strictness

A consumer can hold a line to the schema with director.ParseRecord: the schema algolang.director/1, a known kind, a positive sequence, no repeated key, no unknown key at any level, and only the keys its kind carries. Within /1 the log only adds keys and kinds.

Every record kind

Each record below is one line in its log; most are shown whole, from the runs this chapter describes.

snapshot, opening the quick start’s second batch: the book held a long of one ESH25, and the director read the contract and the continuous symbol it was traded under:

{"schema":"algolang.director/1","kind":"snapshot","sequence":3,"event_time":"2025-01-09T14:30:00Z","instant_sequence":2,"phase":"admission","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","base_currency":"USD","positions":[{"owner":"fut:XCME:ES","symbol":"ESH25","quantity":1}],"instruments":[{"symbol":"ESH25","logical":"fut:XCME:ES","currency":"USD","point_value":50,"mark":5944.75,"initial_margin":46080,"maintenance_margin":33225,"margin_currency":"USD"},{"symbol":"fut:XCME:ES","currency":"USD","point_value":50,"mark":5944.75,"initial_margin":46080,"maintenance_margin":33225,"margin_currency":"USD"}],"config_hash":"80cf3e43bbf85bb57c517182e25050493c4f8c744b6b6779092f44ea0ff6e768"}

decision, a refusal from the same run (the trimmed decision is in the quick start): the buy found the entry-rate budget spent after rule 1 passed it:

{"schema":"algolang.director/1","kind":"decision","sequence":6,"event_time":"2025-01-16T22:00:00Z","instant_sequence":3,"phase":"admission","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","snapshot":5,"candidate_sequence":0,"client_id":"smacross_1737064800000000000","attempt":1,"origin":"strategy","stage":"director","symbol":"fut:XCME:ES","side":"buy","physical":"ESH25","base_currency":"USD","requested_quantity":1,"reducing_quantity":0,"accepted_quantity":0,"reason_code":"director_entry_rate","cause_code":"director_entry_rate","rule":2,"reason":"director_entry_rate: rule=2 requested=1 count=1 max=1 window=720h0m0s","rule_results":[{"ordinal":1,"rule":"max-directional-exposure","status":"pass","requested":1,"accepted":1,"dimensions":[{"name":"short-quantity","before":0,"requested_after":0,"accepted_after":0,"limit":0}]},{"ordinal":2,"rule":"entry-rate","status":"refuse","requested":1,"accepted":0,"code":"director_entry_rate","detail":"rule=2 requested=1 count=1 max=1 window=720h0m0s"}],"config_hash":"80cf3e43bbf85bb57c517182e25050493c4f8c744b6b6779092f44ea0ff6e768"}

decision, phase roll: the same run’s June roll. The close passes unevaluated; the reopen, of no more than the close, skips the rules a transfer skips:

{"schema":"algolang.director/1","kind":"decision","sequence":28,"event_time":"2025-06-15T22:00:00Z","instant_sequence":14,"phase":"roll","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","snapshot":27,"candidate_sequence":0,"client_id":"roll/fut:XCME:ES/2025-06-15T22:00:00Z/close","origin":"engine","stage":"director","symbol":"ESM25","side":"sell","base_currency":"USD","requested_quantity":1,"reducing_quantity":1,"accepted_quantity":1,"reason_code":"director_accepted","reason":"roll ESM25->ESU25: the close passes as a reduction and is not evaluated","rule_results":[{"ordinal":1,"rule":"max-directional-exposure","status":"not_evaluated"},{"ordinal":2,"rule":"entry-rate","status":"not_evaluated"}],"config_hash":"80cf3e43bbf85bb57c517182e25050493c4f8c744b6b6779092f44ea0ff6e768"}
{"schema":"algolang.director/1","kind":"decision","sequence":29,"event_time":"2025-06-15T22:00:00Z","instant_sequence":14,"phase":"roll","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","snapshot":27,"candidate_sequence":1,"client_id":"roll/fut:XCME:ES/2025-06-15T22:00:00Z/open","origin":"engine","stage":"director","symbol":"ESU25","side":"buy","base_currency":"USD","requested_quantity":1,"reducing_quantity":0,"accepted_quantity":1,"reason_code":"director_accepted","rule_results":[{"ordinal":1,"rule":"max-directional-exposure","status":"not_evaluated"},{"ordinal":2,"rule":"entry-rate","status":"not_evaluated"}],"config_hash":"80cf3e43bbf85bb57c517182e25050493c4f8c744b6b6779092f44ea0ff6e768"}

A recheck is a decision in phase release or dequeue whose prior names the first decision (E9: decision release B/2 director director_deployed_percent accepted=0 prior=9).

outcome: smacross on ES with max-open-positions 1 and --max-order-quantity 1. The director accepted a reversal of 2; the admission limit refused it, so the order ended Denied with the policy’s token:

{"schema":"algolang.director/1","kind":"outcome","sequence":5,"event_time":"2025-01-09T14:30:00Z","instant_sequence":2,"phase":"admission","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","snapshot":3,"prior":4,"client_id":"smacross_1736433000000000000","attempt":1,"stage":"policy","symbol":"fut:XCME:ES","requested_quantity":2,"reason_code":"cap_quantity","reason":"cap_quantity: quantity 2 exceeds the limit of 1 per order"}

In phase recovery, an outcome is an attempt a live restart released because it was not transmitted (reason code recovery_released).

link, from a live run on the IBKR emulator: the accepted attempt’s canonical id in the ledger, written after the ledger record and before the broker call:

{"schema":"algolang.director/1","kind":"link","sequence":4,"event_time":"2026-01-02T14:32:00Z","run_id":"run-AAPL-1m","owner_id":"AAPL","client_id":"step2","attempt":1,"canonical_id":"algo-2p7547vs66-1","symbol":"AAPL"}

breach, phase corporate_action: the XLE split of Corporate actions and expiries doubled a long of 100 above a cap of 150:

{"schema":"algolang.director/1","kind":"breach","sequence":5,"event_time":"2025-12-04T00:00:00Z","instant_sequence":2,"phase":"corporate_action","run_id":"run-eq:ARCX:XLE-1d","owner_id":"eq:ARCX:XLE","snapshot":4,"reason_code":"director_directional_limit","rule":1,"dimension":"long-quantity","measure":200,"cap":150,"reason":"director_directional_limit: rule=1 dimension=long-quantity measure=200 max=150 after corporate action CA_SPLIT on eq:ARCX:XLE at 2025-12-05T00:00:00.000000000Z","config_hash":"6f20b40695f73c68982e912d1601755d98ee3ade952269bebe8c867570fd6bb8"}

A monetary cap’s breach gives measure_value and cap_value instead of measure and cap.

state, phase state: the peak stop latching, with the rule’s state after the transition in rule_state:

{"schema":"algolang.director/1","kind":"state","sequence":21,"event_time":"2025-03-31T20:59:59Z","instant_sequence":11,"phase":"state","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","reason_code":"director_state_changed","rule":1,"reason":"director_state_changed: rule=1 kind=max-peak-drawdown from=active to=director_peak_drawdown","state":"director_peak_drawdown","from_state":"active","rule_state":{"rule":1,"kind":"max-peak-drawdown","hwm":128687.5,"period":"","baseline":0,"has_baseline":false,"latched":true,"band":0,"reducing":false},"config_hash":"7659550ee1d18fba46417411836d5fa7b6b3f937992ede6034764b6b6f4a9ef6"}

A transition an owner event caused is in phase control and carries the event’s sequence as control.

control: the owner’s reset-peak of that stop, applied, with the event as the control file gave it:

{"schema":"algolang.director/1","kind":"control","sequence":32,"event_time":"2025-06-02T21:00:00Z","instant_sequence":17,"phase":"control","run_id":"run-fut-xcme-es-1d","owner_id":"fut:XCME:ES","rule":1,"reason":"control applied: sequence=1 action=reset-peak rule=1","state":"applied","control":1,"event":{"sequence":1,"at":"2025-06-02T00:00:00Z","config_hash":"7659550ee1d18fba46417411836d5fa7b6b3f937992ede6034764b6b6f4a9ef6","action":"reset-peak","rule":1,"reason":"drawdown reviewed; new high-water mark from today"},"config_hash":"7659550ee1d18fba46417411836d5fa7b6b3f937992ede6034764b6b6f4a9ef6"}

A refused event has "state": "refused" and its control_* token as reason_code (Owner control events).

checkpoint, from the live run: the director’s whole state after an opening the entry-rate rule counted, written in the same synced append as the decision (Restart and recovery):

{"schema":"algolang.director/1","kind":"checkpoint","sequence":3,"run_id":"run-AAPL-1m","owner_id":"AAPL","checkpoint":{"version":1,"config_hash":"2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644","equity":{"config_hash":"2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644","last":0,"last_at":"0001-01-01T00:00:00Z","last_anchored":true,"at":"0001-01-01T00:00:00Z","date":"","observations":0,"equity_missing":true,"date_missing":true,"control":0,"control_at":"0001-01-01T00:00:00Z","engine_reducing":false,"rules":null},"rate":{"config_hash":"2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644","since":"0001-01-01T00:00:00Z","at":"2026-01-02T14:32:00Z","entries":["2026-01-02T14:32:00Z"]}},"config_hash":"2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644"}

account, from a live run on the emulator with max-margin: the summary accepted at the bar’s close, with its receipt time kept for replay (The live account):

{"schema":"algolang.director/1","kind":"account","sequence":1,"event_time":"2026-01-02T15:00:00Z","instant_sequence":1,"phase":"account","run_id":"run-AAPL-1m","owner_id":"AAPL","account":{"version":1,"request":1,"account":"DU1234567","currency":"USD","net_liquidation":99997.97,"initial_margin":0,"maintenance_margin":0,"cash":99997.97,"received":"2026-10-19T10:11:03.109819Z"}}

footer, closing the quick start’s log:

{"schema":"algolang.director/1","kind":"footer","sequence":34,"records":33,"sha256":"3dfca806c09c91f8bb8d8625e3ae64536f3b284f9d814e4391d79b414e5837c3"}

The hash is of every byte before the footer line, so it can be checked with standard tools:

$ sed '$d' director-decisions.jsonl | shasum -a 256
3dfca806c09c91f8bb8d8625e3ae64536f3b284f9d814e4391d79b414e5837c3  -
$ tail -n 1 director-decisions.jsonl | jq -r .sha256
3dfca806c09c91f8bb8d8625e3ae64536f3b284f9d814e4391d79b414e5837c3

Reading a log with jq

Each recipe below ran on a log from this chapter.

Refusals and trims by binding rule (the quick start):

$ jq -r 'select(.kind == "decision" and .accepted_quantity < .requested_quantity)
         | "\(.rule) \(.cause_code)"' director-decisions.jsonl | sort | uniq -c
   7 1 director_directional_limit
   3 2 director_entry_rate

Every reason the strategy was refused with:

$ jq -r 'select(.kind == "decision" and .accepted_quantity == 0) | .reason' \
    director-decisions.jsonl | sort | uniq -c
   2 director_directional_limit: rule=1 requested=1 dimension=short-quantity measure=1 max=0
   3 director_entry_rate: rule=2 requested=1 count=1 max=1 window=720h0m0s

One order’s history, its decision and the outcome that refused it after the director accepted (the --max-order-quantity run above):

$ jq -c 'select(.client_id == "smacross_1736433000000000000")
         | {sequence, kind, phase, stage, requested_quantity, accepted_quantity, reason_code, prior}' \
    director-decisions.jsonl
{"sequence":4,"kind":"decision","phase":"admission","stage":"director","requested_quantity":2,"accepted_quantity":2,"reason_code":"director_accepted","prior":null}
{"sequence":5,"kind":"outcome","phase":"admission","stage":"policy","requested_quantity":2,"accepted_quantity":null,"reason_code":"cap_quantity","prior":4}

The binding measures of each VaR refusal (the max-var run):

$ jq -c 'select(.kind == "decision" and .reason_code == "director_var_limit")
         | .rule_results[] | select(.status == "refuse") | .dimensions' director-decisions.jsonl | head -2
[{"name":"var","before":0,"requested_after":6325,"limit":6000}]
[{"name":"var","before":0,"requested_after":7200,"limit":6000}]

Every state change (the peak-drawdown run):

$ jq -r 'select(.kind == "state") | "\(.event_time) \(.reason)"' director-decisions.jsonl
2025-01-02T21:59:59Z director_state_changed: rule=1 kind=max-peak-drawdown from=director_input_missing to=active
2025-03-31T20:59:59Z director_state_changed: rule=1 kind=max-peak-drawdown from=active to=director_peak_drawdown

The lifecycle trace

With a director, a backtest’s --lifecycle-json holds a director_decision record for each director decision (its first or a recheck) and records every other refusal before the venue (the engine’s, the guard’s, the admission policy’s, the log’s, the transport’s) as a submit with status rejected, linked by director_record to the log record that refused it (The lifecycle trace schema). No rejection cancel follows a refusal before the venue; a venue’s denial is followed by one, as without a director.

When the log cannot be written

A backtest’s run fails at the batch that could not be appended, before any of it is submitted; a log whose directory cannot be made refuses the run before the strategy starts. A live run refuses the batch’s opening parts and latches its reducing state:

director_log_unavailable: requested=1 the decision log could not be written

Each reduction that proceeds has its decision written to stderr and with its attempt’s ledger order record (director), so a full disk cannot trap an exit and leave it unrecorded. When the append that fails is an opening’s entry-rate or participation record, written after its decision was logged, the opening part is refused and the reduction proceeds the same way, the refusal an outcome (stage log) linked to the decision once the log takes a write again. A later decision the log takes replaces an attempt’s note.

Owner control events

--director-control FILE names a file of owner control events, which change the director’s state explicitly. They are how an owner pauses entries, resumes them, and accepts a drawdown:

ActionTarget ruleEffect
resume0, the director’s own reducing latch, or a reduce-only-state ruleBack to active. Only a resume of rule 0 clears the latch that an unwritable log or a failed restore set.
reducea reduce-only-state ruleInto reducing state.
reset-peaka max-peak-drawdown ruleClears its stop and sets its high-water mark to the current equity.
reset-laddera drawdown-ladder ruleClears its band and sets its high-water mark to the current equity.

The control file

The file holds one JSON object per event, conventionally one per line, in the order the owner issued them. Each object has exactly these keys:

{"sequence": 1, "at": "2025-02-03T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "reduce", "rule": 1, "reason": "risk review: entries paused"}
{"sequence": 2, "at": "2025-03-03T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "resume", "rule": 1, "reason": "review complete"}

sequence is the owner’s number for the event, at its effective time (RFC 3339), config_hash the hash of the configuration the owner means (a run’s result.json records it as Director, and every decision and state record as config_hash), rule the target’s position in the rules array (from 1, or 0 for the engine’s latch), and reason the owner’s reason. A key that is unknown, repeated or missing, a value of the wrong type or a time that does not parse refuses the run. To get the hash, run once, or read Director from an earlier run’s result.json:

jq -r .Director results/run-fut-xcme-es-1d/result.json

How events are applied

The run reads the file once, when it starts. Each event is applied at the first event boundary at or after its effective time (a bar’s close, a timer’s instant, a roll’s cut), after the boundary’s equity observation, so a reset takes the boundary’s equity, and before the boundary’s decision. Events due at one boundary are taken in the file’s order. Each is checked in this order and refused at the first check it fails:

  1. malformed: a sequence below 1, no effective time, no hash, a blank reason or an unknown action (control_malformed);
  2. a hash other than the configuration’s (control_hash_mismatch);
  3. a sequence not above the last applied event’s (control_out_of_sequence);
  4. an effective time before the last applied event’s (control_out_of_order);
  5. an effective time after the boundary (control_not_effective), which a backtest records at its end for an event it did not reach;
  6. a target the action does not take (control_bad_target).

A refused event changes nothing, and its control record names the token. The applied events are applied to a copy of the state and logged with the transitions they caused, and only then does the copy become the state. With {run} in the path, each run reads its own file. Without a director the flag is refused.

  • Backtest: the file is frozen. An event whose effective time is after the run’s last event boundary is recorded at the run’s end as refused, control_not_effective.
  • Live: to add an event, append it to the file and restart the run, whose state the sidecar keeps. A restart offers only the events the sidecar has not recorded, applied or refused, so none is taken twice; an event is matched in every field, so one corrected after a refusal is a new event, judged afresh (its sequence must still exceed the last applied one’s). An event whose records the sidecar cannot take is not applied: it waits for a later boundary, and the director latches its reducing state, as it does for any append that fails.

A control file in a backtest

smacross on ES through the first half of 2025, with a reduce-only-state rule and max-open-positions 1. The run’s configuration hash is dd8fcdfb…; the file pauses entries on 3 February and resumes them on 3 March, and four of its events are wrong on purpose:

{"sequence": 1, "at": "2025-02-03T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "reduce", "rule": 1, "reason": "risk review: entries paused"}
{"sequence": 2, "at": "2025-02-10T00:00:00Z", "config_hash": "0000000000000000000000000000000000000000000000000000000000000000", "action": "resume", "rule": 1, "reason": "written against another configuration"}
{"sequence": 3, "at": "2025-02-17T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "reset-peak", "rule": 1, "reason": "wrong target"}
{"sequence": 4, "at": "2025-03-03T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "resume", "rule": 1, "reason": "review complete"}
{"sequence": 4, "at": "2025-03-10T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "reduce", "rule": 1, "reason": "sequence reused"}
{"sequence": 5, "at": "2025-08-01T00:00:00Z", "config_hash": "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d", "action": "reduce", "rule": 1, "reason": "after the run's end"}
bin/algo run --strategy bin/smacross --symbol fut:XCME:ES --interval 1d \
  --start 2025-01-01T00:00:00Z --end 2025-07-01T00:00:00Z \
  --director director.json --director-control control.jsonl --results-dir results

The log’s control and state records, by event time and reason:

2025-02-03T22:00:00Z control applied: sequence=1 action=reduce rule=1
2025-02-03T22:00:00Z director_state_changed: rule=1 kind=reduce-only-state from=active to=director_reducing control=1
2025-02-10T22:00:00Z control_hash_mismatch: sequence=2 configuration hash "0000000000000000000000000000000000000000000000000000000000000000" does not match the tracker's "dd8fcdfbdf42ac7414df745d58d8588c3d009ffba5dc1dac74e223f4ef273f2d"
2025-02-17T18:00:00Z control_bad_target: sequence=3 rule 1 is not a max-peak-drawdown rule
2025-03-03T22:00:00Z control applied: sequence=4 action=resume rule=1
2025-03-03T22:00:00Z director_state_changed: rule=1 kind=reduce-only-state from=director_reducing to=active control=4
2025-03-10T21:00:00Z control_out_of_sequence: sequence=4 sequence 4 does not exceed the last applied sequence 4
2025-06-30T21:00:00Z control_not_effective: sequence=5 effective time 2025-08-01T00:00:00Z is after the boundary 2025-06-30T21:00:00Z

Each event took effect at the first daily close after its time (the 17 February close was early, for a holiday). While entries were paused, a reversal lost its opening part and a sell from flat was refused with director_reducing: rule=1 requested=1 state=reducing. The report’s director: block counts the six events (breaches 0 | state changes 2 | control events 6).

The other two refusals read control_malformed: sequence=0 sequence 0 is below 1 (or, after the sequence=N, effective time is zero, configuration hash is empty, reason is blank, action "pause" is not resume, reduce, reset-peak or reset-ladder) and control_out_of_order: sequence=N effective time … is before the last applied event's …. A reset-peak run is in max-peak-drawdown.

Restart and recovery

A live run writes the director’s state ahead. Every change of it (an equity observation, an applied control event, the reducing latch, an opening an entry-rate rule counts or a daily bound charges) is followed, in the same synced append to the sidecar and before anything relies on it, by a checkpoint record holding the whole state.

checkpoint is a versioned container keyed by the configuration hash, with one slot per kind of state. equity holds, by rule ordinal, each high-water mark, period and baseline, latch, ladder band and reducing state, and also the director’s own latch, the last equity, the latest event time and the last applied control event. rate, with an entry-rate rule, holds the openings the rules count (entries, kept for the longest window), when the history is known from (since, the zero time 0001-01-01T00:00:00Z when it is complete) and its latest event time (at). liquidity, with a liquidity-cap daily bound, holds the daily participation: the latest account trade date (date), the openings charged on it by symbol (charges) and whether they are unknown. An opening’s checkpoint is on the sidecar before the order is held or sent, so a restart counts every opening the account may have taken; if that append fails the opening part is refused (director_log_unavailable), it takes no slot and no charge, and the reducing state latches.

A live run holds a lock on its sidecar from start to finish, so a second run on the same sidecar is refused, and a backtest’s decision log is not written over it. A restart reads the sidecar back before it appends to it. Its record and instant sequences continue after the largest there, and the director takes up the last checkpoint’s state. A sidecar that recorded no change (none at all, or one of decisions alone) starts the director fresh, as configured. Anything else fails closed: every opening is refused with director_reducing (rule 0), and reductions proceed, until the owner’s resume of rule 0. The latch’s state record and a note on standard error give the cause:

CauseWhen
restore_tornThe sidecar ended in a torn line, which a crash during an append leaves. The line is removed; the state is the last complete checkpoint’s. Until the latch is on the sidecar, a marker beside it (<sidecar>.torn) keeps the cause, so a restart that stops before then fails closed again.
restore_missingA state change follows the last checkpoint, or there is none (a sidecar written before checkpoints existed).
restore_hash_mismatchThe checkpoint is for another configuration. The director starts fresh under the new one.
restore_versionThe checkpoint’s container version is unknown.
restore_refusedThe checkpoint holds a state the director cannot be in.
restore_lower_peakA high-water mark is below the last equity: a lower peak that no run reaches.

So a restart cannot quietly forgive a drawdown: the persisted mark is restored, or entries stay refused until the owner decides. A resume after a fresh start accepts the fresh state, its marks seeded as configured, and the records show it.

The entry-rate history follows the same rule. A sidecar that recorded no change starts it empty and complete. A checkpoint that restores gives it back. Otherwise the history before the restart is unknown: a checkpoint without rate (written before the entry-rate rules were evaluated) fails closed with restore_missing, one whose rate is not a state the history can be in with restore_refused, and either, like the causes above, latches rule 0. The history then counts as known only after the sidecar’s latest event time, so after the owner’s resume an entry-rate rule still refuses openings with director_input_missing (inputs=entry-history) until a whole window has passed since then. No control event resets the history.

The daily participation follows it too. A checkpoint without liquidity fails closed with restore_missing, one whose liquidity the ledger refuses with restore_refused, and either, like the causes above, leaves the charges of the trade date of the sidecar’s latest event unknown (for an event the exchange calendar does not cover, the first trade date after it): after the owner’s resume a daily bound still refuses openings on that date with director_input_missing (inputs=participation), and the next trade date starts complete. A live run refuses liquidity-cap today, so this waits for a live volume source.

A line before the last that is not a record is not something a crash leaves: the run is refused, naming the line and its byte offset, and the sidecar is left as it is for inspection. So is a completed backtest’s decision log (one with a footer record) at the sidecar’s path.

Across a period boundary, the restored last equity becomes the new period’s baseline only when it was observed, with a trade date, before the boundary; otherwise the period rule has no baseline (input=baseline) until the following boundary. The race guard’s reservations are not restored from the sidecar: they are rebuilt from the startup recovery and the ledger (Restart seeding). A latch the sidecar did not take, because its append failed, does not survive a restart; the restarted run’s first append that fails latches again.

An operator’s walk-through

This was run on the IBKR emulator, whose clock the engine drives, with a scripted strategy that sends market orders for AAPL at set bars, and this director:

{"director": {"version": 1, "rules": [
  {"rule": "max-order-size", "all": true, "max-quantity": 5},
  {"rule": "entry-rate", "max": 1, "window": "1h"}
]}}

Each step is a live run on the same ledger, over the next minutes of the emulator’s bars:

bin/algo run --mode live --venue-url http://127.0.0.1:5079 --venue-emulated \
  --ledger var/orders.json --lifetime v2 \
  --adapter bin/file-csv-adapter --adapter-inputs Path=aapl-1m.csv \
  --start 2026-01-02T14:30:00Z --end 2026-01-02T14:37:00Z \
  --strategy bin/mystrategy --symbol AAPL --interval 1m \
  --director director.json

1. A normal run. The strategy buys 1 at its second bar, 1 at its third, sells 1 at its fourth and buys 10 at its fifth. The first buy is accepted and counted by the entry-rate rule; the second is refused (director_entry_rate); the sell, a reduction, passes; the buy of 10 is refused by the order-size cap. The sidecar, var/orders.json.director.jsonl, gets the decision, then a checkpoint with the counted entry, then the link to the ledger’s canonical id, before the broker call.

2. A crash. The machine stops in the middle of an append, leaving the sidecar’s last line torn. On the next start the engine cuts the line away and latches:

algo: trade director: the sidecar var/orders.json.director.jsonl ended in a torn line of 739 byte(s) at byte 4788, which a crash during an append leaves; it is removed, and entries fail closed until an owner control event (GLE-532)
algo: trade director for instance AAPL: the restart could not restore the director's state (restore_torn: the sidecar ended in a torn line of 739 byte(s) at byte 4788, which may have held a state change): opening risk is refused (reducing state latched, rule 0) until an owner control event resumes it; reductions proceed
algo: order "step2" (AAPL) from instance AAPL denied: director_reducing: rule=0 requested=1 state=reducing

The latch is a state record on the sidecar:

{"schema":"algolang.director/1","kind":"state","sequence":11,"event_time":"2026-01-02T14:35:00Z","instant_sequence":5,"phase":"state","run_id":"run-AAPL-1m","owner_id":"AAPL","reason_code":"director_state_changed","reason":"director_state_changed: rule=0 kind=reduce-only-state from=active to=director_reducing; restore_torn: the sidecar ended in a torn line of 739 byte(s) at byte 4788, which may have held a state change","state":"director_reducing","from_state":"active","rule_state":{"rule":0,"kind":"reduce-only-state","hwm":0,"period":"","baseline":0,"has_baseline":false,"latched":false,"band":0,"reducing":true},"config_hash":"2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644"}

3. The owner decides. The torn line held a decision, not a state change, so the restored state is right. The owner writes a resume of rule 0 with the run’s configuration hash and restarts with --director-control:

{"sequence": 1, "at": "2026-01-02T14:50:00Z", "config_hash": "2282db5e624461a6a676abc224cab76a2913f224b59b66ad9fce6b645f20d644", "action": "resume", "rule": 0, "reason": "torn line inspected: it held a decision only"}

The restart notes that the restored state holds the latch, applies the event at the first boundary at or after 14:50, and checkpoints the result:

algo: trade director for instance AAPL: the restored state holds the reducing latch (rule 0): opening risk is refused until an owner control event resumes it

control applied: sequence=1 action=resume rule=0
director_state_changed: rule=0 kind=reduce-only-state from=director_reducing to=active control=1

4. The entry-rate history. The latch is gone, but the entries counted before the crash cannot be known for certain, so the history counts as known only from the sidecar’s latest event time, 14:35 (the checkpoint’s "rate": {"since": "2026-01-02T14:35:00Z", …}). Until a whole window has passed since then, the entry-rate rule refuses openings for want of it:

algo: order "step2" (AAPL) from instance AAPL denied: director_input_missing: rule=2 requested=1 inputs=entry-history

From 15:35 the rule counts again. Had the run simply stopped and restarted without a torn line, the checkpoint would have restored the history: a restart within the hour of the first buy refused the next one with director_entry_rate: rule=2 requested=1 count=1 max=1 window=1h0m0s.

Live runs

The director runs on a live run with the same decision code as on a backtest; what differs is where its inputs come from, and some inputs have no live source yet. A live run with a director must name --lifetime v2 and needs a ledger or --director-log. This build’s live mode is the emulated loop: --mode live needs --venue-emulated (The venue seam and the current live path).

What works and what does not, rule by rule:

RulesOn a live run
reduce-only-state, restricted-instruments, entry-rate, max-open-positions, max-group-positions, top-n-conviction, the directional counts and quantities, max-order-size’s max-quantityWork today: they read nothing from the account or the market.
max-peak-drawdown, max-period-drawdown, drawdown-ladderRefused at configuration: they follow equity adjusted for external cash flows (deposits and withdrawals), and a live run has no source of them yet.
max-marginThe summary’s initial margin plus the modelled increments below; a max-fraction bound divides by net liquidation.
max-deployed-value, max-deployed-percentRefuse every opening: a live run reads no marks yet (mark:<SYM>).
max-order-size’s max-notional; stale-data’s marksRefuse every opening: no live marks.
max-order-size’s price-collarRefuses every opening with a limit or stop price, for want of a mark; a market order has no price to collar.
stale-data’s account and marginThe account summary’s readings, aged from the boundary that accepted it.
stale-data’s fxRefused at configuration: a live run reads no FX rates.
max-var, the directional risk capsThe history of completed sessions, from the run’s bars and the director’s warm-up read at the start (Historical risk); max-var adds the summary’s cash in each currency other than the base. A rule that values an instrument or a cash balance in another currency refuses openings (fx-history:<CUR>); max-var values the whole account, so it serves only an account holding base-currency instruments and cash.
volatility-multiplierThe history as for max-var, and net liquidation as E. A foreign-currency instrument refuses every opening of the batch (fx-history:<CUR>).
liquidity-capRefused at configuration: a live run has no validated volume or open-interest source yet (GLE-545).

A rule whose input no source supplies yet refuses the run at configuration, rather than refusing every opening for a missing input. The refusals name the rule by its position:

engine: director.rules[N] (max-peak-drawdown): the rule follows equity adjusted for external cash flows (deposits and withdrawals), and a live run has no source of external cash flows yet, so a deposit would raise its high-water mark and a withdrawal would read as a loss; on a live run the fraction bounds (max-deployed-percent, max-margin's max-fraction), which read net liquidation as it is, and reduce-only-state are available (GLE-532)
engine: director.rules[N] (stale-data): a live run reads no FX rates yet, so the fx input cannot be checked; remove fx from inputs (GLE-532)
engine: director.rules[N] (liquidity-cap): a live run has no validated volume or open-interest source yet (spec §7), so the rule cannot be evaluated; remove it from a live configuration (GLE-532)

So does an explicit freshness limit for marks or fx (engine: director.freshness: ...): those inherited limits are not applied yet. The account and margin limits are: a live run withholds an account summary older than them.

The live account

On a live run the director reads the account through IBKR’s numeric account summary, GET /iserver/account/{accountId}/summary: net liquidation, initial margin, maintenance margin and the cash in each currency. Only a configuration that reads them starts the reader, one with max-deployed-percent, max-margin, max-var, volatility-multiplier or a stale-data rule with the account or margin input; a counts-only configuration asks the venue for nothing more, and a live run without a director is unchanged.

The reader. The run owns one poller goroutine. It starts with the live loop, and stops before the teardown cancels anything at the venue and on any error. At each event boundary (a bar’s close, a timer’s instant) the loop asks it for one summary, with a deadline of 5 seconds. The request counts in the transport’s window (--transport-requests, --transport-window) as an order does, and is skipped while the window is full or requests wait in its queues; a pacing refusal (HTTP 429) of the summary pauses the transport as an order’s does. The emulated loop, the only live loop in this build, waits for the summary it asked for, at most 6 seconds, so its runs are deterministic. The loop then takes the newest summary the poller has, stamps it with the boundary’s event time, and appends an account record before anything uses it. From a live run on the emulator, whose account is a cash account with no margin:

{"schema":"algolang.director/1","kind":"account","sequence":1,"event_time":"2026-01-02T14:31:00Z","instant_sequence":1,"phase":"account","run_id":"run-AAPL-1m","owner_id":"AAPL","account":{"version":1,"request":1,"account":"DU1234567","currency":"USD","net_liquidation":100000,"initial_margin":0,"maintenance_margin":0,"cash":100000,"received":"2026-10-19T10:08:52.706809Z"}}

Every decision until the next boundary reads that one summary, whatever arrives meanwhile; received, the wall time it arrived, is kept for replay and read by no rule. A request that gives no summary gets an account record too, with version 0, the error in failed and, for a pacing refusal, paced. Feeding the recorded summaries and failures back at their boundaries reproduces the decision log byte for byte.

A summary is read strictly. Each amount must be a JSON number: one that is absent, null, a string such as "1,290,490 USD" or out of range is unavailable on its own and named in unavailable, and so is a negative margin. The response names no account and no time, so the account is the one the request named (the venue’s), and the base currency comes from the cash balances’ one "Total (in XXX)" row. A summary without exactly one such row, of another account, or in a base currency other than the run’s is taken as unusable, its refusal saying why, so every input it would give is missing, even when an earlier summary is still fresh. A request that fails leaves the last summary in place, and it ages: one older than its freshness limit (freshness.account for net liquidation, freshness.margin for the initial margin, 30 seconds each by default) is withheld and named as stale, and the run notes the first failure on standard error. A rule that needs a withheld reading refuses openings with director_input_stale, one that needs a missing reading with director_input_missing; both name equity for net liquidation and margin for the reported initial margin, and reductions pass:

director_input_stale: rule=1 requested=1 inputs=equity,margin
director_input_missing: rule=1 requested=1 inputs=equity,margin

stale-data’s account and margin inputs age the two readings from the boundary that accepted the summary. max-var reads the cash from the same summary’s cash balance rows other than the total, each currency’s balance as it stands: a row whose currency is not three capital letters, or whose balance is not a JSON number, is named missing (cash:<CUR>, or cash:row<N> by its index from 0), and without a fresh summary (freshness.account) the cash as a whole is missing (cash); a missing reading refuses openings with director_input_missing. E5 shows an account that gives cash but neither net liquidation nor initial margin.

Live margin

The reserved initial margin of a live run is max(reported initial margin, modelled filled-position margin) + pending increment + fill supplements. Each modelled figure comes from an instrument’s approved schedule: the per-contract requirement of a futures contract that the data adapter serves from its registry seed, or from an IBKR what-if capture that does not depend on the account’s positions. A requirement of 0, an account-dependent capture, an equity or an option has no approved schedule, so max-margin refuses the openings that need it (margin:<SYM>). The schedules come from data the run already has; an explicit list the owner approves is possible later. The filled-position margin is each absolute position times its requirement; the pending increment is what the working, held and queued orders add to each row’s worst quantity.

A fill that raises a position is counted again, as a supplement, until a position reconciliation after it was recorded has confirmed it (one runs at each bar on a run with a ledger) and a summary requested after that reconciliation is accepted. A fill the bar’s poll brings is confirmed by the bar’s reconciliation and its summary, so it adds nothing; one found after them (an MOC’s settlement, a fill a timer’s poll finds), or one whose bar’s summary failed, waits for the next bar’s; a run without a ledger keeps its supplements, one per instrument. The snapshot records list them. Maintenance margin is recorded and not read. A schedule keeps its own currency, and a live run reads no FX rates, so a requirement in a currency other than the base is unavailable (fx:<CUR>).

On the emulator, which trades AAPL, a max-margin rule therefore refuses every purchase while the account summary arrives at every bar:

algo: order "step2" (AAPL) from instance AAPL denied: director_input_missing: rule=1 requested=1 inputs=margin:AAPL

Equity. The fraction bounds, max-deployed-percent and max-margin’s max-fraction, divide by the summary’s net liquidation as it is: a bound on the account as it stands needs no adjustment for deposits and withdrawals. The drawdown rules do need one (they follow equity less the external cash flows), and a live run has no source of external flows yet, so a live run with max-peak-drawdown, max-period-drawdown or drawdown-ladder is refused when it starts: otherwise a deposit would raise a high-water mark, loosening a fraction stop, and a withdrawal would read as a loss. reduce-only-state, the director’s own latch, the owner’s control events and the sidecar’s restore work live as on a backtest.

Restart seeding

With a ledger, the restart seeds the race guard from the startup recovery before the first bar: the adopted positions, and the adopted working orders as working attempts, each with its executed quantity and broker order id. So the director’s snapshot, the admission limits and the race guard hold what the account holds, an order whose submission’s answer was lost included, once the broker reports it. An attempt the sidecar records as accepted, but without a link record or a ledger record, was not transmitted (the ledger records the order and the link follows, before the broker call): the restart records an outcome for it in phase recovery, reason code recovery_released, and it reserves nothing. One the ledger records (its link’s append failed and the submission went on) is the ledger’s to settle. The rebuilt book must agree with the ledger’s expected positions and open quantities, and the director’s snapshot with the guard; otherwise the reducing latch is set, its state record giving the cause (recovery_invariant), and every opening is refused until the owner’s resume of rule 0.

With a director the seeded guard changes what a strategy may send after a restart: an order under the label of an adopted working order is refused as a duplicate (duplicate_live_label) while the adopted one is working, as for any working order of the run. Without a director the guard is not seeded, as before.

Limits: a strategy cannot yet cancel an adopted order through order lifetime v2, whose venue operations know only the run’s own submissions, so such a cancel is refused (GLE-544); live marks, FX rates and external cash flows are not read.

Paper-account conformance

The tests run the director’s live path against the IBKR emulator, which proves that the engine and the adapter agree, not that the broker answers as the emulator does. The design makes a check against a paper account a release prerequisite (spec §11). make conformance-ibkr-paper is that check. It is read-only: it places, changes and cancels no order. It is built only with the ibkrpaper tag, so no other target runs it, and make test vets it so it keeps compiling.

Before running it.

  1. Start the IBKR gateway you trade through and log in to the paper account, so the gateway holds an authenticated session.
  2. In the paper account, hold one dated futures contract that has an approved margin schedule (its registry seed, for example fut:XCME:ES:Z26): a position of one or two contracts and nothing else, so that the account’s initial margin is that position’s. Cancel every working order.
  3. Look up the contract’s per-contract initial margin in the registry seed: the figure the director’s schedule uses.

Running it.

IBKR_PAPER_URL="https://$GATEWAY/v1/api" IBKR_PAPER_INSECURE=1 \
IBKR_PAPER_ACCOUNT=DU1234567 IBKR_PAPER_BASE=USD \
IBKR_PAPER_FUTURE=fut:XCME:ES:Z26 IBKR_PAPER_MARGIN="$SEED_MARGIN" \
IBKR_PAPER_EVIDENCE=paper-conformance \
make conformance-ibkr-paper

GATEWAY is the gateway’s host and port, and SEED_MARGIN the figure from step 3. IBKR_PAPER_URL is the API base the adapter appends its paths to, the one a live run’s --venue-url names: IBKR documents the summary as /v1/api/iserver/account/{accountId}/summary, and the adapter asks for /iserver/account/{accountId}/summary. IBKR_PAPER_INSECURE=1 accepts the gateway’s self-signed certificate. IBKR_PAPER_TOLERANCE (default 0.10) is how far the broker’s margin per contract may exceed the schedule’s. IBKR_PAPER_MARGIN_CURRENCY (default the base currency) is the schedule’s currency: the check refuses another, since a live run reads no FX rates and the director takes such a schedule as unavailable. IBKR_PAPER_TIMEOUT (default 30s) is each request’s deadline, so a gateway that stalls fails the check rather than hanging it. IBKR_PAPER_URL=emulator runs the same check against the in-process emulator, which tests the check itself. Each test writes its evidence, the readings it judged, to IBKR_PAPER_EVIDENCE (numbered JSON files and a sidecar).

What it verifies.

TestVerifies
TestPaperAccountSummaryIBKR’s numeric account summary, GET /v1/api/iserver/account/{accountId}/summary, answers for the paper account, and the adapter’s strict parser takes it whole: net liquidation, initial margin, maintenance margin and cash are JSON numbers, the cash balances have one "Total (in XXX)" row whose currency is IBKR_PAPER_BASE, the account is IBKR_PAPER_ACCOUNT, net liquidation is positive and maintenance margin is at most initial margin.
TestPaperMarginFiguresThe schedule is in the base currency; with the future held alone, the summary’s initial margin is positive, and the broker’s margin per contract (initial margin divided by the position) exceeds IBKR_PAPER_MARGIN by no more than the tolerance; a larger one means the schedule understates what an order’s pending increment adds. It also checks that the director reserves the greater of the reported margin and the schedule’s filled-position margin for those readings.
TestPaperRecoverySeedingThe account’s positions, as the adapter reports them to the startup recovery, seed the race guard as a restart does, and the director’s snapshot then holds each with its signed quantity, under the run’s owner.
TestPaperSidecarA live run’s sidecar takes the account record of the paper account’s summary as a live run’s first boundary writes it (appended and synced), a second run cannot open the sidecar while the first holds it, and the sidecar reads back under the strict schema with the summary’s amounts.

What it does not verify. This build’s live mode is the emulated loop: --mode live needs --venue-emulated, so no live run can trade the paper account, and nothing writes a ledger against it. A restart matches the broker’s working orders and positions with the ledger the run wrote before, so the seeding of adopted working orders, the release of untransmitted intents and the invariant against the ledger are verified by the emulated tests only (engine/director_recovery_test.go), as are the account poller’s pacing and the supplements. Those wait for real-time live mode, the order lifetime programme’s live work.

Keep the evidence directory with the release: it is the record that the prerequisite was met, and on which build.

Reports

A backtest’s artefacts summarise the decision log, so you can see how often the director acted without reading the log.

The director summary

A backtest with a director summarises its decision log in its artefacts; a run without one records nothing, so its artefacts are byte-identical to before.

  • result.json carries Director, the configuration’s hash, and DirectorSummary: Log (the log’s path, relative to the run’s results directory when it is in it), Records and LogSHA256 (the count and the SHA-256 of the records before the footer, equal to the footer’s, which link the artefacts to the log), Candidates (first decisions) split into Accepted, Trimmed and Refused (whatever stage refused it), Rechecks, Outcomes (refusals after the director accepted), Breaches, StateChanges and Controls, then Codes (the decision and outcome records by reason code), Stages (the refusals by stage) and Rules (the decisions each rule bound, by ordinal, with its kind), each sorted and without zero counts. The quick start’s, trimmed:

    {
      "Log": "director-decisions.jsonl",
      "LogSHA256": "3dfca806c09c91f8bb8d8625e3ae64536f3b284f9d814e4391d79b414e5837c3",
      "Records": 33, "Candidates": 17, "Accepted": 7, "Trimmed": 5, "Refused": 5,
      "Rechecks": 0, "Outcomes": 0, "Breaches": 0, "StateChanges": 0, "Controls": 0,
      "Codes": [{"Key": "director_accepted", "Count": 7}, {"Key": "director_directional_limit", "Count": 2},
                {"Key": "director_entry_rate", "Count": 3}, {"Key": "director_trimmed", "Count": 5}],
      "Stages": [{"Key": "director", "Count": 5}],
      "Rules": [{"Rule": 1, "Kind": "max-directional-exposure", "Count": 7},
                {"Rule": 2, "Kind": "entry-rate", "Count": 3}]
    }
  • The text report prints a director: block after the performance block, and algo report prints the block a run recorded:

      director:    config 80cf3e43bbf8 | log director-decisions.jsonl (33 records, sha256 3dfca806c09c)
                   candidates 17: 7 accepted, 5 trimmed, 5 refused | rechecks 0 | refused after acceptance 0
                   refused by: director 5
                   reasons: director_accepted 7, director_directional_limit 2, director_entry_rate 3, director_trimmed 5
                   binding rules: 1 max-directional-exposure 7, 2 entry-rate 3
    

    A line of breaches, state changes and control events appears when any happened, as in the control-file run:

      director:    config dd8fcdfbdf42 | log director-decisions.jsonl (41 records, sha256 86a6041d3d60)
                   candidates 17: 15 accepted, 1 trimmed, 1 refused | rechecks 0 | refused after acceptance 0
                   breaches 0 | state changes 2 | control events 6
                   refused by: director 1
                   reasons: director_accepted 15, director_reducing 1, director_trimmed 1
                   binding rules: 1 reduce-only-state 2
    

    binding rules counts trims and refusals together, so it can exceed refused by.

  • report.html gains a Trade director section with the same counts (the candidates, accepted unchanged, trimmed, refused, rechecks, refusals after acceptance, breaches, state changes and control events, the refusals by stage, the reason codes and the decisions bound by each rule), and its run details name the configuration’s hash and the decision log with its record count and hash.

The summary counts a candidate once, by its first decision: an order the director accepted and the admission limits then refused counts as accepted, and once more among the refusals after acceptance; a recheck counts only as a recheck. The decision log has the full story.

Recipes

Three complete configurations, each run over the rankedcross demo (Handling refusals and trims) on real data. The thresholds suit these runs and are not advice: start from your own risk budget and read the decision log to see how often each rule binds.

A futures trend portfolio

Eleven futures, one contract an entry, USD 1,000,000, with drawdown stops, a holdings budget per sector, a margin ceiling and a VaR limit:

{
  "director": {
    "version": 1,
    // Trade dates and periods follow CME's calendar: an evening session
    // belongs to the next day.
    "clock": {"kind": "exchange-trade-date", "symbol": "fut:XCME:ES"},
    "groups": {
      "indices": {"roots": ["fut:XCME:ES", "fut:XCME:NQ"]},
      "metals":  {"roots": ["fut:XCEC:GC", "fut:XCEC:SI", "fut:XCEC:HG"]},
      "energy":  {"roots": ["fut:XNYM:CL", "fut:XNYM:NG"]},
      "rates":   {"roots": ["fut:XCBT:ZN", "fut:XCBT:ZB"]}
    },
    "rules": [
      {"rule": "max-peak-drawdown", "max-fraction": 0.2},
      {"rule": "max-period-drawdown", "period": "month", "max-fraction": 0.06},
      {"rule": "max-open-positions", "max": 6},
      {"rule": "max-group-positions", "group": "indices", "max": 1},
      {"rule": "max-group-positions", "group": "metals", "max": 2},
      {"rule": "max-group-positions", "group": "energy", "max": 1},
      {"rule": "max-group-positions", "group": "rates", "max": 1},
      {"rule": "max-margin", "max-fraction": 0.15},
      {"rule": "max-var", "max-value": 18000}
    ]
  }
}

Line by line:

  • clock: the monthly stop’s months are CME trade dates, so a Sunday evening’s first bar belongs to Monday and a month’s first evening session to the new month.
  • groups: four sectors by root, so every contract and the continuous series match. The two currency futures the strategy also trades belong to no group, and only the total count limits them.
  • Rules 1 and 2: the drawdown stops come first, so that once one latches, every refusal names it. The peak stop at 20% latches until the owner’s reset-peak; the monthly stop at 6% clears when the month ends.
  • Rule 3: at most six holdings at once, working entries included.
  • Rules 4 to 7: one index, two metals, one energy, one rate. A holding in a group takes a slot both here and in rule 3.
  • Rule 8: the buffered initial margin of every position and pending order at most 15% of marked equity: (1 + 0.10) × margin ≤ 0.15 × E.
  • Rule 9: the account’s one-day 99% historical VaR, over the 250 sessions before each decision, at most USD 18,000.
bin/algo run --strategy bin/rankedcross --interval 1d --capital 1000000 \
  --symbols fut:XCME:ES,fut:XCME:NQ,fut:XCEC:GC,fut:XCEC:SI,fut:XCEC:HG,fut:XNYM:CL,fut:XNYM:NG,fut:XCBT:ZN,fut:XCBT:ZB,fut:XCME:EUR,fut:XCME:JPY \
  --start 2023-01-01T00:00:00Z --end 2026-01-01T00:00:00Z \
  --director futures.jsonc --results-dir results
  director:    config 1fe1bdd1eea6 | log director-decisions.jsonl (421 records, sha256 bdc13b6aa7ba)
               candidates 231: 179 accepted, 2 trimmed, 50 refused | rechecks 0 | refused after acceptance 0
               breaches 0 | state changes 37 | control events 0
               refused by: director 50
               reasons: director_accepted 179, director_group_positions 16, director_margin_limit 15, director_open_positions 19, director_trimmed 2
               binding rules: 3 max-open-positions 19, 4 max-group-positions 10, 7 max-group-positions 6, 8 max-margin 15, 9 max-var 2

Over three years the drawdown stops did not latch; their 37 state records are the first observation and the monthly period changes. The index and rate budgets did most of the sector work (director_group_positions: rule=4 group=indices requested=1 count=2 max=1), the margin ceiling refused fifteen entries that would have taken the buffered margin past 15% of equity (director_margin_limit: rule=8 requested=1 margin=151675.7 max=147672.375 bound=fraction), and the VaR limit cut two reversals to their closing part.

An equity book

Nine ETFs, 200 shares an entry, USD 150,000, cash-funded: the book may not deploy more than its equity, no order may be a fat-finger, entries are rationed, and sector funds are limited:

{
  "director": {
    "version": 1,
    // A trim that leaves less than USD 5,000 of an ETF is not worth sending.
    "min-notional": 5000,
    "groups": {
      "sectors": {"symbols": ["eq:ARCX:XLE", "eq:ARCX:XLF", "eq:ARCX:XLK", "eq:ARCX:XLV"]}
    },
    "rules": [
      {"rule": "max-order-size", "all": true, "max-quantity": 1000, "max-notional": 75000},
      {"rule": "entry-rate", "max": 3, "window": "168h"},
      {"rule": "max-group-positions", "group": "sectors", "max": 2},
      {"rule": "max-deployed-percent", "max-fraction": 1.0, "action": "trim"}
    ]
  }
}

Line by line:

  • min-notional: a trimmed opening worth less than USD 5,000 is refused (director_below_minimum) rather than sent as a scrap.
  • groups: the four sector funds by exact symbol.
  • Rule 1: covers every instrument ("all": true), as max-order-size must; no single order may open more than 1,000 shares or more than USD 75,000. It is reject only, so a mistake is refused, not trimmed into an order.
  • Rule 2: at most three new openings in any rolling week.
  • Rule 3: at most two sector funds held at once.
  • Rule 4: the gross value of every position and pending order at most the marked equity; an entry that would pass it is trimmed to what fits.
bin/algo run --strategy bin/rankedcross --interval 1d --capital 150000 --inputs Qty=200 \
  --symbols eq:ARCX:SPY,eq:XNAS:QQQ,eq:ARCX:IWM,eq:ARCX:GLD,eq:XNAS:TLT,eq:ARCX:XLE,eq:ARCX:XLF,eq:ARCX:XLK,eq:ARCX:XLV \
  --start 2024-03-01T00:00:00Z --end 2025-11-01T00:00:00Z \
  --director equities.jsonc --results-dir results
  director:    config 2caa29999013 | log director-decisions.jsonl (183 records, sha256 6f44690e4b80)
               candidates 102: 42 accepted, 12 trimmed, 48 refused | rechecks 0 | refused after acceptance 0
               refused by: director 48
               reasons: director_accepted 42, director_deployed_percent 9, director_entry_rate 2, director_group_positions 19, director_order_size 18, director_trimmed 12
               binding rules: 1 max-order-size 18, 2 entry-rate 4, 3 max-group-positions 19, 4 max-deployed-percent 19

The order-size cap refused every entry in SPY and QQQ, whose 200 shares were worth USD 88,000 to 123,000 (director_order_size: rule=1 requested=200 symbol=eq:XNAS:QQQ dimension=notional measure=88225.92000000001 max=75000): with a fixed share count, a cap on notional falls on the dearest funds. The deployed cap trimmed a gold entry in June 2024 to 137 shares, the most that kept the book at its equity:

{"name": "deployed-percent", "before": 115602.72, "requested_after": 160052.27999999997,
 "accepted_after": 146050.66859999998, "limit": 146129.96640000003}

Later reversals of that holding were trimmed to 137 shares a side: a reversal from long 137 to short 137 does not raise the deployed value, which counts the worst quantity either way, so that much passed even with the book above the cap after a rally, while the rest of the 200, and a new entry then, were refused (director_deployed_percent: rule=4 requested=200 deployed=169367.02200000003 equity=148322.05400000003 max-fraction=1).

A conviction-ranked system

The same eleven futures with a faster crossover, ranked by conviction: at most five holdings, and at most two new ones at any close, one per side and no more than one metal:

{
  "director": {
    "version": 1,
    "groups": {
      "metals": {"roots": ["fut:XCEC:GC", "fut:XCEC:SI", "fut:XCEC:HG"]},
      "energy": {"roots": ["fut:XNYM:CL", "fut:XNYM:NG"]}
    },
    "rules": [
      {"rule": "max-open-positions", "max": 5},
      // At most two new holdings a close: one long, one short, and no more
      // than one of them in metals.
      {"rule": "top-n-conviction", "max": 2, "max-long": 1, "max-short": 1,
       "group-max": {"metals": 1}}
    ]
  }
}

Line by line:

  • groups: metals for the group quota; energy is defined for later rules and unused here, which is allowed.
  • Rule 1: the capacity. It comes before the selection, as constraints must.
  • Rule 2: the selection, always last. The openings of one close are previewed against the book, those no cap would admit are dropped (they are refused by the cap and get no rank), and the rest are ranked by absolute conviction and taken in turn until a quota is full.
bin/algo run --strategy bin/rankedcross --interval 1d --capital 1000000 --inputs Fast=5,Slow=20 \
  --symbols fut:XCME:ES,fut:XCME:NQ,fut:XCEC:GC,fut:XCEC:SI,fut:XCEC:HG,fut:XNYM:CL,fut:XNYM:NG,fut:XCBT:ZN,fut:XCBT:ZB,fut:XCME:EUR,fut:XCME:JPY \
  --start 2023-01-01T00:00:00Z --end 2026-01-01T00:00:00Z \
  --director ranked.jsonc --results-dir results
  director:    config 38131472ec3f | log director-decisions.jsonl (659 records, sha256 35799f572579)
               candidates 397: 298 accepted, 6 trimmed, 93 refused | rechecks 0 | refused after acceptance 0
               refused by: director 93
               reasons: director_accepted 298, director_not_top_n 1, director_open_positions 92, director_trimmed 6
               binding rules: 1 max-open-positions 92, 2 top-n-conviction 7

On 10 January 2023 two buys crossed together: the ten-year note, with conviction 0.035, took the one long slot, and the euro, at 0.018, was refused by the side quota:

director_not_top_n: rank=2 quota=long max=1 score=0.017792727996988913 rule=2

Most of the time only one symbol crosses at a close, so the holdings cap does most of the refusing; the ranking decides who gets a slot when several compete. Six reversals lost their opening part to the quotas.

Troubleshooting

Start from the reason the strategy received, or the log’s reason_code and cause_code: the token says which rule or input to look at.

Reason codes

Every director_* token, with what it means and what to look at. The strategy reads the token before the first :; the decision log’s reason_code and cause_code hold the same tokens.

TokenMeaningWhat to change
director_acceptedLog only: the order passed unchanged.Nothing.
director_trimmedThe order went out with less than it asked for; cause= names the binding token and rule= the rule.Read the cause. Nothing, if trimming is what you configured.
director_reducingReducing state: rule N’s reduce-only-state, or rule 0, the engine’s latch.Rule N: the owner’s resume of it. Rule 0: find the cause in the log’s state record (an unwritable log, a failed restore, a recovery mismatch), then the owner’s resume of rule 0.
director_restrictedThe opening matches a restricted selector on that side.The selectors or side, if the restriction is wrong.
director_stale_dataA reading is older than max-age (age=), or a mark jumped more than max-jump (dimension=jump).max-age for the slowest series, or max-jump; check the data for a gap.
director_drawdown_ladderThe ladder’s band scaled the opening to zero, or a zero band refuses every opening.The bands, or the owner’s reset-ladder after a review.
director_volatilityThe overlay scaled the opening to zero.target, or smaller pending risk; check base, new and budget.
director_order_sizeQuantity, notional or a price collar broken, or an instrument no rule covers (uncovered=).The order (it is refused, not trimmed, by design), or the bounds; cover every traded instrument.
director_entry_ratecount= openings already in the window.max or window; a refused order takes no slot.
director_directional_limitA count, quantity or risk cap of the selection, by dimension=.The caps, or "action": "trim".
director_open_positionsOne holding more than max; working entries count.max, or cancel working entries you no longer want.
director_group_positionsOne holding more than the group’s max.The group’s max or membership.
director_deployed_valueGross notional above max-value.max-value, "action": "trim", or price-buffer.
director_deployed_percentGross notional above max-fraction of equity.max-fraction, "action": "trim"; check equity.
director_margin_limitBuffered reserved margin above a bound (bound=value or fraction).The bound or margin-buffer.
director_peak_drawdownThe peak stop has latched; loss= is the loss now.The owner’s reset-peak after a review; a recovery does not clear it.
director_period_drawdownThe period’s stop has latched.Wait for the next period, or change the bound.
director_var_limitThe VaR with the opening above max-value; before= is without it.max-value, confidence, smaller orders, or hedges.
director_liquidityA position, daily or open-interest bound broken, by dimension=.The fraction, adv-lookback, or "action": "trim".
director_not_top_nA quota of the selection is full; rank=, quota=, score=.max, max-long, max-short, group-max, or the strategy’s convictions.
director_input_missingA reading the rule needs is absent; the tokens below name it.Supply the input, or remove the rule from runs that cannot supply it.
director_input_staleA reading the engine withheld as older than its freshness limit (live: equity, margin).The account summary’s reader, or freshness.
director_warming_upA history is shorter than the rule needs (have=, need=).Start the run later, or a shorter lookback; the warm-up reads data before the run’s start.
director_config_invalidReserved. A configuration the director cannot use refuses the run when it starts, so no order carries it.—
director_invalid_convictionThe conviction is not finite, outside [-1, 1], or has the wrong sign for its side.The strategy’s conviction.
director_invalid_orderA quantity below 1, an unsupported bracket child, an order that overflows the book, or (live) a combo or attached order.The order.
director_below_minimumA trim left less than min-quantity or min-notional; limit= names the rule.The minimum, or larger room under the cap.
director_close_conflictA close would share another working close’s reducing budget.Cancel the other close in the same response.
director_log_unavailableThe live decision log could not be written; reductions went ahead.The disk; then the owner’s resume of rule 0.
director_state_changedLog only: a state transition.Nothing.

Missing-input tokens

A director_input_missing (or director_input_stale) reason names what was missing after inputs=, comma-separated, or for the state rules after input=:

TokenWhat was missingUsual cause
mark:SYMThe instrument’s mark.A live run (no live marks yet), or no bar for the instrument yet.
prev-mark:SYMThe mark before it, for max-jump.The instrument’s first bar.
tick:SYMA tick size, where a mark of zero needs one as a floor.Instrument metadata.
point-value:SYMThe point value.Instrument metadata from the adapter.
fx:CURA rate into the base currency.A live run (no FX rates yet), or no FX data for the currency.
margin:SYMThe instrument’s margin requirement or approved schedule.An equity or option live, or a contract without a requirement.
equityMarked equity (backtest) or net liquidation (live).Live: the summary lacks it.
marginThe account summary’s initial margin.Live: the summary lacks it.
history:SYMThe instrument’s completed-session history.No data for it.
fx-history:CURA currency’s rate history.A live run, or no FX data.
risk-sessionsAny session history.The run cannot get the anchor’s exchange calendar.
cash, cash:CUR, cash:rowNLive: the summary’s cash, a currency’s balance, or the cash row at index N.The account summary.
volume:SYMA contract’s or share’s volume.A failed volume read, noted on standard error.
open-interest:SYMA contract’s open interest.An equity, or no STATISTICS record before the decision.
root:SYMThe instrument’s root, for a restricted root.Instrument metadata.
membership:IDGroup membership, for restricted-instruments.An unknown root under a group of roots.
entry-historyThe entry-rate history after a restart that could not restore it.Wait a whole window, as the restart’s note says.
participationThe day’s participation charges after such a restart.Wait for the next trade date.
trade-dateA trade date for the daily bound.An instant the exchange calendar does not cover.
overflow:deployed, overflow:margin, overflow:bound, overflow:notional, overflow:var, overflow:risk, overflow:volatility, overflow:liquidityA measure or bound too large to represent.An absurd quantity or price.
input=equityThe equity at this instant, for a drawdown rule.Before the first observation, a missing rate, or a roll’s cut after a later observation.
input=hwm, input=baselineA positive high-water mark or period baseline, for a fraction bound.Equity at or below zero, or a baseline not yet known.
input=dateA trade date, for a period rule.An instant the exchange calendar does not cover.
input=membership group=IDGroup membership, for a group rule under "unknown-group": "reject".An unknown root.
input=stateThe rule’s state in the batch.Should not happen; report it.

Other tokens you may meet

  • Guard, policy and transport refusals. duplicate_live_label, the admission limits’ cap_quantity, cap_exposure, cap_working_orders, cap_symbol_working_orders and rate_budget, and transport_overload are not the director’s: an order the director accepted can still meet them, as an outcome record (The admission policy). A held order’s update reason begins held:, and a hold that ends without a release hold_*.
  • Control events. control_malformed, control_hash_mismatch, control_out_of_sequence, control_out_of_order, control_not_effective and control_bad_target, in the log’s control records only (Owner control events).
  • Restore causes. restore_torn, restore_missing, restore_hash_mismatch, restore_version, restore_refused and restore_lower_peak, in the latch’s state record and on standard error (Restart and recovery).
  • Recovery. recovery_released (an untransmitted attempt a restart released) and recovery_invariant (a rebuilt book that disagrees with the ledger) (Restart seeding).

Configuration errors

The run refuses to start, naming the key, for: a configuration that does not decode (an unknown or duplicate key, a wrong type, a value out of range, rules out of phase order, top-n-conviction twice, a group a rule names that is not defined); a base-currency other than the run’s; no decision log; lifetime v1; "equity": "closed"; a freshness limit for marks or fx; and, live, the drawdown rules, liquidity-cap and stale-data’s fx input. The messages are those shown in Configuring the director and Live runs.

Status and limits

This section is the director’s state as built, for the entry the next tagged release’s notes will carry.

What shipped. The trade director (design docs/trade-director-spec.md), in fifteen increments, GLE-532 I0 to I14: a pre-trade gate the engine consults for every order a strategy sends and every order it generates (a continuous roll’s legs), with all eighteen rules of the design, an optional signed conviction on an order (Order conviction) that top-n-conviction ranks by, the director run-config object and the --director, --director-json, --director-control and --director-log flags, owner control events, the decision log (algolang.director/1) and a live run’s synced sidecar with its restart, the live account read from IBKR’s numeric account summary, the completed-session history the risk rules read, the strategy’s feedback (a trimmed order’s acceptance update, a refusal’s reason) on both strategy wires, and the director summary in result.json and the reports. A run without a director is unchanged byte for byte: the I0 baselines (wire bytes, InitAck, stdout, stderr, result headers, equity and trade files, lifecycle traces) and every golden, through every increment.

The release gate (I14). engine/testdata/director/scenarios.json gives the design’s worked examples, E1 to E12 (The worked examples), as 25 runs, and engine/director_parity_test.go runs each through each path that applies to it: a backtest over the simulator, and an emulated live run over the IBKR emulator. Each run is checked against its expected updates and decision records, and run again from its recorded inputs (a live run’s account records fed back at their boundaries), which gives its decision log byte for byte. Ten run on both paths, and their decision records and the strategy’s updates are equal byte for byte (design §11); the snapshots differ only in their readings, which are each path’s inputs. Where a path does not apply the table says why:

ExampleBacktestEmulated live
E1 top-N tiesrunrun, equal
E2 protective ordersrun (the drawdown stop)refused: drawdown rules (GLE-543); the close conflict runs on both, equal without a cancel
E3 stop-and-reverserunrun, equal (reject and trim)
E4 month boundaryrun (exchange and civil dates)refused: drawdown rules (GLE-543)
E5 live margin unavailablenot applicable: the simulator always has equity and marginrun
E6 trimming and minimumrun (both states)not run: no live marks
E7 same-instant allocationrunrun, equal; the cancellation runs per path
E8 continuous rollrun (caps 6,000 and 4,500)refused: continuous live runs (GLE-228 Decision 7)
E9 held recheckrun (70,000 and 90,000)not run: no live marks
E10 metalsrunrun, equal (with and without top-N)
E11 VaR warm-uprun (three samples)run, equal
E12 bracketrun (resting, and filled with the target’s fill cancelling the stop)run: the bracket refused (no live attached orders), the caps refused (no live marks)

The simulator confirms a cancellation at once and the broker answers later, so an order sent beside a cancel is decided at once in a backtest and held until the answer live (E2, E7): both are the design’s behaviour, and the table checks each. E9’s hold in a backtest is behind a combo’s cancellation between its legs’ bars, the one cancellation the simulator answers later. engine/director_coordinator_contract_test.go supplies the snapshot a future account coordinator would, two instances’ rows under their owners, and the decision code, unchanged, keeps their opposing positions apart: no owner’s order reduces another owner’s position. Shared accounts remain GLE-360, not part of this release.

Performance. make bench-director runs the design’s §12 workload, a batch of 200 candidates over 40 instruments, 250 VaR scenarios and 1,000 outstanding reservations, under every rule, as the engine’s bridge decides it. On an Apple M2 Ultra a batch took 876 ms, 2.03 GB and 1.99 million allocations before I14, and takes 77 ms (2,600 candidates a second), 72 MB and 76,000 allocations after, its every decision, rule result and figure bit-identical (the digests of the fixture and nine randomised batches are pinned). The changes: a projection’s book is derived from the batch’s (the touched holdings recomputed, the rest shared, with each holding’s valuation, margins and group membership) rather than indexed again for each candidate and each trim probe; the max-var windows’ unit values, the reference book’s sorted losses and the risk caps’ sigmas are computed once per batch or per commit, and a max-var rule’s inputs once per candidate. Each item earlier increments deferred to I14, measured at that workload:

FromItemMeasuredAction
I7each trim probe re-indexes the bookthe batch 876 → 235 msfixed (derived books)
I11a projection indexed twice; exposure rows rebuilt per candidatein the line abovefixed
I11VaR unit values per evaluation; the reference losses per candidate; sigmas per candidatethe batch 235 → 88 msfixed (per batch and per commit)
I7, I11every holding’s notional, margin and membership summed again for each projectionthe batch 88 → 77 ms, a trimming deployed cap +6.7 → +0.7 msfixed (per holding, kept while the holding is untouched)
I11, I12the history and its volumes rebuilt per completed session3.9 ms a rebuild (40 contracts, 520 sessions), about 10 s over ten years of sessionskept: bounded by the window
I8atwo allocations per observation above eight state rules0.7 µs and 2.3 KB against 0.5 µs, once per barkept
I8amarked equity read two or three times an instant1.1 µs a read with 40 positionskept
I8btwo syncs when an observation and an owner event share a boundary4.4 ms a synced append, at an owner event onlykept
I9each max-order-size rule’s selector rebuilt per callfour rules: +0.5 ms a batchkept
I9entry-rate’s rescan of the batch and the history; linear Stale scans; strings formatted on the passing pathwithin the batch’s noise (±0.3 ms)kept
I9a sync per charged opening on a live sidecar4.4 ms each (none on a backtest)kept, decided 2026-10-19: the write-ahead order matters more than 4.4 ms, and live batches are small
I9, I12recheck and reopen configurations rebuilt per decision2.2 µskept
I12the covariance recomputed per batch+2.7 ms a batchkept: maintained sums would not be bit-identical to the two-pass calculation
I12the ADV per candidate and rule; sorted charge inserts; overlay clonesthe rule +4.2 ms a batch, its trim probes’ rowskept
I12the ledgers’ state copied per charged opening0.55 µskept
I13the decision log’s rule results+7 ms a batch (9%)kept

max-var itself is the largest share, +38 ms a batch: each candidate’s losses are summed over every exposure row in the canonical order, which keeps them bit-identical where an incremental sum over the changed legs would not.

Live restrictions that remain. A live run refuses at configuration, or refuses openings for want of an input, where no live source exists yet:

  • the drawdown rules, which need external cash flows (GLE-543);
  • liquidity-cap, which needs live volume and open interest (GLE-545);
  • live marks and FX (GLE-548): without marks the deployed rules, max-order-size’s notional and stale-data’s marks refuse every live opening, and max-order-size’s collar every live order with a limit or stop price; without FX rates an instrument, a margin or a cash balance in a currency other than the base is unavailable, and stale-data’s fx input is refused;
  • an adopted working order cannot be cancelled through order lifetime v2 (GLE-544);
  • combos and attached orders, which live submission refuses;
  • the live venue stamps no attempt number on fills and cancels, and stamps a cancel with its own clock, the wall clock in an emulated run (GLE-549), so the parity table compares the strategy’s updates, not its fills and cancels;
  • shared accounts (GLE-360).

Two follow-ups outside the live path: the simulator’s raw projection does not check for an unset raw view (GLE-546), and without a director --lifecycle-json fails to encode a non-finite conviction (GLE-547).

Release prerequisite. The paper-account conformance check, make conformance-ibkr-paper (Paper-account conformance), has not been run against a paper account. Its evidence is the last condition of the release.