FNOTraderAI Access

FNOTrader Backtest — Strategy-Definition JSON Schema (authoritative reference)

Purpose: enable an LLM (or any developer) to author ANY valid strategy params JSON for FNOTrader Algo and to run/interpret backtests. Every field below is generated from and verified against the live product; where behavior is genuinely ambiguous it is marked AMBIGUOUS rather than guessed.

All times everywhere are IST (Asia/Kolkata). All money values are ₹ (INR). Booleans must be real JSON booleans (anything that is not a JSON true is treated as false).

0. Mandatory fields — write ALL of these on every save

A strategy is only finished when it can be backtested AND deployed. These five fields decide that; the rest of this document is detail.

🔴 Backtest boundaries — stay inside these unless the user asks otherwise. The replay is bounded by what the option archive holds, and exceeding a bound does not fail loudly, it SUBSTITUTES — so the report describes a different position from the one you wrote, while looking entirely normal.

Bound Value What happens past it
Strike depth \|strike_offset\| <= 10 is always safe Deeper legs replay at the NEAREST archived strike on the same side (never crossing to ITM), and the run reports the substitution per leg. Real depth is DATA, varying by source and by (day, expiry) — the SQLite parity file stops at ±10, prod carries ~±17 — so 10 is the safe floor, not the ceiling.
Underlyings NIFTY, SENSEX, BANKNIFTY FINNIFTY / MIDCPNIFTY / stocks / MCX have no usable history — forward-test instead.
History NIFTY from 2021-05-28 · SENSEX 2023-05-15 · BANKNIFTY 2024-09-30 Earlier dates return nothing.
Resolution 1-minute bars Live is ~1-second ticks; intra-minute behaviour cannot be replayed.

When a user asks for something outside a bound, say so and offer the nearest in-bounds version — do not silently emit the out-of-bounds value and report the substituted result as though it were theirs.

Field Rule If you omit it
underlying "NIFTY" / "SENSEX" / "BANKNIFTY" / a supported stock, at strategy level Rejectedsave_strategy returns invalid_params
legs[] Non-empty. Each leg: side (BUY/SELL) and either option_type (CE/PE) + strike_offset, or an explicit symbol Rejected
strike_selection "relative" for the normal strike_offset path (see §3.3 for the other modes) Auto-filled + warning. The live engine reads an empty value as "absolute" and demands a literal per-leg symbol — omitting it used to produce a strategy that backtests and is then refused at forward-test/deploy
start_time "HH:MM" IST entry, e.g. "09:20" Defaults to 09:20 + warning — a time the user never asked for
square_off_time "HH:MM" IST exit, e.g. "15:25" Defaults to 15:25 + warning

Minimum viable strategy:

{
 "underlying": "NIFTY",
 "strike_selection": "relative",
 "start_time": "09:20",
 "square_off_time": "15:25",
 "legs": [
 { "side": "SELL", "option_type": "CE", "strike_offset": 2, "qty": 75, "expiry": "current" },
 { "side": "SELL", "option_type": "PE", "strike_offset": -2, "qty": 75, "expiry": "current" }]
}

save_strategy returns a warnings array naming anything it defaulted or auto-filled. Read it and re-save with the fields set rather than shipping a strategy built on defaults.

1. SavedStrategy envelope — POST /api/saved-strategy..

{
 "name": "My Short Straddle", // REQUIRED 
 "type": "multi_leg_basket", // REQUIRED 
 "broker_id": "", // OPTIONAL
 "params": {... }, // the strategy recipe — see §2
 "tags": "intraday,straddle", // OPTIONAL free-text string (not array)
 "remarks": "sold 9:20, exits 15:25" // OPTIONAL free-text string
}
Field Type Notes
id string Omit on create. If omitted, upsert-by-name (update if a strategy of the same name exists for the user, else insert with a new UUID —). Supplying id updates that row.
name string Required. Unique per user (upsert key).
type string Required. Valid values registered in the engine: "multi_leg_basket" (the real options-basket engine,) and "noop" (smoke-test placeholder,). Any other value fails at run time with unknown strategy type. For every real strategy use "multi_leg_basket".
broker_id string Optional. Used only when the strategy is deployed/run live (resolution order: SavedStrategy.broker_id → request broker → boot default;,). Irrelevant for backtesting.
params object The recipe. §2 onward.
tags, remarks string Stored verbatim; not interpreted.

Response: the same object with id, user_id, created_at, updated_at filled.

Other endpoints: GET /api/saved-strategy (list), GET/DELETE /api/saved-strategy/{id}, POST /api/saved-strategy/{id}/run (deploy live), POST /api/saved-strategy/{id}/rename.

2. params — top-level fields read by the backtest parser

Parser: ParseStrategy —. Unknown fields are silently ignored (dropped with warnings only for the cases noted). The parser is deliberately tolerant: numbers may be JSON numbers or numeric strings (asFloat,), but booleans must be JSON true/false.

2.1 Identity / timing

Key Type Default Semantics
name string Display name of the run (stamped by ensureRunName,).
underlying string request-level fallback Authoritative underlying. Resolution order (declaredUnderlying,): top-level underlying → first spot/future instruments[].symbol → first legs[].underlying → first synthetic_instruments[].underlying. Values ""/NONE/NULL/AUTO are treated as unset. If it disagrees with the backtest request's underlying the request is auto-switched with a warning.
start_time string "HH:MM" "09:20" Earliest entry (parseHHMM — invalid → fallback).
square_off_time string "HH:MM" "15:25" Forced flatten.

2.2 Legs

legs — REQUIRED non-empty array of leg objects (§3). ParseStrategy errors with "strategy has no legs" / "no usable legs after parsing" otherwise (1411-1413).

2.3 Combined (basket) SL / Target

Legacy simple form (still fully supported, 0 = disabled):

Key Type Semantics
combined_sl_inr number Basket MTM loss ₹ threshold.
combined_target_inr number Basket MTM profit ₹ threshold.

Typed form. combined_sl_type / combined_target_type ∈:

type value normalised to value read from Trigger meaning
"pnl", "inr", "" (or unknown) pnl combined_sl_inr / combined_target_inr ₹ basket-MTM threshold (default path)
"pct" pct combined_sl_pct / combined_target_pct % of total combined entry premium (adverse for SL, favourable for TG)
"points" points combined_sl_points / combined_target_points combined ₹ premium move = Σ
"underlying_move" same combined_sl_underlying_move / combined_target_underlying_move spot points either direction
"underlying_move_up" underlying_move_long same field upward spot move only
"underlying_move_down" underlying_move_short same field downward spot move only
"atr" / "atr_mult" atr_mult combined_sl_atr_mult / combined_target_atr_mult × underlying ATR
"delta" delta combined_sl_delta / combined_target_delta basket net-

On-hit handler fields:

Key Type Default Semantics
combined_sl_action / combined_target_action string "" See action enum §5. close_and_re_enter_strategy gets real re-entry semantics; close_all_losing / close_all_profitable / move_sl_to_cost_profitable_legs are accepted; unlisted values behave like the default close_current_strategy at basket level.
combined_sl_n_times / combined_target_n_times int 1 Max fires per day.
combined_sl_wait_sec / combined_target_wait_sec int 0 Cooldown before re-entry.
combined_sl_breach_wait_sec int 0 Debounce — condition must stay true this many seconds before firing.
combined_sl_freq / combined_target_freq string "tick" "tick" | "xm_close" (backtest granularity is the 1-min bar; only xm_close with interval ≥ 2 actually throttles —).
combined_sl_freq_interval_min / combined_target_freq_interval_min int 0 X for xm_close.
combined_sl_qty_pct / combined_target_qty_pct number 100 Slice exited when the (synthesised) primary condition fires — % of the basket's ORIGINAL qty. The builder always emits 100 (conditions are ONE stop / ONE target); partial booking is the scale-out ladder below.

OR-conditions (the modern, preferred schema)combined_sl_conditions / combined_target_conditions: arrays of Condition (§4.1), parsed at. These are ONE SL / ONE target composed of several triggers (full exit). When present they REPLACE the single typed field; when absent the engine synthesises an equivalent one-element array from the legacy fields (synthConditions), so both schemas behave identically. combined_sl_combine / combined_target_combine: "OR" (default — any one condition fires the exit) or "AND" (all must be simultaneously true; the first condition's action runs).

SCALE-OUT LADDER (2026-08-19) — combined_sl_levels / combined_target_levels. A separate block from the conditions: "how much to exit at each level". Ordered rungs, one metric per ladder, each rung fires once and exits its slice; the strategy keeps running until a full (100%) rung or a condition flattens it. Both blocks run together — a condition firing flattens everything, rungs scale out on their own. Arrays of ExitLevel (§4.2):

Key (per rung) Type Default Semantics
trigger_mode string the combined type The ladder's metric — stamp the SAME value on every rung: pct | points | pnl | underlying_move | underlying_move_up | underlying_move_down | delta | atr. Measured exactly like a combined condition of that type (SL = adverse, TGT = favourable).
trigger number Level value in the metric's unit (e.g. 2 with atr = 2×ATR; 10 with points = 10 premium pts). ≤ 0 = inert rung.
qty_pct number 100 % of every leg's ORIGINAL qty to exit at this rung — 20,20,20,20,20 flattens exactly on the fifth; the last rung should carry the remainder. ≤0 / ≥100 = everything left (full rung).
qty_lots int 0 N lots per leg to exit (> 0 overrides qty_pct); capped at what the leg still holds, snapped down to a lot multiple.
qty_unit "pct" | "lots" "pct" UI round-trip only.
actions array [] Optional follow-up (actions[0].type, e.g. close_and_re_enter_strategy); the builder emits none.
freq / freq_interval_min tick Per-rung check frequency (the builder copies the tab's Check Frequency).

Also store combined_sl_ladder_type / combined_target_ladder_type (the metric) for the builder to round-trip. Semantics: every partial rung hit on a bar/tick fires its slice (a gap that jumps two rungs books both); a full rung hit absorbs the partial ones; SL rungs honour combined_sl_breach_wait_sec. The slice's ₹ is locked at its fill price (live + backtest); pct / points / atr / underlying_move rungs keep measuring the move from basket entry at ORIGINAL size ("every 2×ATR → book 20%" reads as a price move), while pnl rungs see realized + remaining-qty MTM. (Conditions still accept qty_pct / qty_lots for API callers — a partial condition exits its slice the same way — but prefer the ladder.)

Example — ONE target at +₹8,000 plus a ladder booking 20% at every 2×ATR favourable move (flat at 10×):

"combined_target_type": "pnl", "combined_target_inr": 8000,
"combined_target_conditions": [ {"type": "pnl", "value": 8000, "qty_pct": 100, "action": "close_current_strategy"}],
"combined_target_ladder_type": "atr",
"combined_target_levels": [
 {"trigger_mode": "atr", "trigger": 2, "qty_pct": 20, "qty_unit": "pct", "actions": []},
 {"trigger_mode": "atr", "trigger": 4, "qty_pct": 20, "qty_unit": "pct", "actions": []},
 {"trigger_mode": "atr", "trigger": 6, "qty_pct": 20, "qty_unit": "pct", "actions": []},
 {"trigger_mode": "atr", "trigger": 8, "qty_pct": 20, "qty_unit": "pct", "actions": []},
 {"trigger_mode": "atr", "trigger": 10, "qty_pct": 20, "qty_unit": "pct", "actions": []}]

Lots flavour: {"trigger_mode": "points", "trigger": 10, "qty_lots": 2, "qty_unit": "lots", "actions": []} — every 10 premium points, book 2 lots off each leg.

Combined trailing SL — nested trailing_sl object:

"trailing_sl": { "enabled": true, "trigger_inr": 2000, "step_inr": 500, "lock_inr": 1000 }

All three of trigger_inr, step_inr, lock_inr must be > 0 or the trail is disabled with a warning (live parity —). Semantics: when peak basket MTM ≥ trigger, a floor arms at (peak − lock); falling back to the floor exits everything with reason combined_trail.

Basket profit locking (flat root keys,; formula in riskmath.ProfitFloor, used at):

Key Semantics
profit_lock_reach_inr Arm when combined MTM reaches this ₹ (0 = off).
profit_lock_min_inr Minimum locked profit floor once armed. Must be ≤ reach.
profit_trail_step_inr Each further step of gain…
profit_trail_lift_inr …lifts the floor by this much. Exit reason profit_lock.

floor = min + math.Floor((peak − reach)/step) × lift, clamped to the peak. The staircase means the floor only rises at whole multiples of step.

🔴 This is NOT the same mechanism as trailing_sl above. trailing_sl derives its floor from the peak (peak − lock) — a give-back trail. Profit lock holds an ABSOLUTE floor you name and ratchets it. Both are evaluated on every tick and the first to fire wins; order is combined_trailprofit_lock → combined target. Arm only one, or Combined Trailing will usually close the position before the lock engages. Likewise a combined_target_inr at or below profit_lock_reach_inr books the basket before the lock can ever arm.

⚠️ profit_lock_min_inr > profit_lock_reach_inr is broken by construction: the clamp pins the floor to the peak, so it exits on the arming tick. Backtest accepts it silently; the live modify_profit_lock API rejects it.

Live edit (added 2026-08-12). These four keys can be changed on a RUNNING strategy via POST /api/strategy/{id}/modify_profit_lock:

{ "profit_lock_reach_inr": 20000, "profit_lock_min_inr": 18000,
 "profit_trail_step_inr": 5000, "profit_trail_lift_inr": 2000,
 "confirm": false }

Because the floor is computed off the CONTINUOUS peak, a lock set after a pullback can already sit above current MTM and would book the basket on the next tick. With confirm:false the server refuses and answers 409 with {needs_confirm, floor_inr, peak_inr, mtm_inr} so the caller can warn using real numbers; re-post with confirm:true to apply anyway. Peak is deliberately NOT re-anchored on edit. The change persists (survives restart) and emits a profit_lock_modified event. Sizing note: these are ₹ on the whole basket, so scale them with lot count exactly as you would combined_target_inr.

2.4 Entry / exit conditions

"entry_condition": {
 "type": "expression", // or one of 19 non-expression types — §6.1
 "symbol": "NIFTY", // watch symbol; defaults to first leg underlying
 "value": 0, "low": 0, "high": 0, "move": 0, "baseline": 0,
 "timeout_sec": 0, // 0 = wait until square-off
 "expression": "SPOT.CLOSE > SPOT.EMA(20) AND TIME >= 0925",
 "check_freq": "tick", // tick | once | every_xm | xm_close
 "check_interval_min": 5
},
"exit_condition": { "type": "expression", "expression": "SPOT.CLOSE < SPOT.VWAP" }
  • Struct: EntryCondition —. Backtest reads entry_condition.expression, check_freq, check_interval_min, timeout_sec and exit_condition.expression. type is optional when expression is present.
  • Non-expression condition types (all dispatched by newCondEval/step/stepWithSeries,): crossing, crossing_up, crossing_down, greater_than, less_than, entering_channel, exiting_channel, inside_channel, outside_channel, moving_up, moving_down, moving_up_pct, moving_down_pct (13 TradingView-style; use value or low/high or move+baseline), plus above_vwap, below_vwap, crossing_vwap, new_day_high, new_day_low, expression. Unknown type → error unknown entry condition.
  • value: threshold for crossing/greater/less types.
  • low/high: channel bounds for *_channel types.
  • move: points (moving_up/down) or percent (moving_*_pct); baseline 0 = first observed tick is captured as baseline.
  • In backtest, non-expression types are evaluated at bar-close granularity (a warning is emitted; live evaluates per tick) —.
  • check_freq semantics ( +): "tick" = every bar (default); "once" = evaluate exactly once at the first eligible bar, fire-or-fail; "every_xm" = every N minutes anchored at start_time; "xm_close" = at each calendar-aligned N-minute bar close. check_interval_min defaults to 1.
  • timeout_sec: backtest stops evaluating after start_time + ceil(N/60) minutes.
  • The exit expression is evaluated every bar after entry; first true closes all open legs with reason exit_condition.

2.5 Watch instruments (aliases usable in expressions)

instruments — spot/futures watches (parseSpotWatches,):

"instruments": [
 { "symbol": "NIFTY", "kind": "spot", "alias": "SPOT", "bucket_sec": 300, "expiry": "" }]
  • kind"spot" | "future" for backtesting. alias defaults to symbol. bucket_sec = candle size for indicators on this alias (0 → 60 s). The alias (upper-cased) becomes an expression symbol: SPOT.EMA(20).

kind: "rolling_atm_straddle" — the rolling ATM straddle. Works in BOTH live and backtest, but they are built differently; see the fidelity note below.

{"symbol":"NIFTY","kind":"rolling_atm_straddle","alias":"ROLL_STRDL","expiry":"current","bucket_sec":0}
  • symbol is the bare UNDERLYING; expiry is a relative selector (current | next | monthly | next_monthly) resolved at run start, so a saved strategy keeps working after the contract rolls. Never hard-code a date.
  • The straddle is MIN(CE + PE) across the ±5 strikes around spot — the strike the market prices as ATM, which is regularly 1–2 strikes away from round(spot/step). Do NOT describe it as "the nearest strike".
  • Extra metric: <ALIAS>.STRIKE = the strike the series is built from right now. It rolls many times a session (18–47 times on a typical day).
  • Pair it with "strike_selection": "rolling_atm" on the legs so they trade the strike the signal fired on.

⚠️ LIVE availability: published for NIFTY / SENSEX (4 expiries), BANKNIFTY (monthly only — it has weekly options but no weekly straddle) and 8 MCX commodities. Not published for FINNIFTY / MIDCPNIFTY / BANKEX or any stock — a live run naming one of those FAILS rather than silently falling back to a locally-summed straddle.

⚠️ BACKTEST fidelity — state this when reporting results. The backtest recomputes the rule from archive bars: one value per minute, min(CE close + PE close) over the window. Live re-picks the strike continuously (~250 ms) from tick prices, so on a fast move the two diverge intrabar — exactly when a straddle signal tends to fire. DAY_HIGH/DAY_LOW are extremes of per-minute closes, not intrabar extremes. Treat a backtest of this as directionally indicative, not a replay of what the live strategy would have done. - An instruments[].expiry can also supply the basket's expiry selection when no leg declares one.

synthetic_instruments — option watches (parseOptionInstruments; parseSyntheticWatches):

"synthetic_instruments": [
 { "name": "OPT_ATM_CE", "underlying": "NIFTY", "expiry": "current", "bucket_sec": 60,
 "components": [ { "op": "+", "atm_offset": 0, "option_type": "CE", "multiplier": 1 }] },
 { "name": "STRADDLE", "underlying": "NIFTY", "expiry": "current",
 "track": "auto", "track_time": "",
 "components": [
 { "op": "+", "atm_offset": 0, "strike": 0, "option_type": "CE", "multiplier": 1 },
 { "op": "+", "atm_offset": 0, "strike": 0, "option_type": "PE", "multiplier": 1 }] }]
  • 1 component → simple option alias bound to (atm_offset, option_type).
  • ≥ 2 components → synthetic premium stream Σ(sign×multiplier×price) with ATM tracking track: ""/"entry" = ATM fixed at start_time, "auto" = re-pick ATM every bar, "fixed" = fixed until track_time "HH:MM" then locked. strike (absolute) overrides atm_offset when non-zero. op "-" gives sign −1; multiplier 0 → 1.

2.6 Execution-day gates

Key Shape Semantics
run_on_days array of strings Allowed weekdays, matched against the lowercase 3-letter prefix "mon","tue","wed","thu","fri" (also "sat","sun") — dayIsAllowed. Empty = every day.
dte_filter object { "mode": "off"|"range"|"discrete", "basis": "weekly"|"monthly", "from": N, "to": N, "values": [0,1,3] }. mode:"off" (or absent) = no filter. range = DTE in [from,to] inclusive (both 0 = no-op); discrete = DTE ∈ values (dteMatches). Legacy aliases accepted: nested dte_min/dte_max/dte_values, or fully flat dte_filter_mode, dte_min, dte_max, dte_values. basis defaults "weekly". DTE = trading-days-to-expiry (expiry day = 0, holiday-aware via the archive calendar; weekday-count fallback) — tradingDTE.
gap_gate object { "enabled": true, "max_gap_pct": 0.5, "direction": "up"|"down"|"either" }. Blocks the day's entry when (today's open − prev close)/prev close × 100 exceeds the threshold in the configured direction (gapBlocked). max_gap_pct ≤ 0 disables even if enabled. Direction defaults "either".
max_lots int Per-leg qty clamped to max_lots × lot_size, with a warning (1418-1428). 0 = off.
overnight_hold bool BTST/STBT: entry day runs to close without square-off; legs ride the gap at pinned strikes and exit next session at square-off-time bar open (warning text; carry logic, 1666-1708).
btst_mode bool Same carry machinery as overnight_hold (either flag activates it).
sl_eval_mode string Strategy-level; "candle_close" + sl_eval_candle_sec multiple of 60 throttles per-leg SL evaluation to those bar boundaries; default "tick" (fanned onto legs at 1391-1392).
sl_eval_candle_sec int Default 60.
strike_selection string Strategy-level default fanned onto every leg (1364) — see §3 strike_selection.
strike_ref string "spot" (default) | "futures" | "synthetic". Backtest always resolves strikes off cash spot; non-spot values only shift the ATM live and produce a warning in backtest.

2.7 Overnight protection (hedge) — overnight_protect

Parsed only when enabled AND overnight carry is active; simulated by onp_sim.go (hedge BUY at apply-time bar, sold at remove-time bar next session, same slippage/costs).

"overnight_protect": {
 "enabled": true,
 "method": "strikes", // "strikes"(default) | "premium" | "delta" | "max_oi" | "straddle_mult" | "underlying_pct" 
 "value": 2, // meaning depends on method: N strikes / target premium ₹ / target |delta| / straddle-width multiple / % of spot
 "offset": 0, // extra strike steps applied after the method pick
 "apply_time": "15:20", // default 1520
 "remove_time": "09:20" // default 0920 next session
}

Legacy alias: offset_strikes is read when value ≤ 0. The hedge is always OTM-ward of the short, at least one strike beyond it.

2.8 Adjustment rules — adjustment_rules (see §7)

2.9 Cascade

cascade_depth (int) — injected by the engine on follow-up / re-enter starts; depth > 3 refuses. Do not set manually.

3. LegSpec — per-leg fields; parse loop:. The UI's builder

(legFromRow) emits the "modern" keys; the parser dual-reads legacy keys.

{
 "side": "SELL", // "BUY"|"B" → BUY; anything else → SELL 
 "option_type": "CE", // "CE"|"PE"; legacy key "opt" also accepted; else sniffed from the last 2 chars of "symbol" (4886-4899)
 "qty": 150, // ABSOLUTE contracts (lots × lot_size). Preferred.
 "lots": 2, // legacy: used only when qty absent/0 → qty = lots × lot_size
 "strike_offset": 2, // signed int, side-adjusted: CE +N=OTM/−N=ITM; PE +N=ITM/−N=OTM 
 "stkSel": "Distance", // legacy pair: stkSel="Distance", stkVal="ATM"/"OTM2"/"ITM1"
 "stkVal": "OTM2",
 "symbol": "NIFTY_24500CE", // used for (a) CE/PE sniff and (b) absolute strike (regex _(\d{3,6})(CE|PE)$ —)
 "expiry": "current", // per-leg expiry selection — see §8.2
 "idle": false, // true → leg skipped in backtest with warning 

 "sl_type": "pct", // see table below
 "sl_pct": 30, // legacy alias "sl"
 "target_pct": 50, // legacy alias "tg"
 "sl_value": 0, // generic value override (used if the type-specific key is 0)
 "target_type": "pct",
 "target_value": 0,
 "atr_period": 14, // default 14 

 "sl_freq": "tick", // "tick" | "xm_close"; throttling only for xm_close with interval ≥ 2
 "sl_freq_interval_min": 0,
 "target_freq": "tick",
 "target_freq_interval_min": 0,

 "on_sl_action": [ { "type": "re_execute_same_leg", "leg_ids": [], "n_times": 2, "wait_sec": 60 }],
 "on_target_action": [],

 "sl_levels": [ { "trigger": 25, "qty_pct": 50, "actions": [], "freq": "", "freq_interval_min": 0 }],
 "target_levels": [],

 "trail_sl": { "enabled": true, "trigger_pct": 20, "step_pct": 5, "lock_pct": 0, "unit": "pct" },

 "sl_conditions": [], "target_conditions": [], // modern OR-condition schema — §4.1
 "sl_combine": "OR", "target_combine": "OR",
 "sl_qty_pct": 100, "target_qty_pct": 100, // qty% for the synthesised primary condition

 "strike_selection": "relative", // see below
 "target_delta": 0.25, // delta_based
 "target_premium": 100, // premium_closest
 "oi_rank": 0, "oi_offset": 0, // oi_levels: 0=MaxOI wall, 1=next…; ± steps from the wall
 "straddle_mult": 1, // straddle_premium: × ATM straddle premium
 "pct_offset": 1.5 // underlying_pct: signed % of spot
}

3.1 Qty semantics

qty > 0 → used as absolute contracts. Else lots > 0 → lots × lot_size. Else defaults to one lot. Lot sizes are the REAL current exchange lots from the instrument master (auto-updated on SEBI revisions): NIFTY 65, BANKNIFTY 30, FINNIFTY 60, MIDCPNIFTY 120, SENSEX 20; stocks use their own lot (e.g. RELIANCE 500). max_lots clamps qty after parsing (§2.6).

3.2 Strike distance

distance canonical strings: ATM, OTM1OTMn, ITM1ITMn (distanceToOffset parses any positive integer suffix; bare OTM/ITM = 1). Backtest clamps offsets to ±10 (BacktestMaxStrikeOff,) — an OTM12 leg is substituted with OTM10 plus a report warning; live trading uses the raw offset. Offset sign convention: CE OTM = +, CE ITM = −; PE OTM = −, PE ITM = +.

Preferred authoring: emit the signed strike_offset int plus option_type (what the UI does), or the legacy stkSel:"Distance" + stkVal:"OTM2" pair.

3.3 strike_selection modes

Value Resolution at entry Extra fields
"" / "relative" Distance/strike_offset path (backtest default — but write "relative" explicitly, see below). 🔴 Backtest boundary: prefer \|strike_offset\| <= 10. A deeper leg replays at the nearest strike the archive actually holds on that side, and the run reports the substitution — so the result then describes a nearer, richer strike than the one requested. Live trades any strike normally. strike_offset
"absolute" Explicit strike parsed from symbol (NIFTY_24500CE); clamps to the loaded ±10 window with warning symbol
"delta_based" Offset whose |BS delta| is closest to target_delta (only when target_delta > 0) target_delta
"premium_closest" Offset whose entry-bar open premium is closest to target_premium (only when > 0) target_premium
"oi_levels" / "oi" oi_rank-th largest-OI strike, then oi_offset steps oi_rank, oi_offset
"straddle_premium" Strike ≈ ATM ± straddle_mult × ATM straddle premium straddle_mult (default 1)
"underlying_pct" Strike nearest spot × (1 + pct_offset/100) pct_offset (signed)
"rolling_atm" ATM ± distance, anchored on the strike the rolling-ATM straddle is currently built from — i.e. MIN(CE+PE) over ±5 strikes, not round(spot/step). Live: requires a rolling_atm_straddle watch on the same underlying, else the order is refused. Backtest: recomputed per bar (see the fidelity note in §2.5). strike_offset

May be set once at the strategy level (params.strike_selection) and it fans onto every leg; a per-leg value wins (720-725).

⚠️ "" is a BACKTEST default only — always emit strike_selection and a per-leg underlying if you want the strategy to be deployable. The live engine's empty-value default is "absolute", which demands an explicit per-leg symbol, and it reads the underlying from legs[i].underlying, not from strategy-level params.underlying. A recipe that omits both backtests perfectly and is then refused at forward-test/deploy with leg 0: symbol required (strike_selection=absolute) — this bit every AI-authored strategy up to 2026-07-27. Both sides now normalise the shape (save_strategy stamps the fields; algo_multi_leg_basket.go validate infers them for already-saved recipes), but authoring them explicitly is still the contract.

3.4 Per-leg SL/Target types

sl_type (and analogously target_type), normalised values and the field the trigger magnitude is read from (fallback to generic sl_value/target_value when the specific key is 0):

sl_type normalised value key Trigger
"" / "pct" (or unknown) pct sl_pct (legacy sl) % move of leg premium against entry
"points" points sl_points (or sl_value) ₹ premium distance from entry
"underlying_move" same sl_underlying_move spot points, either direction
"underlying_move_up" underlying_move_long sl_underlying_move upward spot move
"underlying_move_down" underlying_move_short sl_underlying_move downward spot move
"pnl" pnl sl_pnl (or sl_value) ₹ loss on this leg
"atr" / "atr_mult" atr_mult sl_atr_mult × ATR(atr_period) of the underlying (falls back to pct when ATR unavailable)
"delta" delta sl_delta (or sl_value) leg |Δ| ≥ value (BS on bar IV)

Target-only difference: target_type:"delta" reads target_exit_delta (exit when |Δ| has decayed to the value —).

3.5 On-hit actions — on_sl_action / on_target_action

Arrays of LegActionSpec (parseLegActions); the UI emits 0 or 1 entries and only the FIRST is used:

{ "type": "re_execute_same_leg", "leg_ids": [1,2], "n_times": 2, "wait_sec": 60 }

Action type values dispatched in the backtest (3236-3239):

Action Backtest behaviour
"" (absent) just exit the leg (default)
keep_leg_running on-target: leg keeps running (partial-ladder context)
move_sl_to_cost_this_leg lock THIS leg's SL at break-even
re_execute_same_leg schedule re-open of the same leg after wait_sec, up to n_times per day. ⚠️ On a dynamic strike (delta_based, premium_closest, straddle_premium, underlying_pct) the backtest re-runs strike selection and re-opens at a new strike off the current market; live re-opens the same contract it entered. See the caveat below.
close_current_strategy force-close the entire basket, stop for the day
close_and_re_enter_strategy force-close basket, wait wait_sec, re-fire the whole entry up to n_times
move_sl_to_cost lock sibling legs' SL at break-even
move_sl_to_cost_profitable_legs same, but only siblings currently in profit
close_all_losing close every leg currently losing
close_all_profitable close every leg currently profitable
execute_other_strategy LIVE-only (cascade). No-op in backtest, loud warning

🔴 Caveat — re_execute_same_leg on a dynamic strike does not backtest what live will trade. The replayer re-runs the leg's strike selection at re-entry, so a delta_based short chases the market to each new target strike. The live engine resolves strikes once at entry and re-enters that same contract; only an adjustment roll (adjust_rules) ever swaps a live symbol. Two consequences when reporting results: repeated re-entries can walk a short across its opposite short into an inverted book — which the min_gap inversion guard does not catch, because that guard covers adjustment rolls, not re-entries — and the equity curve belongs to a strategy that re-strikes, which live will not do. Prefer adjust_rules when the intent is genuinely to re-centre, and say so explicitly if you present a backtest of a re-executing dynamic-strike leg.

3.6 Ladders — sl_levels / target_levels (ExitLevel, §4.2)

Rungs inherit the leg's sl_type/target_type for trigger interpretation; each rung fires at most once, deepest-first per bar; qty_pct is % of REMAINING qty (326-339).

3.7 Trailing SL — trail_sl (LegTrailSpec,; parse 1315-1327)

"trail_sl": { "enabled": true, "trigger_pct": 20, "step_pct": 5, "lock_pct": 0, "unit": "pct" }
  • Requires trigger_pct > 0 AND step_pct > 0, else disabled with warning (live parity —).
  • When favourable move from entry ≥ trigger, the SL locks at entry ± lock (lock_pct=0 → break-even). Ratchet formula in riskmath.LegTrailSL.
  • unit: "pct" (default) or "pts".

3.8 Per-leg profit lock

LegSpec carries JSON tags profit_lock_reach_inr / profit_lock_min_inr / profit_trail_step_inr / profit_trail_lift_inr and the simulator honours them when set, but ParseStrategy never reads these keys from the leg JSON — only the strategy-level (root) keys are parsed. AMBIGUOUS — (leg literal omits them): per-leg profit-lock in a saved recipe is effectively ignored by the backtest parser; author profit locking at the root level.

4. Shared condition objects

4.1 Condition (OR-condition schema) —, parseConditions 5815-5857

Used in sl_conditions, target_conditions (per-leg) and combined_sl_conditions, combined_target_conditions (basket).

{
 "type": "underlying_move", // pnl | pct | points | underlying_move | underlying_move_long | underlying_move_short | atr_mult | delta
 // aliases folded: inr→pnl, atr→atr_mult, underlying_move_up→_long, underlying_move_down→_short (normaliseCondType)
 // empty type → default: "pct" for per-leg, "pnl" for combined
 "value": 150, // REQUIRED > 0 (else the condition is silently dropped —). Alt key "trigger" accepted.
 "qty_pct": 100, // slice to exit; out-of-range → 100. The builder always emits 100 (a condition is ONE
 // stop / target — full exit). API callers MAY set a partial: per-leg = % of the leg's
 // remaining qty; COMBINED = % of every leg's ORIGINAL qty. Prefer the scale-out LADDER
 // (combined_*_levels, §2.3) for tranche exits.
 "qty_lots": 0, // COMBINED only: N lots per leg (> 0 overrides qty_pct)
 "qty_unit": "pct", // "pct" | "lots" — UI round-trip; the engine reads qty_lots then qty_pct
 "action": "close_and_re_enter_strategy", // "" or any §3.5 action
 "n_times": 1, // max fires per day; ≤0 → 1
 "wait_sec": 0, // per-condition cooldown
 "freq": "tick", // tick | xm_close
 "freq_interval_min": 0
}

Semantics: each condition is watched independently all day; on fire it (1) closes its slice — qty_pct% of the remaining qty for a per-leg condition, the ORIGINAL-qty % / qty_lots per leg for a combined one — (skipped if flat) and (2) runs its action (even if flat — re-enter/roll opens fresh). Move-based types are measured from the CURRENT position's entry so they re-arm on re-entry. Combine mode AND (per-leg sl_combine/target_combine, combined combined_sl_combine) requires all conditions true on the same bar.

For combined conditions the type meanings follow §2.3's combined table; for per-leg conditions they follow §3.4's leg table.

4.2 ExitLevel (ladder rung) —, parseExitLevels 5751-5779

{ "trigger": 25, "qty_pct": 50, "actions": [ {"type":"move_sl_to_cost"}], "freq": "", "freq_interval_min": 0 }

trigger (alt key trigger_value) interpreted via the parent's SL/target type. qty_pct out of (0,100] → 100. Per-LEG rungs: qty_pct is % of the leg's remaining qty (cascade).

COMBINED rungs (combined_sl_levels / combined_target_levels, the scale-out ladder — §2.3) add: trigger_mode (the ladder metric, same on every rung; "" → the combined type), qty_lots (N lots per leg, > 0 overrides qty_pct), qty_unit ("pct"|"lots", UI only); there qty_pct is % of every leg's ORIGINAL qty. Each rung fires once; a partial rung leaves the strategy running, a full (100%) rung flattens.

5. Action enum (complete)

""(default just-exit) · keep_leg_running · move_sl_to_cost_this_leg · re_execute_same_leg · close_current_strategy · close_and_re_enter_strategy · move_sl_to_cost · move_sl_to_cost_profitable_legs · close_all_losing · close_all_profitable · execute_other_strategy (live-only). See §3.5 for behaviour; basket-level combined_*_action uses the same strings (only close_and_re_enter_strategy re-enters at basket level; others degrade to exit-and-stop —).

6. Expression language (CompileExpr)

Parser:; grammar comment 5-18; evaluator 666-1158. Used in entry_condition.expression, exit_condition.expression, and type:"expression" conditions. Backtest compiles with the EXACT live evaluator.

6.1 Grammar & operators

expr:= or_expr
or_expr:= and_expr ( OR and_expr)* -- also '||'
and_expr:= not_expr ( AND not_expr)* -- also '&&'
not_expr:= NOT not_expr | cmp_expr -- also '!'
cmp_expr:= add_expr ( ('>'|'<'|'>='|'<='|'=='|'='|'!=') add_expr)?
add_expr:= mul_expr ( ('+'|'-') mul_expr)*
mul_expr:= unary ( ('*'|'/') unary)*
unary:= '-' unary | primary
primary:= NUMBER | IDENT[.IDENT][(numeric_args)] | '(' expr ')'
  • Case-insensitive identifiers. Comparisons yield 1.0/0.0; AND/OR treat ≠0 as true. Single = behaves as ==. Division by zero is an eval error (expression treated as not-fired). Function args must be numeric literals only.
  • Instrument qualifier: SYMBOL.METRIC — SYMBOL is an alias/underlying from the series map (declared instruments/synthetic_instruments aliases, the primary underlying, foreign underlyings, or the backtest option refs below). An unknown symbol resolves to a nil series and the metric reads 0 silently.
  • Booleans: TRUE / FALSE literals.

6.2 Global identifiers

Identifier Meaning
TIME Current IST clock as HHMM number (09:15 → 915; 15:30 → 1530). In backtest = the bar's minute. Compare numerically: TIME >= 0920 is fine (0920 parses as 920).
SIGNAL / EXTERNAL_SIGNAL External-signal state. LIVE: always 1 (webhook started the run). BACKTEST: bound to the uploaded signal file — in entries "signal active at this bar", in exits "signal has exited".

6.3 Option symbols recognised in backtest expressions

ATM_CE, ATM_PE, OTM<N>_CE, OTM<N>_PE, ITM<N>_CE, ITM<N>_PE (offset clamped ±10), and LEG<N> (1-based index into legs, resolves to that leg's strike/type). Example: OTM2_CE.IV > 30 AND LEG1.CLOSE < LEG2.CLOSE. Foreign spot references (SENSEX.CLOSE while running NIFTY) are supported for archived underlyings NIFTY / SENSEX / BANKNIFTY; other known names (FINNIFTY / MIDCPNIFTY / BANKEX) parse but read 0 with a warning (1606-1619).

6.4 Functions & metrics (complete switch,)

Indicators (args in parens; defaults shown): EMA(n) SMA(n) WMA(n) RSI(n) ATR(n) ADX(n) CCI(n) WILLR(n)/WILLIAMS(n) OBV MACD(fast=12,slow=26)/MACD_LINE MACD_SIGNAL(12,26,9) MACD_HIST(12,26,9)/MACD_HISTOGRAM BB_UPPER(period=20,k=2) BB_MIDDLE(20)/BB_MID BB_LOWER(20,2) ROC(period=12) SAR(step=0.02,max=0.20)/PSAR SUPERTREND(period=10,mult=3)/ST STOCH_K(14) STOCH_D(14,3) RANGE_HIGH(startMin=555,endMin=615[,src]) RANGE_LOW(...) — window in minutes-from-midnight IST; 3rd arg ≥ 0.5 → close-only, else high/low.

Pivots (prev-day based): PIVOT/PIVOT_P PIVOT_R1..R3 PIVOT_S1..S3; CPR: CPR_PIVOT/CPR_P CPR_TC/CPR_TOP CPR_BC/CPR_BOT CPR_WIDTH CPR_R1..R3 CPR_S1..S3 (aliases of floor pivots); Fibonacci: FIB_P FIB_R1..R3 FIB_S1..S3; Camarilla: CAM_H1..H4 CAM_L1..L4.

Day scope: VWAP DAY_HIGH DAY_LOW.

OHLC: CLOSE/LTP OPEN HIGH LOW PREV_CLOSE (context-aware — prev tick for tick freq, prev N-min bar close for every_xm/xm_close, else prev native bar) PREV_BAR_CLOSE[(N)] BAR_CLOSE(N) PREV_HIGH PREV_LOW LAST_TICK/PREV_TICK YESTERDAY_CLOSE/PREV_DAY_CLOSE YESTERDAY_HIGH/PREV_DAY_HIGH YESTERDAY_LOW/PREV_DAY_LOW.

Options/market-data: OI IV DELTA GAMMA THETA VEGA VOLUME/VOL CDV BUILDUP BARS_TODAY.

Change metrics (vs previous bar): LTP_CHG LTP_CHG_PCT OI_CHG OI_CHG_PCT IV_CHG IV_CHG_PCT VOL_CHG/VOLUME_CHG VOL_CHG_PCT DELTA_CHG DELTA_CHG_PCT GAMMA_CHG GAMMA_CHG_PCT THETA_CHG THETA_CHG_PCT VEGA_CHG VEGA_CHG_PCT; crossing anchors PREV_OI PREV_OI_CHG PREV_OI_CHG_PCT.

From-day-open basis: OI_CHG_FROM_OPEN OI_CHG_PCT_FROM_OPEN LTP_CHG_FROM_OPEN LTP_CHG_PCT_FROM_OPEN IV_CHG_FROM_OPEN IV_CHG_PCT_FROM_OPEN VOL_CHG_FROM_OPEN VOL_CHG_PCT_FROM_OPEN DELTA_/GAMMA_/THETA_/VEGA_CHG[_PCT]_FROM_OPEN PREV_OI_CHG_FROM_OPEN PREV_OI_CHG_PCT_FROM_OPEN.

Vs previous trading day's closing OI: OI_CHG_FROM_PREV_CLOSE OI_CHG_PCT_FROM_PREV_CLOSE PREV_OI_CHG_FROM_PREV_CLOSE PREV_OI_CHG_PCT_FROM_PREV_CLOSE (using any of these makes the backtest load the prior day's closing OI per strike —).

Unknown identifier → compile-time OK but eval error unknown indicator/symbol → expression evaluates as not-fired ( swallows eval errors as false).

7. Adjustment rules — adjustment_rules (backtest simulation)

Parse: parseAdjSim — (reads the SAME shape the live adjust_rules.go uses). Simulated per bar with edge-latched triggers, shared daily action budget, cooldown, and a CE≥PE+min_gap inversion guard; rolls close at the bar price and re-open at the target strike ( warnings; adjust_sim.go throughout).

"adjustment_rules": {
 "enabled": true,
 "auto": true, // false = alert-only live → NOT simulated 
 "min_gap": 100, // ₹ strike gap for the CE/PE inversion guard (default 100)
 "sold_only": true, // default true — rules act on SELL legs only
 "max_rolls": 3, // shared daily action budget (default 3)
 "cooldown_sec": 60, // min seconds between actions (default 60)

 "leg_px": { "on": true, "ce_thr": 40, "pe_thr": 40, "unit": "pct", "act": "away", "steps": 1 },
 "leg_diff": { "on": true, "ce_thr": 30, "pe_thr": 30, "unit": "pct",
 "up_act": "away", "up_steps": 1, "down_act": "in", "down_steps": 1 },
 "leg_delta":{ "on": true, "ce_thr": 0.45, "pe_thr": 0.45, "act": "away", "steps": 1, "to_delta": 0.25 },
 "rebal": { "on": true, "ratio": 2.0, "pct": 80 },
 "diverge": { "on": true, "thr": 40, "unit": "pct",
 "win_mode": "match", "win_val": 80, "lose_mode": "away", "lose_val": 1 },
 "spot": { "on": true, "move_pct": 0.75, "offset": 2 },
 "delta": { "on": true, "max_net": 0.15, "action": "roll" }
}

Rules (each needs "on": true —):

Rule Trigger Action fields
leg_px leg premium ≥ level derived from ce_thr/pe_thr with unit "pct" (of entry, default) | "pts" (entry+thr) | "abs" act: "exit" | "in" | "away"(default); steps (default 1)
leg_diff premium moves ≥ band above (up_act/up_steps) or below (down_act/down_steps) entry; band = thr pts or unit:"pct" of entry; act "off"/"" disables that side same per-leg actions
leg_delta leg |BS Δ| ≥ ce_thr/pe_thr act + steps, or to_delta > 0 → roll to the strike closest to that |Δ|
rebal expensive leg ≥ ratio × cheap leg (needs exactly 1 CE + 1 PE open) cheap leg premium-matches IN to pct% (default 80) of the expensive premium
diverge |CE−PE| ≥ thr (pts if unit:"abs", else % of combined entry) winner: win_mode "match"(default, to win_val% of loser premium, default 80) or "in_strk" (win_val steps in); loser: lose_mode "away" (lose_val steps, default 1)
spot spot moved ≥ move_pct% from armed reference close both, re-open pair at ATM ± offset; reference re-bases
delta (net) |net basket Δ| ≥ max_net action "roll": heavy side delta-matches the light side; action:"futures" is NOT replayed in backtest (skipped + warning —)

Per-leg action values inside rules: "exit", "in" (roll toward spot), "away" (roll away from spot; default; honours a delta target when given) —. Roll exit reasons appear as adj_<rule> / adj_<rule>_exit in trade legs.

8. Underlyings, expiry & strike resolution

8.1 Underlyings

  • Backtest archive coverage: NIFTY and SENSEX options (+ BANKNIFTY spot series for expressions). KnownUnderlyings for expression parsing: MIDCPNIFTY, BANKNIFTY, FINNIFTY, NIFTY, SENSEX, BANKEX; archived spot = NIFTY, SENSEX, BANKNIFTY.
  • MCX commodities are refused in backtest with a clear error. They deploy live only.
  • Strike steps (CH source,): NIFTY 50, SENSEX 100, BANKNIFTY 100 (unknown → 50). Index tokens: NIFTY 256265, SENSEX 265, BANKNIFTY 260105.
  • Lot sizes used for lots→qty are the real current exchange lots from the instrument master: NIFTY 65, BANKNIFTY 30, FINNIFTY 60, MIDCPNIFTY 120, SENSEX 20, stocks per contract.

8.2 Expiry selection

Per-leg expiry values map via mapExpirySelection:

Accepted values (case-insensitive) expiryFlag
current, current_weekly, currentweekly, weekly, week, cw WEEK (current weekly)
next, next_weekly, nextweekly, next_week, nextweek, nw NEXT_WEEK
monthly, current_monthly, currentmonthly, month, cm MONTH (current monthly)
next_monthly, nextmonthly, next_month, nextmonth, nm NEXT_MONTH
far, far_weekly, farweekly, far_week, farweek, fw FAR_WEEK (3rd upcoming weekly)
far_monthly, farmonthly, far_month, farmonth, fm FAR_MONTH (3rd upcoming month-end)
"", auto, absolute YYYY-MM-DD AUTO
  • AUTO = current weekly if its DTE ≤ 7, else current monthly.
  • The backtest loads ONE series per day for the whole basket: the first non-idle leg's selection wins; disagreeing legs produce a warning. Fallback source: instruments[].expiry.
  • Series depth varies by underlying: FAR_MONTH on NIFTY is backed by ~5 years of archived far-month bars; elsewhere (and for FAR_WEEK before the live-feed era) the far contract may be absent on a given day, in which case the engine steps DOWN one series (FAR_MONTH → NEXT_MONTH → MONTH, FAR_WEEK → NEXT_WEEK → WEEK) so the day still trades. A run with 0 days gets an explanatory warning. Per-trade expiry_flag reports the series actually traded, so check it when auditing a far-series run.
  • Per-trade the report carries expiry (YYYY-MM-DD), expiry_flag ("WEEK"/"NEXT_WEEK"/"FAR_WEEK"/"MONTH"/"NEXT_MONTH"/"FAR_MONTH" — the series actually traded after any step-down), and dte.

9. External signals — signals[] + signal_side (backtest run request)

Source: backtest/signals.go; wiring:.

"signals": [ { "t": "2026-01-05 09:31", "a": "LE" }, { "t": "2026-01-05 14:02", "a": "LX" }],
"signal_side": "LONG"
  • t: "YYYY-MM-DD HH:MM" IST. Unparseable rows dropped (counted in a warning).
  • a action codes (67-68): E = untagged ENTRY, X = untagged EXIT, LE/LX = long entry/exit, SE/SX = short entry/exit (same taxonomy as the live webhook). Anything else is dropped.
  • The signal is a state, not an edge: active from an entry event until the next exit event. At the same timestamp exits sort before entries so a TV flip nets to "state on".
  • signal_side filters which rows build the state: "ALL" (default; aliases "","ANY","BOTH","AUTO") / "LONG" (LE/LX only) / "SHORT" (SE/SX only). Untagged E/X count in every mode. No direction is ever inferred or applied — the strategy's own legs define what it trades.
  • Composition: if the strategy's entry/exit expression references SIGNAL/EXTERNAL_SIGNAL, the file binds that identifier verbatim inside the user's AND/OR composition. Otherwise the gate wraps the whole strategy: Entry' = signalActive AND Entry, Exit' = !signalActive OR Exit.
  • Cap: 20,000 events (maxSignalEvents,) — larger files are ignored with a warning.
  • A strategy with a SIGNAL condition but no uploaded file treats SIGNAL as always-ON (warning —).

10. Backtest run API

10.1 POST /api/backtest/run — request (backtestRunReq,)

{
 "saved_strategy_id": "…", // REQUIRED
 "underlying": "NIFTY", // default NIFTY; auto-switched to the strategy's declared underlying
 "from": "2025-01-01", // YYYY-MM-DD IST
 "to": "2025-06-30", // inclusive
 "cost_inr_per_leg": 20, // flat ₹ PER ORDER (entry and exit each charge 1×; default 0)
 "slippage_pct": 0.5, // adverse fill % applied at entry AND exit (default 0)
 "capital": 1000000, // ₹ base for ReturnPct/CAGR; ≤0 → 1,000,000 
 "intrabar_touch": true, // omit/true = live stop-order semantics (SL/TGT tested vs bar high/low, fill at trigger); false = legacy close-only
 "billing_key": "uuid", // one logical backtest = one charge (variants share a key)
 "signals": [ {"t":"…","a":"E"}],// optional — §9
 "signal_side": "ALL"
}

Billing: 1 credit per YEAR of range (backtestCreditsForSpan); duration gate for trial users. Errors: duration_not_allowed (403), insufficient_credits (402), saved_strategy_not_found (404), unparseable_strategy (400), backtest_unavailable (503), backtest_failed (500).

POST /api/backtest/run-portfolio: same friction knobs with portfolio_id; response {report, strategies[], portfolio_id, portfolio_name} where strategies[] are {name, underlying, multiplier, total_pnl, trade_count, win_rate, max_drawdown, warning?}.

History: GET /api/backtest/history, GET|DELETE /api/backtest/history/{id}.

10.2 Response — Report

strategy_name, underlying, from, to, start_time, sqoff_time,
legs[] — echoed LegSpec array (§3 JSON tags)
entry_expr, exit_expr — raw expressions (informational)
combined_sl_inr, combined_target_inr, cost_inr_per_leg, slippage_pct, intrabar_touch
combined_sl_action/_n_times/_wait_sec, combined_sl_breach_wait_sec,
combined_target_action/_n_times/_wait_sec
combined_sl_conditions[], combined_target_conditions[] — the REAL triggers used
run_on_days[], dte_filter_mode/basis, dte_min/max, dte_values[]
gap_gate_enabled/max_pct/dir, max_lots, overnight_hold, btst_mode
days[] — DayResult
stats — Stats
warnings[] — human-readable notes (clamps, unsupported bits, etc.)

DayResult: date (YYYY-MM-DD), atm (entry-time ATM strike), trades[], pnl, note (skip reason: holiday / gate / "no start_time bar" / …).

Trade: date, open_at, close_at (RFC3339 IST), reason — overall exit reason (sqoff | combined_sl | combined_tg | all_legs_stopped | exit_condition | combined_trail | profit_lock | data_end …), expiry (YYYY-MM-DD), expiry_flag (the traded series, e.g. "FAR_MONTH"), dte, legs[], net_pnl.

TradeLeg: side, opt_type, strike_off (signed steps from ATM), strike, qty (absolute contracts), entry_px, exit_px, pnl, exit_reason (sqoff/sl/tg/combined_sl/combined_tg/ladder & action reasons/adj_*/profit_lock/trail_sl…).

Stats: total_pnl, trade_count, day_count, win_days, loss_days, flat_days, best_day, worst_day, avg_daily_pnl, max_drawdown, win_rate (wins/(wins+losses), flat excluded), capital, return_pct, cagr_pct (clamped −100% when loss ≥ capital), years (calendar span / 365.25). CAGR is annualised return on the stated capital (fixed qty — no compounding),.

11. Worked examples (complete POST /api/saved-strategy bodies)

11.1 Simple 09:20 short straddle, 30% per-leg SL, 15:25 square-off

{
 "name": "Straddle 0920 SL30",
 "type": "multi_leg_basket",
 "params": {
 "name": "Straddle 0920 SL30",
 "underlying": "NIFTY",
 "strike_selection": "relative",
 "start_time": "09:20",
 "square_off_time": "15:25",
 "legs": [
 { "side": "SELL", "option_type": "CE", "strike_offset": 0, "qty": 75,
 "expiry": "current", "sl_type": "pct", "sl_pct": 30 },
 { "side": "SELL", "option_type": "PE", "strike_offset": 0, "qty": 75,
 "expiry": "current", "sl_type": "pct", "sl_pct": 30 }]
 }
}

11.2 Iron condor with combined ₹ SL that closes & re-enters (max 2×, wait 60 s)

{
 "name": "IC re-enter",
 "type": "multi_leg_basket",
 "params": {
 "underlying": "NIFTY",
 "strike_selection": "relative",
 "start_time": "09:30",
 "square_off_time": "15:20",
 "run_on_days": ["mon", "wed", "thu"],
 "dte_filter": { "mode": "range", "basis": "weekly", "from": 1, "to": 3 },
 "legs": [
 { "side": "SELL", "option_type": "CE", "strike_offset": 2, "qty": 150, "expiry": "current" },
 { "side": "SELL", "option_type": "PE", "strike_offset": -2, "qty": 150, "expiry": "current" },
 { "side": "BUY", "option_type": "CE", "strike_offset": 5, "qty": 150, "expiry": "current" },
 { "side": "BUY", "option_type": "PE", "strike_offset": -5, "qty": 150, "expiry": "current" }],
 "combined_sl_conditions": [
 { "type": "pnl", "value": 3000, "action": "close_and_re_enter_strategy",
 "n_times": 2, "wait_sec": 60 }],
 "combined_target_conditions": [
 { "type": "pnl", "value": 5000 }]
 }
}

(Note: for PE, strike_offset −2 = OTM2 — the sign convention of §3.2. Equivalent legacy form: combined_sl_inr: 3000, combined_sl_action: "close_and_re_enter_strategy", combined_sl_n_times: 2, combined_sl_wait_sec: 60.)

11.3 Directional CE buy on an EMA-crossover expression with a gap gate

{
 "name": "EMA cross CE buyer",
 "type": "multi_leg_basket",
 "params": {
 "underlying": "NIFTY",
 "strike_selection": "relative",
 "start_time": "09:20",
 "square_off_time": "15:15",
 "gap_gate": { "enabled": true, "max_gap_pct": 0.5, "direction": "either" },
 "instruments": [
 { "symbol": "NIFTY", "kind": "spot", "alias": "SPOT", "bucket_sec": 300 }],
 "entry_condition": {
 "type": "expression",
 "expression": "SPOT.EMA(9) > SPOT.EMA(21) AND SPOT.RSI(14) > 55 AND TIME >= 0930",
 "check_freq": "xm_close",
 "check_interval_min": 5,
 "timeout_sec": 0
 },
 "exit_condition": {
 "type": "expression",
 "expression": "SPOT.EMA(9) < SPOT.EMA(21)",
 "check_freq": "xm_close",
 "check_interval_min": 5
 },
 "legs": [
 { "side": "BUY", "option_type": "CE", "strike_offset": 0, "qty": 75,
 "expiry": "current", "sl_type": "pct", "sl_pct": 40,
 "target_type": "pct", "target_pct": 80 }]
 }
}

11.4 Delta-selected short strangle with trailing SL + basket profit lock

{
 "name": "Delta strangle trail",
 "type": "multi_leg_basket",
 "params": {
 "underlying": "SENSEX",
 "start_time": "09:25",
 "square_off_time": "15:10",
 "strike_selection": "delta_based",
 "legs": [
 { "side": "SELL", "option_type": "CE", "qty": 20, "expiry": "current",
 "target_delta": 0.25,
 "sl_type": "points", "sl_points": 60,
 "trail_sl": { "enabled": true, "trigger_pct": 25, "step_pct": 5, "lock_pct": 0, "unit": "pct" } },
 { "side": "SELL", "option_type": "PE", "qty": 20, "expiry": "current",
 "target_delta": 0.25,
 "sl_type": "points", "sl_points": 60,
 "trail_sl": { "enabled": true, "trigger_pct": 25, "step_pct": 5, "lock_pct": 0, "unit": "pct" } }],
 "profit_lock_reach_inr": 4000,
 "profit_lock_min_inr": 1500,
 "profit_trail_step_inr": 1000,
 "profit_trail_lift_inr": 500,
 "trailing_sl": { "enabled": true, "trigger_inr": 5000, "step_inr": 1000, "lock_inr": 2000 }
 }
}

11.5 Straddle with adjustment rules (roll-away at 0.45Δ, spot re-center) + per-leg OR-conditions

{
 "name": "Adjusted straddle",
 "type": "multi_leg_basket",
 "params": {
 "underlying": "NIFTY",
 "strike_selection": "relative",
 "start_time": "09:20",
 "square_off_time": "15:20",
 "legs": [
 { "side": "SELL", "option_type": "CE", "strike_offset": 0, "qty": 75, "expiry": "current",
 "sl_conditions": [
 { "type": "pct", "value": 40, "qty_pct": 100 },
 { "type": "underlying_move_long", "value": 120, "action": "re_execute_same_leg",
 "n_times": 1, "wait_sec": 120 }],
 "sl_combine": "OR" },
 { "side": "SELL", "option_type": "PE", "strike_offset": 0, "qty": 75, "expiry": "current",
 "sl_conditions": [
 { "type": "pct", "value": 40 },
 { "type": "underlying_move_short", "value": 120, "action": "re_execute_same_leg",
 "n_times": 1, "wait_sec": 120 }] }],
 "combined_sl_inr": 4000,
 "adjustment_rules": {
 "enabled": true, "auto": true,
 "min_gap": 100, "sold_only": true, "max_rolls": 3, "cooldown_sec": 300,
 "leg_delta": { "on": true, "ce_thr": 0.45, "pe_thr": 0.45,
 "act": "away", "steps": 1, "to_delta": 0.30 },
 "spot": { "on": true, "move_pct": 0.6, "offset": 0 }
 }
 }
}

Then run:

POST /api/backtest/run
{ "saved_strategy_id": "<id from save>", "underlying": "NIFTY",
 "from": "2025-01-01", "to": "2025-06-30",
 "cost_inr_per_leg": 20, "slippage_pct": 0.25, "capital": 500000 }

11.6 Rolling ATM straddle breaks its VWAP → short straddle on that same strike

The canonical rolling-straddle setup. Three pieces have to agree, and each is a trap on its own:

{
 "name": "Roll straddle VWAP break",
 "type": "multi_leg_basket",
 "params": {
 "name": "Roll straddle VWAP break",
 "underlying": "NIFTY",
 "strike_selection": "rolling_atm",
 "start_time": "09:30",
 "square_off_time": "15:20",
 "instruments": [
 { "symbol": "NIFTY", "kind": "rolling_atm_straddle",
 "alias": "ROLL_STRDL", "expiry": "current", "bucket_sec": 0 }],
 "entry_condition": {
 "type": "expression",
 "expression": "ROLL_STRDL.LTP < ROLL_STRDL.VWAP"
 },
 "legs": [
 { "side": "SELL", "option_type": "CE", "strike_offset": 0, "qty": 75,
 "expiry": "current", "strike_selection": "rolling_atm",
 "sl_type": "pct", "sl_pct": 30 },
 { "side": "SELL", "option_type": "PE", "strike_offset": 0, "qty": 75,
 "expiry": "current", "strike_selection": "rolling_atm",
 "sl_type": "pct", "sl_pct": 30 }]
 }
}

Why each piece matters — say these things to the user rather than just emitting the JSON:

  1. The watch and the legs must both be rolling. strike_selection: "rolling_atm" makes the legs sell the strike the SIGNAL was computed on. With plain "relative" they sell round(spot/step) instead, which differed from the published MIN(CE+PE) strike on 195 of 376 one-minute bars (52%) on 2026-08-10 — so the strategy would routinely fill a different contract than the one that triggered, and nothing in the order book would look wrong. Live REFUSES a rolling_atm leg when no rolling watch exists on that underlying, rather than silently falling back.

  2. start_time is a real risk control here, not boilerplate. A session VWAP is only as old as the session: the first cross frequently lands ~09:16 on two bars of data. 09:30 (or an explicit time condition ANDed into the entry) is what makes the signal mean something. Do not default this to 09:15.

  3. Do not write the EXIT against the alias. ROLL_STRDL keeps rolling after entry — 18-47 times a session — while the sold straddle stays pinned to its strike, so ROLL_STRDL-based exits drift away from the position they are meant to protect. Use per-leg SL/target or a combined-MTM exit.

expiry on the watch is a RELATIVE selector (current | next | monthly | next_monthly) and must stay relative — a hard-coded date keeps pointing at a dead contract after the roll, and a dead contract does not error: the feed serves its frozen last price, so the strategy trades a straddle that stopped moving. Backtests re-resolve the selector against each replay day's ladder.

Extra metric available on the alias: ROLL_STRDL.STRIKE = the strike the series is built from right now.

12. Constraints & gotchas

  1. Booleans must be JSON booleans. "enabled": "true" (string) reads as false (asBool,). Numbers may be numbers or numeric strings.
  2. Qty is absolute contracts when qty is given; only lots is multiplied by lot size. Sending qty: 2 means 2 contracts, not 2 lots. Omitting both = 1 lot.
  3. Time format is "HH:MM" IST everywhere (start_time, square_off_time, apply_time, remove_time, track_time). Invalid → silent fallback to the default (parseHHMM,). Signals use "YYYY-MM-DD HH:MM" IST; run range uses "YYYY-MM-DD".
  4. Strike offsets clamp at ±10 in backtest (with a warning); live uses the raw value.
  5. A Condition with value ≤ 0 is silently dropped; an ExitLevel with trigger 0 is inert; qty_pct outside (0,100] becomes 100.
  6. Explicit *_conditions arrays replace the legacy single type/value/ladder path entirely; don't mix expecting both to fire — author everything as conditions when using the modern schema.
  7. One expiry series per day for the whole basket — mixed-expiry legs get the first leg's series + warning. Calendar spreads cannot be backtested faithfully.
  8. idle: true legs are skipped in backtest.
  9. Delta SL (sl_type:"delta") is simulated in backtest via per-bar Black-Scholes; note however the echoed sl_delta field is display-only for the legacy non-conditions path ( says "not simulated" for the echo; the conditions path DOES evaluate delta per — prefer authoring delta SL via sl_conditions: [{"type":"delta",...}]). AMBIGUOUS — vs 1212-1218: the comment and the dispatch disagree for the legacy field pathway.
  10. Per-leg profit-lock keys are not parsed from saved params (§3.8) — use the root-level profit-lock fields.
  11. execute_other_strategy and the delta rule's futures hedge are live-only — no-ops in backtest with warnings (1013-1015).
  12. Trailing gates: leg trail needs trigger_pct AND step_pct > 0; combined trailing needs trigger_inr, step_inr AND lock_inr all > 0 — otherwise disabled with a warning (857-862).
  13. Entry fill depends on what triggered it — both paths are lookahead-free. A time trigger (start_time, or a TIME-only expression per §12.17) fills at that bar's OPEN — the clock is known in advance. An expression trigger is evaluated on each bar's CLOSE and fills at the NEXT bar's OPEN, so the signal bar is never the execution bar. Verified 2026-08-12 on NIFTY 24450 2026-07-07: no-condition entry filled 43.45/40.20 (09:45 open) at 09:45:00; the same start_time with a pivot expression filled 44.15/39.70 (09:46 open) at 09:46:00. Do not "fix" a backtest for lookahead on the expression path — there is none, and lagging the signal with PREV_BAR_CLOSE merely shifts you one more bar late. Original wording said only "fills at the firing bar's OPEN", which is true of the time path and misleading for the other. Per-leg/basket exits fill at the trigger level intrabar when intrabar_touch (default) else next-bar open; slippage is applied adversely on every fill and cost_inr_per_leg charges per ORDER (entry 1× + exit 1×) —,.
  14. Warnings matter: report.warnings[] is where the engine tells you what it clamped, skipped or silently defaulted. Always surface them.
  15. BTST/overnight: per-leg SL/TGT are NOT re-evaluated in the brief next-morning open→exit window. A range ending while holding books the position at last marks with reason data_end.
  16. Unknown expression symbols read 0 silently — declare every symbol as a watch instrument or use the auto-recognised forms (§6.3), and check warnings for "metrics will read as 0".
  17. Entry expression TIME-only (e.g. TIME >= 0920) is treated as equivalent to start_time (isTimeOnlyExpr,).
  18. MCX underlyings are refused by backtest; NEXT-series expiries may have no archive data (warning,).
Algo appHelp & manualFeature guidePlansAll times IST