← capabilities overview · Builder UI reference · Strategy JSON reference
Purpose: 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).
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 | % 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. |
"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" (anything else is skipped). 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).instruments[].expiry can also supply the basket's expiry selection when
no leg declares one.synthetic_instruments — option watches (parseOptionInstruments; parseSyntheticWatches):
"synthetic_instruments": [
{ "name": "OPT_ATM_CE", "underlying": "NIFTY", "expiry": "current", "bucket_sec": 60,
"components": [ { "op": "+", "atm_offset": 0, "option_type": "CE", "multiplier": 1 }] },
{ "name": "STRADDLE", "underlying": "NIFTY", "expiry": "current",
"track": "auto", "track_time": "",
"components": [
{ "op": "+", "atm_offset": 0, "strike": 0, "option_type": "CE", "multiplier": 1 },
{ "op": "+", "atm_offset": 0, "strike": 0, "option_type": "PE", "multiplier": 1 }] }]
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 (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).
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 |
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 |
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, // % 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.
{ "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.
""(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 |
"", auto, absolute YYYY-MM-DD |
AUTO |
instruments[].expiry.expiry (YYYY-MM-DD), expiry_flag
("WEEK"/"MONTH"), 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 ("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),.
POST /api/saved-strategy bodies){
"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 }]
}
}
{
"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.)
{
"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 }]
}
}
{
"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",
"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 }
"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).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,).