A strategy language the model can’t escape
The JSON DSL at the center of āagman: why the LLM’s only output is a typed strategy document, what the grammar covers, how the compiler turns it into an execution plan, and where the design stops protecting you.
Contents
The most important design decision in āagman was what the language model is allowed to output. The answer is one thing: a JSON document that conforms to a strict schema, which we called the Strategy Intermediate Representation (SIR). The model doesn’t write Python, doesn’t call the broker, and doesn’t produce free text that anything downstream interprets. It picks building blocks from a closed set, and an interpreter that we wrote runs them.
This part describes the language in enough detail to rebuild it:
- Why a DSL instead of code generation or function calling
- The grammar: market, universe, features, signals, risk, execution and backtest range
- The compiler: what it checks, and why it rejects instead of repairing
- How the same document drives backtests and screens, and what paper and live add
- What the language can’t protect against
Background: three ways to let a model express a strategy#
A user says “buy NIFTY 50 stocks when RSI(14) crosses above 30, stop 2%, exit after 10 bars.” There are three broad ways to turn that into something a machine runs.
Generate code. Ask the model for a Python function and run it. This is the most expressive option and the one most demos use. It’s also unacceptable here: running model-written code means any prompt injection that reaches the model can reach the interpreter. Sandboxing reduces the blast radius, but it doesn’t make the output correct, and you can’t diff two generated functions to know whether they mean the same strategy.
Function calling. Give the model a place_order(symbol, qty, side) tool. This is safe for single actions but can’t express conditional logic (“when X, then Y, unless Z”), so the model ends up looping and calling tools in sequence. The strategy then lives in the model’s context window, not in an artifact you can store, test or audit.
A typed DSL. Define a small language whose every construct maps to code we wrote and tested, and have the model emit a document in it. The strategy becomes data: it can be validated, hashed, stored, compared, backtested, approved and replayed. This is what we built.
The trade is expressiveness. Anything the grammar can’t say, āagman can’t do. The first version of the runtime was deliberately narrow (long-only, market orders, one timeframe, backtest only). Every limit was a grammar or runtime decision rather than an accident, and each was lifted only when it had a test matrix behind it.
The grammar#
A full SIR document (schema version 1.2.0) has these top-level sections:
| Section | Answers | Example |
|---|---|---|
status, reasoning, name, description |
Is it ready, and what is it | READY, PARTIAL or INVALID |
market, universe |
Which venue and which instruments | NSE, equity, RELIANCE |
data |
Which bars, warmup, session filter | bar_timeframe: "1d", warmup_bars: 14 |
features |
The indicators the rules use | {"id": "rsi_14", "type": "RSI", "params": {"period": 14}} |
signals |
When to enter and when to exit | Rule groups over indicators, prices and times |
portfolio, risk |
Capital, sizing, stops, limits | FIXED_QTY or PERCENT_EQUITY, stop-loss, take-profit |
execution, backtest |
Mode, product, order type; the date range | BACKTEST, PAPER or LIVE; order type, product, start and end |
explainability |
The assumptions and open questions | Each assumption can ask for confirmation |
The compiler turns that into a smaller ExecutionPlan that the engine runs. The following example is the shape of a compiled plan for a simple RSI strategy, trimmed from one of the golden fixtures in the repository:
{
"intent": "BACKTEST",
"date_range": {"start_date": "2025-02-03", "end_date": "2026-01-30"},
"warmup_bars": 14,
"universe": {"symbols": [{"symbol": "NIFTY50", "venue": "NSE", "asset_class": "INDEX"}]},
"indicators": [{"id": "rsi_14", "type": "RSI", "params": {"period": 14},
"timeframe": "1d", "source": "close"}],
"rules": {
"entry": [{"rules": [{"id": "rsi_cross_above_30", "type": "INDICATOR",
"condition": {"left": {"indicator_id": "rsi_14"}, "op": "crosses_above", "right": {"value": 30}}}]}],
"exit": [{"rules": [{"id": "rsi_cross_below_70", "type": "INDICATOR",
"condition": {"left": {"indicator_id": "rsi_14"}, "op": "crosses_below", "right": {"value": 70}}}]}]
},
"sizing": {"model": "FIXED_QTY", "value": 1}
}Three properties of this grammar did most of the work:
Operators and types are enums. A comparison is one of >, >=, <, <=, ==, !=, crosses_above or crosses_below. An operand is an indicator, a literal, a price field or a time. A model that emits an operator outside the list gets a validation error, not a best guess.
Indicators resolve through a registry. The runtime builds its registry from TA-Lib: single-output functions such as SMA, EMA, RSI, ATR, ADX, CCI, MFI, OBV and about sixty more, plus the candlestick-pattern functions, which return boolean series (“bullish engulfing on the last closed bar”). Multi-output indicators such as BBANDS, MACD and STOCH expose each output as its own series id, so a rule can still refer to one id and one series. A document that names an unsupported indicator fails at compile time.
Rules are groups. Entry and exit are lists of rule groups. Conditions inside a group are all required, and any group can fire, which gives you AND within a group and OR across groups without a free-form expression language.
Look-ahead is a grammar concern#
The cheapest place to prevent look-ahead bias is where signals turn into trades. Conditions are evaluated at the close of bar t, and the adapter shifts the resulting entry to the next bar, so a rule can’t act on a close it has only just seen (Part 4). Putting that in the engine, not in each document, means a model can’t get it wrong.
Exits, sizing and direction#
Exits and risk are explicit fields, not free text. The first version supported:
- Stop-loss and take-profit, each in percent or in points from the entry price.
- A trailing stop, in percent or points.
- A time stop, after N bars.
- Rule exits, which are the exit rule groups above.
- Account limits, such as a maximum number of trades per day and a maximum daily loss.
Sizing has two models: a fixed quantity, or a percentage of equity. Direction is long or short, and the order type is market, limit, market-if-touched, cover or after-market, with the product (intraday, delivery or carry-forward) chosen per strategy. The execution.mode field selects backtest, paper or live.
Options and multi-leg strategies#
Options and futures strategies carry a list of legs. Each leg names its side, instrument type, expiry and a strike selector: an offset from the at-the-money strike, or a target delta. Strike selectors are where a model is most likely to be wrong, so they are explicit enums, and the document says which side of the trade BUY and SELL refer to. A four-leg structure such as an iron condor is one strategy with four legs that are placed together or not at all.
The compiler#
A SIR document is source code, and it gets compiled before anything runs. The compiler lives in the Python backtester; the TypeScript backend validates the document with Zod before sending, so most errors never cross the wire.
graph LR
A["SIR JSON
from the model"] --> B{"1. Schema
validation"}
B -->|fail| X["Reject
+ reason"]
B --> C{"2. Capability
check"}
C -->|unsupported| X
C --> D{"3. Compile and
cross-check"}
D -->|fail| X
D --> F["ExecutionPlan
+ sir_hash"]
class B,C,D amber
class X red
class F green1. Schema validation. The full document is validated with Pydantic models: closed enums, required sections and typed fields, so an unknown operator or a missing section fails here. The backend runs the equivalent Zod check first.
2. Capability check. The schema is wider than any one mode. A separate check reports which features the requested mode supports: a live document needs a product and order type the broker accepts, a screener cannot carry sizing, and a paper run cannot use features the live path would refuse. Anything unsupported is a warning or a rejection instead of a silent downgrade.
3. Compile and cross-check. The compiler turns universe, indicators, rules, session filter, risk, sizing and date range into plan types. It rejects duplicate indicator ids, unknown indicator types or bad parameters, empty entry rules, a start date after the end date, and any rule that refers to an indicator the document never defined. The plan records warmup_bars so the loader fetches enough history before the start date.
4. The hash. The plan is stamped with a sir_hash: a SHA-256 over the canonical JSON of the document (sorted keys, compact separators). The same document always gives the same hash, which is how a result, a stored strategy and an approval can all point at one exact thing.
The output is an ExecutionPlan, a tree of typed models that the adapters consume. A backtest plan must have sizing; a screener plan must not have risk or sizing. The plan is closed to extra fields, so a stray key is an error, not ignored.
Reject, don’t repair#
When compilation fails, the request is rejected with the error. The model can try again, and the agent can retry, but the new attempt goes through the whole compiler. We never patch the model’s output: no “the operator is close enough to crosses_above,” no filling a missing stop with a guess.
This felt strict early on. It turned out to be the property that made everything downstream simple: every plan that reaches the engine came out of the same stages, so a backtest result and a screen rest on a document that passed the same checks.
The runtime formats its errors as one line with a category (compilation, validation, data load or execution), where it happened, what happened and why. The agent turns those into questions for the user, such as “Did you mean RELIANCE?” or “What stop do you want?”.
One document, every mode#
The same compiled plan feeds the adapters, chosen by the plan’s intent and execution.mode. The following table shows what each does with it:
| Mode | Adapter | Reads | Writes |
|---|---|---|---|
BACKTEST |
Backtest adapter | Parquet history | Summary, trades, equity curve |
SCREENER |
Screener adapter | Parquet history for the universe | Matching symbols with indicator snapshots |
PAPER |
Paper runner in the execution service | Closed bars as they finalize | Simulated fills, no broker contact |
LIVE |
Live runner in the execution service | Closed bars as they finalize | Signed order commands, after the gates |
A user who approves a live strategy approves the sir_hash of the plan they backtested and paper traded, so editing a stop changes the hash and the approval no longer applies. Part 7 describes the approval.
Versioning the language#
Every document carries a schema_version (1.2.0 today), the execution plan carries its own version, and the compiler records a compiler version for reproducibility tracking. Stored strategies are never rewritten in place. Older documents are upgraded when they are read, by small versioned functions that map version N to N+1, so a strategy saved under an older grammar still compiles and still hashes to the value it was approved under.
Where the language stops protecting you#
A DSL makes invalid actions unrepresentable. It doesn’t make valid actions correct. If a user says “buy 10” and the model writes a quantity of 100, the document is valid. Three things were meant to bound that risk, and none of them is the grammar:
- The plan is shown back to the user in plain language before a run, and the explainability section lists every assumption the model made, with a flag for the ones that need confirmation.
- Pre-trade gates enforce position, notional and loss limits that the user set and the model can’t change (Part 7).
- Every document and plan has a hash, and the backend stores strategy versions append-only, so a wrong parameter is visible after the fact.
The claim I’m comfortable defending is narrow: the model can’t cause an action outside the grammar, and can’t cause a permitted action outside the user’s limits. It can still propose a wrong but permitted parameter, and the confirmation step is what catches that.
What I’d do differently#
Constrained decoding. We validated after generation. With a self-hosted model, I’d compile the JSON Schema into a grammar and constrain decoding so invalid documents can’t be generated at all. It wouldn’t remove the compiler (resolution and semantic checks still need data), but it would remove most retries.
Keep the first grammar small. The first runtime was deliberately narrow, and that was right: every operator and indicator added a column to the test matrix. I’d resist composition (an indicator computed from another indicator) until usage justifies it.
Conclusion#
The strategy language is where the probabilistic part of āagman ends. Upstream of it, a model reads a sentence and guesses. Downstream, a compiler, an engine and a policy layer do exactly what the document says, every time, and the document has a hash. The next part covers the upstream side: how the agent desk turns a sentence into that document.
I built āagman as Founding Software Engineer at KotiLabs, July 2025 to April 2026.
Responses
Select any passage to highlight it or comment on that line. Highlights stay private, in this browser.