Feature-rule runner

featurerule (strategies/featurerule, GLE-1307) runs a frozen Feature Analyser rule. AlgoChart exports the rule as a draft. The runner trades the draft through the engine’s own orders, fills, account and export, and writes a trace that the app checks against its own research. It has two inputs, both paths: Draft, the draft file, and Trace, where the trace is written. The rule itself is not an input, so a --config sweep cannot refit it.

bin/algo run --adapter bin/marketfeed-adapter --adapter-inputs Dir=/usr/local/marketfeed \
  --strategy bin/featurerule --inputs Draft=/abs/draft.json,Trace=/abs/trace.jsonl \
  --symbols fut:XCEC:HG:cont:adj=none --interval 1h \
  --start 2021-10-31T00:00:00Z --end 2026-10-31T00:00:00Z \
  --commission-per-share 2.50 --slippage-ticks 1 --export /abs/run.algorun
bin/featurerule --capabilities

--capabilities prints algolang.feature-rule-capabilities/1: the runner’s version (1.0.0), its build, and every format, feature, operator, exit and contract policy it honours. The app refuses a handoff that needs anything the list leaves out.

The draft

A draft is algolang.feature-rule/1 JSON. The runner validates all of it at init and refuses, with a stable code, anything it cannot honour exactly. It never approximates, rounds or drops a clause. Unknown keys are refused everywhere except inside lineage, which is free-form and copied into the trace header. The draft’s digest is the SHA-256 of the file’s bytes as written. The app writes its canonical sorted-key encoding, and the header’s draftSortedKeys says whether the file was in that form.

The feature program:

  • price: the bar’s open, high, low or close. On adj=none this is the raw served series.
  • rsi: nseries Series.RSI(length) (Wilder’s, seeded with the mean of the first length changes) over the last warmup bars, the current bar included, on a price feature. The value is traced from the length+1th bar. The rule waits for minBars known bars.
  • atr: the average true range over the last warmup bars. Seed sma is the mean of the last length true ranges, which is what the research’s ATR(14) is. Seed wilder is TA-Lib’s recursion. ATR is for scaling only, with role: "scale"; a rule that reads it is refused.
  • hour_of_day (clock bar_end): the wall-clock hour of the bar END in the draft’s IANA zone, DST applied. The 15:00 to 16:00 bar reads 16.
  • day_of_week (clock trade_date): 0 Monday to 6 Sunday, of the bar’s exchange trade date. The trade date is the session whose span holds the bar’s close less 1 ns, from the exchange calendar. The runner asks for the calendar only when the draft has this feature. A bar on no trade date is skipped_data.

A price, rsi or atr feature may name the price frame it reads, with frame: none, panama or ratio (GLE-1308). Without frame it reads the signal series’ own frame. That frame comes from the symbol:

  • A bare root (fut:XCEC:HG) or a bare :cont is panama.
  • :cont:adj=X is X.
  • A dated contract, or a symbol with no adjusted view, is none.

v1 honours a frame only when it is the series’ own, and capabilities say so with "frames": ["series"]. Any other frame is refused with frame_unsupported, for example “rsi14 reads panama; the signal series is none”. The trace header records the series’ frame.

The rule is a conjunction (all) of comparisons, each a feature against a float64 constant at full precision: <, <=, >, >=, and == or != on the two integer features only. any, or and not are refused.

The policy (v1):

  • One series, which is also the traded market.
  • A fixed side, long or short, quantity 1.
  • One position, no pyramiding. A signal while an order is working or a position is open is ignored and traced ignored_busy.
  • Entry next_open: a Day market order at the signal bar’s close, filled at the next bar’s open.
  • Exit bars, on one of two clocks:
    • Clock signal: the exit bar is E = S + bars, with S the signal bar. An entry still working when its next fill would be on E or later is cancelled, traced fill_after_exit, rather than extending the trade. bars must be at least 2. A signal on E itself is ignored, because the exit order is working at that close.
    • Clock fill: E = F + bars - 1, with F the bar the entry filled on.
  • Exit price:
    • bar_close: a market-on-close order settled at E’s close. This is a simulation, and the runner logs a one-line warning that no closing auction prices the market there.
    • next_open: a market order at E’s close, filled at E+1’s open.
  • Bars are served bars, so a Friday signal holds over the weekend.

Costs are declared per side, with the instrument’s tick size, point value and currency. Zero costs need explicitZero: true. Every fill’s commission and slippage are checked against the draft’s. A mismatch fails the run with costs_mismatch, naming the --commission-per-share and --slippage-ticks flags to set.

The runner also checks the run. The draft’s symbol must be a run symbol. The run’s bars must lie within the evaluation window; the engine’s Init window is the bars served, not --start and --end. At the first bar the instrument the engine resolved must match the draft’s tick size, point value, currency and exchange clock, and the account must start with the draft’s capital.

Contracts and the engine’s limits

The draft schema’s hold_entered_until_exit is refused. On a continuous future the engine rolls an open position at the served cut, with two market legs, each charged commission and slippage. A strategy cannot read the roll schedule in advance, so the runner can neither hold the entered contract nor skip an entry whose exit would fall after the roll. It honours roll_at_served_cut/1 instead, and traces every roll: whether a position was open, whether an order was working, the leg count, and the raw gap.

The roll legs’ fills are the engine’s and never reach the strategy. They are in the export, not the trace. The trace’s trade statistics therefore cover only the trades that crossed no roll; a trade held over a roll is counted crossedRoll and its P&L is the export’s. skipped_roll is never written.

The trace

algolang.feature-rule-trace/1 is JSON Lines in event order, which is time order. Times are unix seconds of the bar END. The lines are:

  • header: the runner, version and build, and the draft’s digest, id and revision. Also the series, the zone, both tz database versions, the lifetime, the Init window, the lookback, the contract policy, and the lineage and limitations.
  • refused: the code and reason, when the run is refused. It is the last line.
  • instrument, and calendar when day_of_week is used: what the engine resolved.
  • bar: one per bar, with every feature’s value (null when unknown), the signal, the action, the dated contract and the bars known. The action is one of none, warmup, submitted, ignored_busy or skipped_data.
  • order, fill and cancel. A fill records its price, contract, commission and slippage, the bar it priced on and its reference price (that bar’s raw open, or the close for market-on-close).
  • roll.
  • summary: the counts and the trade statistics.

The --export file is the engine’s ordinary export, roll legs included.

Tests

go test ./strategies/featurerule/ runs:

  • The copper parity check against the research’s fixtures in testdata/ (sha256s checked on read; testdata/gen_fixtures.py regenerates them). RSI(14) matches within 1e-9 from bar 2,000, the Chicago hour on every bar, and the 793 reference matches exactly.
  • A replay of the holdout through the policy, which reproduces the research’s per-trade prices and mean net.
  • Policy tests on crafted bars with a fake engine.
  • Draft refusal tests, one per refusal.
  • cf21 conformance through the engine and the file-csv adapter under both lifetimes. Two drafts reproduce their frozen blotters (FEATURERULE_FREEZE_CF21=1 rewrites them), and their traces reconcile with the blotters fill for fill.