This page documents the Lua scripting API used by the OptionForge options backtester. For the main app, see OptionForge. For parameter sweeps, see Grid Search.
Note: the code you enter is run for every tick. So it's often useful to provide some constraints on opening. But, we will likely want to check for adjustments or hitting profit target or stop-loss every tick.
This example may look complex, but it contains nearly all of what you need to know to create any trade plan. Take your time and read through it carefully.
Run title templates: the first-line comment (-- ...) is used as the run title and evaluated with Luau string interpolation. You can use expressions like {dte}, {sim_params.starting_cash}, or {dte + 5}. Use \{ and \} for literal braces. Useful for keeping title up-to-date as you try different parameters. Scripts ending with a top-level return may not resolve local placeholders.
-- Put Credit Spread (dte={dte}, qty={qty})
-- Optional sim params (defaults: $40k and full archive date range)
sim_params.starting_cash = 40000
sim_params.start_date = "2019-01-02"
sim_params.end_date = "2023-12-29"
sim_params.commission = 1.08
sim_params.slippage = 3.00
sim_params.spread_cost = 0.5 -- optional, 0..1 (1 = buy ask/sell bid, 0.5 = mid)
dte = 90
qty = 1
if date.day_of_week == "Mon" and portfolio.n_open_trades < 5 then
trade = portfolio:new_trade()
trade:add_leg(Put("SP", Delta(-10), dte, -qty))
sp_strike = trade:leg("SP").strike
-- use the strike of the short put to determine placement of the LP.
-- here we make it a 50-wide Put Credit Spread.
trade:add_leg(Put("LP", Strike(sp_strike - 50), dte, qty))
-- the 1st 50 print statements will go to your javascript console (ctrl+shift+j)
-- this is a nice way to debug your code
print(trade)
end
-- nearly all strategies will want to check open trades on each tick for StopLoss or ProfitTarget
-- or adjustments. It's not possible do other methods on a `trade` variable after it has been closed.
for _, trade in portfolio:trades() do
if trade.pnl > 200 or trade.pnl < -500 then
trade:close(trade.pnl > 0 and "PT" or "SL") -- PT or SL
elseif trade.dit > 50 or trade.dte < 20 then
-- a string argument to close will be counted so we can track how many trades
-- were closed due to DIT or DTE. We could do the same above for PT and SL.
trade:close("DIT")
end
end
Creates a new empty trade in the portfolio, optionally tagged with a string.
Returns: Trade object
Number of open trades in the portfolio.
Returns: number
Returns an iterator over open trades in the portfolio (yields index, trade). If tag is provided, only trades with an exact matching tag are yielded. nil returns all open trades.
Returns: iterator over Trade objects
for _, trade in portfolio:trades() do
print(trade.pnl)
end
Returns the PnL history of the portfolio.
Returns: table of numbers
local history = portfolio:history()
for i, pnl in ipairs(history) do print(i, pnl) end
Current portfolio profit and loss.
Returns: number
Current portfolio value (PnL + starting cash).
Returns: number
Current portfolio delta or theta.
Returns: number
if portfolio:delta() > 10 then -- Adjust if delta too high
-- Hedging logic
end
Most recently opened trade.
Returns: Trade object
Count events in the portfolio. These are reported to the user in a final table.
Note: trade:close("reason") already increments that reason in User Counts. Use portfolio:count() only for additional custom events.
Parameters: key (string) - key to count, inc (number) - amount to increment by
portfolio:count("ManualEvent", 2) -- increment the count of "ManualEvent" by 2
portfolio:count("ManualEvent") -- increment the count of "ManualEvent" by 1
portfolio:count("ManualEvent", -1) -- decrement the count of "ManualEvent" by 1
Trade by ID.
Parameters: id (number) - ID of the trade
Returns: Trade object
Profit and loss of the trade.
Returns: number
Current mid value of the trade (sum of leg mid prices with quantity and multiplier).
Returns: number
if trade.mid < -2000 then trade:close() end
Cash used or made from the trade.
Returns: number
Show the risk graph of the trade in the risk-graph tab. Up to 200 risk graphs are allowed per run.
Returns: table
if trade.pnl < - 5000 -- let's see what's happening
trade:risk_graph()
end
Maximum days in trade.
Returns: number
Minimum days to expiration of the trade.
Returns: number
if trade.dte < 7 then trade:close() end
Id of the trade.
Returns: number
Delta or theta of the trade.
Returns: number
if math.abs(trade.delta) > 5 then
-- adjust the leg named LP to make the trade delta neutral (0). Accounts for quantity of named leg.
trade:adjust(TradeDelta(0), "LP")
end
Closes all legs in the trade, or partially closes specific legs when a table is provided.
Parameters: reason (string) - optional reason label
Passing a reason also increments that key in User Counts, so you usually do not need a matching portfolio:count(reason).
Parameters: peel (table) - optional map of leg_name => qty_to_reduce. Qty sign is ignored.
if trade.pnl > 500 then trade:close("ProfitTargetHit") end
trade:close("scale-down", { UL = 1, shorts = 2, LL = 1 })
-- NOTE: full close/erase invalidates the trade handle, but peel closes keep it open.
Removes the trade from the portfolio and erases any commissions. Useful for filtering out bad data or invalid trade setups.
Adds a new leg to the trade.
Parameters: TradeLeg (created using Put() or Call())
Leg names must be unique within an open trade.
trade:add_leg(Call("LC", Delta(30), 30, 1))
trade:add_leg(Put("SP", Delta(-30), 30, -1))
Closes a specific leg in the trade.
name (string) - name of the leg to close
erase (boolean) - optional, default is false. if true, it's like the leg never existed
local lc = trade:leg("LC")
if lc ~= nil and lc.mid > 500 then
trade:close_leg(lc.name)
end
Adjusts a leg of the trade using a selector.
Parameters:
Returns: boolean (success)
-- Adjust leg "LP" to make trade delta -5. Accounts for quantity of named leg.
trade:adjust(TradeDelta(-5), "LP")
Gets a specific leg by name, or nil when that leg is not present.
Parameters: name (string) - name of the leg
Returns: Leg object or nil
local my_leg = trade:leg("LC")
if my_leg ~= nil then
print(my_leg.strike, my_leg.delta)
end
Creates a put option leg.
Parameters:
local short_put = Put("SP", Delta(-30), 45, -1)
Creates a call option leg.
Parameters: Same as Put()
Delta or theta of the leg.
Returns: number
local leg_delta = trade:leg("LC").delta
Days to expiration of the leg.
Returns: number
Expiration date of the leg as a string.
Returns: string
Mid price of the leg.
Returns: number
Approximate bid/ask spread of the leg.
Returns: number
Name of the leg.
Returns: string
Quantity of the leg.
Returns: number
Side of the leg ("put" or "call").
Returns: string
Strike price of the leg.
Returns: number
Selects an option by delta value (use negative for puts).
Call("LC", Delta(30.0), 30, 1)
Put("SP", Delta(-30), 30, -1)
Adjusts a leg to achieve a specific trade-level delta. Accounts for quantity.
trade:adjust(TradeDelta(-5), "LP")
Selects an option by strike price.
Call("ATM", Strike(underlying_price), 30, 1)
Selects an option by mid price.
Call("Cheap", Mid(1.0), 30, 10)
Selects an option by theta value.
Call("HighDecay", Theta(-0.5), 30, -1)
Selects an option by vega value.
Call("VolSensitive", Vega(0.2), 45, 1)
Selects an option by gamma value.
Call("HighGamma", Gamma(0.05), 15, 1)
Returns: number. The EMA of the underlying price over the last period elements.
local ema = MA:EMA(20)
print(ema) -- 20-day EMA of underlying price
Returns: number. The SMA of the underlying price over the last period elements.
Current price of the underlying asset (index/stock/ETF).
Type: number
if underlying_price < 20 then -- VIX is low
-- Enter positions
end
simulation date with attributes: day_of_week, day, month, year, hour, minute, second
Type: date
if date.day_of_week == "Mon" and date.hour == 10 then -- Entry on Monday at 10am EST
-- Enter positions
-- Manage positions
end
Last trade opened. May be nil if no trades have been opened yet or if most recent trade has been closed.
Type: Trade
if last_trade ~= nil and last_trade.dit >= 3 then
-- open new trade ... not shown.
end
Global writable table that persists between ticks. Useful for storing data like starting values to compare against later.
-- ... note were only showing a subset of the code here. we'd also want to set O[trade.id] = 0 when we open the trade.
if trade.pnl > O[trade.id].pnl + 400 then -- if we made $400 in a single day (tick) then just close.
trade:close()
end
-- ... other operations. but can't use `trade` variable if it has been closed.
-- store the pnl of the trade at end of tick code.
O[trade.id] = trade.pnl
Table populated from your CSV indicator data. Column headers become fields, e.g. user.signal.
Type: table
Provide the URL with sim_params.csv at the top of your script.
CSV format (public URL):
datetime,signal,vol
2025-10-12 10:30:00,0.42,18.1
2025-10-12 10:31:00,0.38,18.3
The engine uses the row that matches the current tick time, or the closest row that precedes it. If no prior row exists, the value is nil. A warning is printed if the row used is older than the previous tick of the current interval. This avoids false alarms on weekend gaps for daily data.
CSV user data requires an active subscription.
Limits: max 12 columns, max 35,000 rows (about 10 years at 30-minute intervals), values must be numeric (f32), and column names must be valid Lua identifiers. The datetime column is required and must be named datetime or Date (case-insensitive). The format must be YYYY-MM-DD, YYYY-MM-DD HH:MM, or YYYY-MM-DD HH:MM:SS with no timezone. Date-only values are treated as 23:59:59 to avoid accidental lookahead (“seeing the future”).
Value column headers are case-sensitive (e.g., ATR maps to user.ATR, not user.atr).
if user.signal and user.signal > 0.5 then
-- trade logic
end
Simulation parameters. Set these before running (top-level assignments).
Type: SimParameters
Fields:
sim_params.starting_cash = 40000
sim_params.start_date = "2019-01-02"
sim_params.end_date = "2023-12-29"
sim_params.commission = 1.08
sim_params.slippage = 3.00
sim_params.spread_cost = 0.5
-- Daily ticks at 10:00 (or nearest available that day)
-- Available named archives commonly include "SPX", "VIX", "NDX", and "RUT"
sim_params.archive = "SPX"
sim_params.tick_interval = "day"
sim_params.tick_time = "10:00"
-- Random daily time (one random time per run)
-- sim_params.tick_time = 9999
-- Hourly ticks
-- sim_params.tick_interval = "hour"
-- 15-minute ticks
-- sim_params.tick_interval = "15"
-- sim_params.tick_interval = "15-minute"
This lets the user create custom plots using any data available in the lua API. This can be very powerful for debugging trade performance and strategy backtesting. Use trace to add multiple series to the same plot.
The plot title can start with "main_" to plot on the second y-axis in the main chart.
Only scatter plots are supported on the main.
The default is to plot these in a User Plots tab.
Parameters:
-- show a histogram of how many trades we have open at any time.
plots:add("n_trades", portfolio.n_open_trades, "histogram", {bins = 20, color="red"})
-- show a scatter plot of how many trades we have open vs date.
plots:add("n_trades_time", portfolio.n_open_trades, "scatter", {date=tostring(date), color="#E500E5", symbol="x"})
-- after opening a trade, we can get the debit (or credit) and show a histogram.
plots:add("debit", trade.cash, "histogram")
-- for each open trade, plot pnl over time with one trace per trade id
for _, trade in portfolio:trades() do
plots:add("trade_pnl", trade.pnl, "scatter", {date=tostring(date), trace=tostring(trade.id)})
end
-- plot starting theta vs final Pnl.
if portfolio.n_open_trades < MAX_TRADES and (portfolio:last_trade() == nil or portfolio:last_trade().dit > 2) then
-- open a new trade ... not shown.
O[trade.id] = trade.theta
end
-- ... other operations.
-- iterate over open trades
for _, trade in portfolio:trades() do
if math.abs(trade.pnl) > 10000 or trade.dit > 50 then
plots:add("starting theta vs final pnl", O[trade.id], "scatter", {x=trade.pnl, symbol='o'})
trade:close()
end
end
-- Put Credit Spread Example (dte={dte}, qty={qty})
-- This is an example trade. It will look complex at first glance.
-- But, it contains most of the pieces to implement any strategy.
-- Optional sim params (defaults: $40k and full archive date range)
sim_params.starting_cash = 40000
sim_params.start_date = "2019-01-02"
sim_params.end_date = "2023-12-29"
sim_params.commission = 1.08
sim_params.slippage = 3.00
dte = 45
qty = 2
-- nearly all trades will want some sort of condition to open the trade.
-- this is often day of week and number of open trades.
if date.day_of_week == "Tue" and portfolio.n_open_trades < 5 and MA:EMA(10) > MA:EMA(20) then
trade = portfolio:new_trade()
trade:add_leg(Put("SP", Delta(-10), dte, -qty))
-- use the strike of the short put to determine placement of the LP.
local sp = trade:leg("SP")
trade:add_leg(Put("LP", Strike(sp.strike - 50), dte, qty))
-- the 1st 200 print statements will go to your javascript console (ctrl+shift+j)
-- this is a nice way to debug your code
print(trade)
-- sometimes the strikes are not available so we end up with a trade
-- that is not 50-wide
local dist = sp.strike - trade:leg("LP").strike
-- make it like the trade never existed.
if dist > 75 or dist < 25 then trade:erase() end
end
-- nearly all strategies will want to check open trades on each tick for StopLoss or ProfitTarget
-- or adjustments.
for _, trade in portfolio:trades() do
if math.abs(trade.pnl) > 10000 then
trade:erase() -- can get rid of trades with bad data (but be careful!!).
elseif trade.pnl > 300 or trade.pnl < -2000 then
trade:close(trade.pnl > 0 and "PT" or "SL") -- PT or SL
elseif trade.dte < 2 then
trade:close("Days in Trade") -- the message to close gets logged to "User Counts tab"
end
end
This example contains much of what you need to know to create any strategy.
-- Balanced Butterfly with Delta Hedge (dte={dte}, qty={qty})
-- Optional sim params (defaults: $40k and full archive date range)
sim_params.starting_cash = 40000 -- starting cash for the sim
sim_params.start_date = "2019-01-02" -- sim start date
sim_params.commission = 1.08 -- per-contract commission
sim_params.slippage = 3.00 -- assumed slippage
local dte = 90
local qty = 20
-- open a 90 DTE balanced butterfly on Mondays
if date.day_of_week == "Mon" and (last_trade == nil or last_trade.dit > 2) and portfolio.n_open_trades < 4 then -- gate entry by day and trade count
local trade = portfolio:new_trade() -- create a new trade
trade:add_leg(Put("UL", Delta(-45), dte, qty)) -- UL: 2 upper long puts at 30 delta
local ul = trade:leg("UL") -- cache UL leg for strikes
trade:add_leg(Put("SP", Strike(ul.strike - 50), dte, -2 * qty)) -- SP: -4 short puts 50 points below UL
local sp = trade:leg("SP") -- cache SP leg for strikes
local width = ul.strike - sp.strike -- compute UL-SP width
trade:add_leg(Put("LL", Strike(sp.strike - width), dte, qty)) -- LL: 2 lower long puts width below SP
trade:add_leg(Call("LC", TradeDelta(0), dte - 30, 1))
if width ~= 50 then -- enforce 50-point width at entry
trade:erase() -- erase trade if width is wrong
else -- width is correct
O[trade.id] = { initial_theta = trade.theta } -- store initial theta for exits. O is always available.
trade:risk_graph() -- see a risk graph of the trade at open.
print(trade) -- first 200 print statements go to the javascript console.
end -- end width check
end -- end entry logic
-- manage open trades for exits and adjustments
for _, trade in portfolio:trades() do -- iterate open trades
local initial_theta = O[trade.id].initial_theta -- the the stored value
if trade.dit >= 35 then -- time exit: 35 DIT
trade:close("DIT") -- close on max days in trade
elseif trade.dte <= 30 then -- time exit: 30 DTE
trade:close("DTE") -- close when too close to expiration
elseif trade.pnl > 30 * initial_theta then -- profit-target based on initial theta
trade:close("PT") -- close for profit-target
elseif trade.pnl < -0.5 * underlying_price then -- stop-loss based on underlying price
trade:close("SL") -- close for stop-loss
elseif trade.theta < 0 then
trade:close("low theta") -- exit when theta is negative.
else -- still open, apply adjustments
local sp = trade:leg("SP") -- get the short put leg
-- add a reactive hedge when underlying moves below the short strike
if sp ~= nil and underlying_price < sp.strike and trade:leg("HP") == nil then -- price below SP and no hedge yet
trade:add_leg(Put("HP", Mid(1.0), trade.dte, 1)) -- buy long put hedge at $1.00 mid
portfolio:count("hedge added") -- this shows up in "User Counts"
elseif math.abs(trade.delta) > 5 then -- delta too large
trade:adjust(TradeDelta(0), "UL") -- adjust using UL to get flat delta
end -- end adjustment checks
end -- end exit/adjustment branch
end -- end trade loop
-- Dynamic Delta Adjustment (dte={dte}, qty={qty})
-- Open a new trade on Monday
local dte = 30
local qty = 1
if date.day_of_week == "Mon" and portfolio.n_open_trades < 3 then
local trade = portfolio:new_trade()
trade:add_leg(Put("ShortPut", Delta(-30), dte, -qty))
trade:add_leg(Put("LongPut", Delta(-50), dte, qty))
end
-- Iterate over open trades and Manage: adjust delta based on day and check exits
for _, trade in portfolio:trades() do
if trade.pnl > 1000 then
trade:close("Profit Target")
elseif trade.pnl < -1000 then
trade:close("Stop Loss")
elseif date.day_of_week == "Mon" then
trade:adjust(TradeDelta(-3), "ShortPut") -- Monday: bearish bias
elseif date.day_of_week == "Fri" then
trade:adjust(TradeDelta(2), "ShortPut") -- Friday: bullish bias
else
trade:adjust(TradeDelta(0), "ShortPut") -- Other days: neutral
end
end
-- Put Back Ratio (dte={dte}, short_qty={short_qty}, long_qty={long_qty})
local short_qty = 2
local long_qty = 3
local dte = 45
if date.day_of_week == "Tue" then
trade = portfolio:new_trade()
trade:add_leg(Put("SP", Delta(-2), dte, -short_qty))
sp_mid = trade:leg("SP").mid
print("sp_mid:", sp_mid)
-- use the mid of the short put to determine placement of the LP.
trade:add_leg(Put("LP", Mid(sp_mid / long_qty - 1.0), dte, long_qty))
-- the 1st 50 print statements will go to your javascript console (ctrl+shift+j)
-- this is a nice way to debug your code
print(trade)
end
-- check open trades on each tick for StopLoss or ProfitTarget or adjustments.
for _, trade in portfolio:trades() do
if math.abs(trade.pnl) > 10000 then
-- trade:erase() -- can get rid of trades with bad data (but be careful!!).
elseif trade.pnl > 1000 or trade.pnl < -2000 then
trade:close(trade.pnl > 0 and "PT" or "SL") -- PT or SL
end
end