# EABuilder Strategy Spec — Authoring Guide (v1.0)

This is the contract between **you (or your LLM)** and **EABuilder**. You describe a
trading strategy as a single JSON document; EABuilder validates it and generates a
compilable MT5 Expert Advisor (`.mq5`) from it, which then runs on EAHosting.

> **Golden rule:** anything not described in this guide or in `schema/strategy.schema.json`
> is **not allowed**. There is no raw-MQL5 escape hatch. If you need behaviour that
> isn't here, it has to become a new block — request it; don't try to smuggle code in.

The machine-readable contract is `schema/strategy.schema.json` (JSON Schema 2020-12).
This document explains the parts a schema can't: the expression grammar, the operand
namespace, and the rules of the road.

---

## 1. How to use this with an LLM

1. Give your model this file, `schema/strategy.schema.json`, and the files in `examples/`.
2. Tell it: *"Author one EABuilder strategy as a single JSON object that validates
   against the schema. Ask me about my idea first, then output only the JSON."*
3. Paste the JSON into EABuilder (or upload the file).
4. If validation fails, **paste the returned errors back to your model** — each error
   carries a `path`, a `problem`, and a `fix`, so the model can correct itself. Repeat
   until it validates.

The validator is strict on purpose. A spec that validates is one that will generate
safe, compilable code.

---

## 2. Document shape

```jsonc
{
  "specVersion": "1.0",
  "meta":   { ... },            // name, symbol, timeframe, when to evaluate
  "indicators": [ ... ],        // optional — declare reusable indicator operands
  "risk":   { ... },            // REQUIRED — always-on safety envelope
  "newsFilter": { ... },        // optional — avoid economic-calendar events
  "sessions":   { ... },        // optional — time-of-day / day-of-week windows
  "positionManagement": { ... },// optional — breakeven / trailing stop
  "rules":  [ ... ]             // REQUIRED — IF <when> THEN <actions>
}
```

`specVersion`, `meta`, `risk`, and `rules` are required. Everything else is optional.

`meta.name` and `meta.description` are free text that ends up in the generated source
(header comment + `#property description`). Keep them single-line plain text: control
characters (tabs, newlines, etc.) are **rejected** (`META_CONTROL_CHARS`) because they
would break the generated MQL5.

---

## 3. Indicators → named operands

Each indicator you declare gets an `id` (lower_snake_case). That `id` becomes an
**operand** you can use in expressions and in ATR-based stops.

```json
{ "id": "ema_200", "type": "EMA", "params": { "period": 200, "appliedPrice": "close" } }
```

Referencing `ema_200` in an expression yields the indicator's **current value**.
Use a bar index to look back: `ema_200[1]` is the value on the previous closed bar.

Supported `type`s and their `params`. **Bold** params are required; the rest are
optional. A param that does not apply to the declared type is **rejected**
(`INDICATOR_PARAM_UNKNOWN`) rather than silently ignored, and a missing required param is
rejected (`INDICATOR_PARAM_MISSING`) — so the value you ask for is always the value used.

| type             | required          | optional                        |
|------------------|-------------------|---------------------------------|
| `SMA`, `EMA`     | **`period`**      | `appliedPrice`                  |
| `RSI`            | **`period`**      | `appliedPrice`                  |
| `ATR`            | **`period`**      | —                               |
| `MACD`           | **`fastPeriod`**, **`slowPeriod`**, **`signalPeriod`** | `appliedPrice` |
| `BollingerBands` | **`period`**      | `deviations`, `appliedPrice`    |
| `Stochastic`     | **`kPeriod`**, **`dPeriod`** | `slowing`             |
| `ADX`            | **`period`**      | —                               |

`appliedPrice`: `open` `high` `low` `close` `median` `typical` `weighted`. It applies only
to price-based indicators (SMA/EMA/RSI/MACD/BollingerBands); `ATR`, `ADX` and `Stochastic`
take no applied price, so supplying one is rejected.

---

## 4. The expression grammar (the `when` field)

Each rule's `when` is a **boolean expression**. It is parsed server-side into an AST
and validated against the declared operands — it is **never** compiled as a raw string.
Keep it to this grammar:

**Operators** (precedence high → low): `!`  →  `* /`  →  `+ -`  →  `< <= > >= == !=`  →  `&&`  →  `||`. Parentheses `( )` group.

**Functions** (the only ones allowed):

| function                | meaning                                                        |
|-------------------------|----------------------------------------------------------------|
| `crossesAbove(a, b)`    | `a` was ≤ `b` last bar and is > `b` now                         |
| `crossesBelow(a, b)`    | `a` was ≥ `b` last bar and is < `b` now                         |
| `abs(x)`                | absolute value                                                 |
| `min(a, b)` / `max(a, b)` | smaller / larger of two values                               |

**Operands** — identifiers resolving to a number, optionally bar-shifted with `[n]`
(`0` = current, `1` = previous closed bar, … up to `[100]`):

| operand                        | meaning                                          |
|--------------------------------|--------------------------------------------------|
| *any indicator `id`*           | that indicator's current value                   |
| `price.open/high/low/close`    | current bar OHLC                                 |
| `price.bid` / `price.ask`      | live bid / ask                                   |
| `spread`                       | current spread in points                         |
| `account.equity` / `account.balance` | account figures                            |
| `account.drawdownPct`          | current drawdown from peak, percent              |
| `positions.open`               | total open positions for this EA                 |
| `positions.long` / `positions.short` | open count per side                        |
| `time.hour` / `time.minute`    | server time (0–23 / 0–59)                        |
| `time.dayOfWeek`               | 1 = Mon … 7 = Sun                                |

Numeric literals (`30`, `1.5`) and booleans (`true`, `false`) are allowed.
Strings, assignment, loops, and any identifier not in the table above are **rejected**.

Keep the whole expression on a single line: control characters (tabs, newlines, etc.) are
**rejected** (`RULE_WHEN_CONTROL_CHARS`). The lexer treats them as whitespace, so they would
otherwise pass validation but corrupt the generated MQL5 rule comment.

Examples:

```text
crossesAbove(ema_fast, ema_slow)
price.close > ema_200 && rsi_14 < 35 && rsi_14[1] >= 35
spread <= 20 && positions.open == 0 && time.hour >= 7
```

---

## 5. Rules — IF `when` THEN `actions`

```json
{
  "id": "long_entry",
  "when": "price.close > ema_200 && rsi_14 < 35",
  "cooldownBars": 4,
  "actions": [
    { "type": "openTrade", "side": "buy", "lot": 0.1,
      "sl": { "mode": "atr", "indicator": "atr_14", "multiplier": 1.5 },
      "tp": { "mode": "points", "value": 600 } },
    { "type": "alert", "channel": "telegram", "message": "Long {symbol} @ {price.ask}" }
  ]
}
```

Rules are evaluated in order, once per bar (or per tick if `meta.evaluateOn` is `tick`).
`cooldownBars` throttles re-firing.

### Action types

- **`openTrade`** — market order. `side` (`buy`/`sell`), `lot`, `sl`, `tp`, optional `tag`/`comment`.
- **`openPending`** — pending order. `side` (`buy`/`sell`), `pending` (`limit`/`stop`), `price`
  (the `placement`, see below), `lot`, `sl`, `tp`, optional `expireMinutes`, `tag`/`comment`.
- **`closeTrade`** — `target` (`all`/`long`/`short`/`tag`); with `tag` close only matching
  positions. Optional `amount` makes it a **partial** close (see below).
- **`cancelPending`** — delete pending orders. `target` (`all`/`buy`/`sell`/`tag`).
- **`alert`** — `channel` (`email`/`telegram`/`discord`/`all`) + `message`. Alerts are delivered
  by the EABuilder relay to **your own** linked email / Telegram chat / Discord channel (never
  to anyone else). Included with EAHosting; otherwise a 300 KES/month add-on.

Like `meta.name`/`meta.description`, the free-text action fields — an `alert` `message` and an
`openTrade`/`openPending` `comment` — end up in the generated source (a string literal). Keep
them single-line plain text: control characters (tabs, newlines, etc.) are **rejected**
(`ACTION_CONTROL_CHARS`) because they would break the generated MQL5.

### Pending order price (`price` — the `placement`)

The four combinations are buy-stop (breakout up), sell-stop (breakout down), buy-limit
(pullback), sell-limit (rally fade). `price` says where the order sits:

| mode     | fields                    | meaning                                                  |
|----------|---------------------------|----------------------------------------------------------|
| `price`  | `value`                   | absolute price level                                     |
| `points` | `value`                   | offset in points from current price (direction by type)  |
| `atr`    | `indicator`, `multiplier` | offset = ATR × multiplier from current price             |

`sl`/`tp` on a pending order are measured from the **pending entry price**, not the current
price. `expireMinutes` cancels the order if it hasn't filled in time (omit = good-till-cancelled).
Pending orders count toward `risk.maxOpenPositions` alongside open positions, so a rule can't
endlessly stack them.

### Partial closes (`amount` on `closeTrade`)

Omit `amount` for a full close. Otherwise:

| mode      | fields  | meaning                                              |
|-----------|---------|------------------------------------------------------|
| `percent` | `value` | close this % of each matching position's volume      |
| `lots`    | `value` | close this many lots from each matching position     |

Volumes are floored to the broker's lot step; if the result is below the minimum lot, that
position is left untouched (you can't partial-close below the minimum).

### Stops & take-profits (`sl` / `tp`)

| mode     | fields                          | meaning                                  |
|----------|---------------------------------|------------------------------------------|
| `points` | `value`                         | fixed distance in points                 |
| `price`  | `value`                         | absolute price level                     |
| `atr`    | `indicator`, `multiplier`       | distance = ATR × multiplier (volatility-scaled) |
| `none`   | —                               | no stop / no target                      |

### Lot sizing (`lot`)

- A bare number → fixed lots: `"lot": 0.1`.
- `{ "mode": "fixed", "value": 0.1 }` — same, explicit.
- `{ "mode": "riskPercent", "value": 1.0 }` — size so the SL distance risks 1% of equity.
  Requires an `sl` with a measurable distance — mode `points`, `price`, or `atr` (a `price`
  stop is sized off `|entry − level|`). Only `sl.mode: "none"` (or no `sl`) is rejected
  (`RISK_SIZING_NO_SL`).

> **Synthetic indices + `riskPercent` (warning `RISK_PERCENT_SYNTHETIC`).** On Deriv
> synthetic indices (Volatility, Boom/Crash, Step, Jump, crypto/indices — anything whose
> `meta.symbol` category isn't Forex), the broker under-reports `SYMBOL_TRADE_TICK_VALUE`.
> The generated sizing math is correct, but with a too-small per-point value the requested
> `riskPercent` lot inflates and gets clamped to **`risk.maxLot`** — so "1% risk" silently
> degrades to "always max lot" (an observed cross-axis backtest on Vol 75 hit ~577% gross
> loss / ~70% drawdown). Validation emits a **warning** (never an error), because
> `meta.symbol` is best-effort and the EA still generates. To keep risk bounded on these
> symbols, use a **fixed lot**, or set **`risk.maxLot` conservatively** so the cap is a sane
> ceiling. (Unknown symbols — not in the default catalog — are not flagged.)

### Alert message placeholders

`{symbol}` `{price.bid}` `{price.ask}` `{price.close}` and any indicator `id` (e.g. `{rsi_14}`)
are substituted at send time. Telegram goes through the platform's bot; email through the
platform mailer — you don't configure transport here.

---

## 6. The risk envelope (`risk`) — required, and enforced

```json
"risk": {
  "maxLot": 0.5,
  "maxOpenPositions": 1,
  "maxDailyLossPct": 4,
  "maxSpreadPoints": 50,
  "slippagePoints": 15
}
```

These are **hard limits the generated EA enforces on every order** — and the platform
**clamps them again** to your plan/account ceiling. If you ask for `maxLot: 50` but your
plan caps at `2`, the EA runs with `2`. Treat this block as the safety floor under
everything your rules try to do; it cannot be disabled.

---

## 7. News & sessions

- **`newsFilter`** uses the platform's central economic calendar — you only declare the
  rule (`currencies`, `minImpact`, the block window, and whether to `no_new_trades` or
  `flatten`). The EA never scrapes anything itself.

  > **Tester-inert.** The MetaTrader Strategy Tester provides **no economic calendar**
  > (and the Deriv-Demo feed supplies none either), so the calendar is empty during a
  > backtest. The gate is generated and wired correctly, but with nothing to fire against
  > it has **no effect in a backtest** — `newsFilter` can only be verified **live or on a
  > forward test**. (Sessions, by contrast, work fine in the tester — see below.)
- **`sessions`** restrict *new entries* to the given day/time windows. Exits and position
  management still run outside them. Unlike `newsFilter`, sessions rely only on bar/server
  time, so they **do** take effect in a backtest.

---

## 8. What gets rejected (common LLM mistakes)

- Inventing indicator types or operands not listed here.
- Referencing an indicator `id` in an expression without declaring it in `indicators`.
- Raw MQL5, function calls other than the four whitelisted ones, or string operands.
- `riskPercent` lot sizing with `sl.mode: "none"` (no distance to size against).
- Omitting `risk` or `rules`, or an empty `rules` array.
- Extra/misspelled fields — the schema is `additionalProperties: false` everywhere.

When in doubt, copy the closest file in `examples/` and modify it.
