FNOTraderAI AccessPurpose: enable an LLM (or any developer) to author ANY valid strategy
paramsJSON 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
trueis treated as false).
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\| <= 10is always safeDeeper 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 |
Rejected — save_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.
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.
params — top-level fields read by the backtest parserParser: 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.
| 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. |
legs — REQUIRED non-empty array of leg objects (§3). ParseStrategy errors
with "strategy has no legs" / "no usable legs after parsing" otherwise
(1411-1413).
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_trail → profit_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.
"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" }
EntryCondition —. Backtest reads
entry_condition.expression, check_freq, check_interval_min,
timeout_sec and exit_condition.expression. type is optional when expression is present.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.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.exit_condition.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.round(spot/step). Do NOT describe it as "the nearest strike".<ALIAS>.STRIKE = the strike the series is built from right
now. It rolls many times a session (18–47 times on a typical day)."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_LOWare 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. - Aninstruments[].expirycan 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 }] }]
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.| 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. |
overnight_protectParsed 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.
adjustment_rules (see §7)cascade_depth (int) — injected by the engine on follow-up / re-enter starts;
depth > 3 refuses. Do not set manually.
(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
}
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).
distance canonical strings: ATM, OTM1…OTMn, ITM1…ITMn
(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.
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 emitstrike_selectionand a per-legunderlyingif you want the strategy to be deployable. The live engine's empty-value default is"absolute", which demands an explicit per-legsymbol, and it reads the underlying fromlegs[i].underlying, not from strategy-levelparams.underlying. A recipe that omits both backtests perfectly and is then refused at forward-test/deploy withleg 0: symbol required (strike_selection=absolute)— this bit every AI-authored strategy up to 2026-07-27. Both sides now normalise the shape (save_strategystamps the fields;algo_multi_leg_basket.go validateinfers them for already-saved recipes), but authoring them explicitly is still the contract.
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 —).
on_sl_action / on_target_actionArrays 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_legon a dynamic strike does not backtest what live will trade. The replayer re-runs the leg's strike selection at re-entry, so adelta_basedshort 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 themin_gapinversion 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. Preferadjust_ruleswhen the intent is genuinely to re-centre, and say so explicitly if you present a backtest of a re-executing dynamic-strike leg.
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).
trail_sl (LegTrailSpec,; parse 1315-1327)"trail_sl": { "enabled": true, "trigger_pct": 20, "step_pct": 5, "lock_pct": 0, "unit": "pct" }
trigger_pct > 0 AND step_pct > 0, else disabled with warning
(live parity —).riskmath.LegTrailSL.unit: "pct" (default) or "pts".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.
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.
{ "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.
""(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 —).
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.
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 ')'
= behaves as ==. Division by
zero is an eval error (expression treated as not-fired). Function args must
be numeric literals only.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.TRUE / FALSE literals.| 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". |
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).
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).
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.
KnownUnderlyings for expression parsing:
MIDCPNIFTY, BANKNIFTY, FINNIFTY, NIFTY, SENSEX, BANKEX;
archived spot = NIFTY, SENSEX, BANKNIFTY.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.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 |
instruments[].expiry.expiry_flag reports the series actually
traded, so check it when auditing a far-series run.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.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.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.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.maxSignalEvents,) — larger files
are ignored with a warning.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}.
Reportstrategy_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),.
POST /api/saved-strategy bodies){
"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 }]
}
}
{
"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.)
{
"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 }]
}
}
{
"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 }
}
}
{
"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 }
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:
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.
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.
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.
"enabled": "true" (string) reads as
false (asBool,). Numbers may be numbers or numeric
strings.qty is given; only lots is
multiplied by lot size. Sending qty: 2 means 2 contracts, not 2 lots.
Omitting both = 1 lot."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".value ≤ 0 is silently dropped;
an ExitLevel with trigger 0 is inert; qty_pct outside (0,100] becomes
100.*_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.idle: true legs are skipped in backtest.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.execute_other_strategy and the delta rule's futures hedge are
live-only — no-ops in backtest with warnings (1013-1015).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).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×) —,.report.warnings[] is where the engine tells you
what it clamped, skipped or silently defaulted. Always surface them.data_end.TIME-only (e.g. TIME >= 0920) is treated as
equivalent to start_time (isTimeOnlyExpr,).