← capabilities overview · Builder UI reference · Strategy JSON reference

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).

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 % of remaining qty closed when the (synthesised) primary condition fires.

Ladders (partial exits): combined_sl_levels / combined_target_levels — arrays of ExitLevel (§4.2), parsed at. Trigger values are interpreted via combined_sl_type / combined_target_type.

OR-conditions (the modern, preferred schema)combined_sl_conditions / combined_target_conditions: arrays of Condition (§4.1), parsed at. When present these REPLACE the single typed field + ladder; when absent the engine synthesises equivalent conditions from the legacy fields (synthConditions,), so both schemas behave identically. combined_sl_combine / combined_target_combine: "OR" (default — each condition fires independently) or "AND" (all must be simultaneously true; the first condition's action runs) —, 5786-5791.

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.
profit_trail_step_inr Each further step of gain…
profit_trail_lift_inr …lifts the floor by this much. Exit reason profit_lock.

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" }

2.5 Watch instruments (aliases usable in expressions)

instruments — spot/futures watches (parseSpotWatches,):

"instruments": [
 { "symbol": "NIFTY", "kind": "spot", "alias": "SPOT", "bucket_sec": 300, "expiry": "" }]

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 }] }]

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 (default)
"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)

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

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
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

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" }

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, // % of remaining qty to exit; out-of-range → 100
 "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 qty_pct% of the remaining open qty (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.

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 ')'

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

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
"", auto, absolute YYYY-MM-DD AUTO

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"

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 ("WEEK"/"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",
 "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",
 "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",
 "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",
 "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 }

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 fills at the firing bar's OPEN; 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,).