OptionForge Lua API Documentation

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.

Looking for grid search? See the Grid Search docs.
Need help with OptionForge? [option-forge GPT] is a custom GPT on OpenAI that can answer questions about the Lua API and help with OptionForge strategy ideas, implementation, and debugging.

Table of Contents

General

optionforge uses luau for the lua language. luau has a benefit of being extremely fast and allowing safe execution of lua code.
It can be helpful to refer to a lua cheatsheet while reading the docs.

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.

Quick Example (See Complete Examples for more)

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

Portfolio Management

new_trade n_open_trades trades history pnl value delta/theta last_trade count trade

portfolio:new_trade(tag?)

Creates a new empty trade in the portfolio, optionally tagged with a string.

Returns: Trade object

portfolio.n_open_trades

Number of open trades in the portfolio.

Returns: number

portfolio:trades(tag?)

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

portfolio:history()

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

portfolio:pnl()

Current portfolio profit and loss.

Returns: number

portfolio:value()

Current portfolio value (PnL + starting cash).

Returns: number

portfolio:delta() / portfolio:theta()

Current portfolio delta or theta.

Returns: number

if portfolio:delta() > 10 then -- Adjust if delta too high
    -- Hedging logic
end

portfolio:last_trade()

Most recently opened trade.

Returns: Trade object

portfolio:count(key: string, inc: option[number])

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
                

portfolio:trade(id)

Trade by ID.

Parameters: id (number) - ID of the trade

Returns: Trade object

Trade Operations

pnl mid id dte dit delta/theta close cash erase add_leg close_leg adjust leg risk_graph

trade.pnl

Profit and loss of the trade.

Returns: number

trade.mid

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

trade.cash

Cash used or made from the trade.

Returns: number

trade:risk_graph()

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

trade.dit

Maximum days in trade.

Returns: number

trade.dte

Minimum days to expiration of the trade.

Returns: number

if trade.dte < 7 then trade:close() end

trade.id

Id of the trade.

Returns: number

trade.delta / trade.theta

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

trade:close(option[string], option[table])

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.
			

trade:erase()

Removes the trade from the portfolio and erases any commissions. Useful for filtering out bad data or invalid trade setups.

trade:add_leg(TradeLeg)

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

trade:close_leg(name: str, option[erase: bool])

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

trade:adjust(selector, name)

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

trade:leg(name)

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

Leg Management

Put Call

Put(name, selector, dte, qty)

Creates a put option leg.

Parameters:

local short_put = Put("SP", Delta(-30), 45, -1)

Call(name, selector, dte, qty)

Creates a call option leg.

Parameters: Same as Put()

Leg Properties

leg.delta / leg.theta

Delta or theta of the leg.

Returns: number

local leg_delta = trade:leg("LC").delta

leg.dte

Days to expiration of the leg.

Returns: number

leg.expiration

Expiration date of the leg as a string.

Returns: string

leg.mid

Mid price of the leg.

Returns: number

leg.spread

Approximate bid/ask spread of the leg.

Returns: number

leg.name

Name of the leg.

Returns: string

leg.qty

Quantity of the leg.

Returns: number

leg.side

Side of the leg ("put" or "call").

Returns: string

leg.strike

Strike price of the leg.

Returns: number

Option Selectors

Delta TradeDelta Strike Mid Theta Vega Gamma

Delta(number)

Selects an option by delta value (use negative for puts).

Call("LC", Delta(30.0), 30, 1)
Put("SP", Delta(-30), 30, -1)

TradeDelta(number)

Adjusts a leg to achieve a specific trade-level delta. Accounts for quantity.

trade:adjust(TradeDelta(-5), "LP")

Strike(number)

Selects an option by strike price.

Call("ATM", Strike(underlying_price), 30, 1)

Mid(number)

Selects an option by mid price.

Call("Cheap", Mid(1.0), 30, 10)

Theta(number)

Selects an option by theta value.

Call("HighDecay", Theta(-0.5), 30, -1)

Vega(number)

Selects an option by vega value.

Call("VolSensitive", Vega(0.2), 45, 1)

Gamma(number)

Selects an option by gamma value.

Call("HighGamma", Gamma(0.05), 15, 1)

Moving Averages

MA:EMA(period)

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

MA:SMA(period)

Returns: number. The SMA of the underlying price over the last period elements.

Global Variables

CSV user data: See CSV User Data for how to load custom indicators into user.

underlying_price

Current price of the underlying asset (index/stock/ETF).

Type: number

if underlying_price < 20 then -- VIX is low
    -- Enter positions
end

date

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

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

O

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

user

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
Cache: We cache one dataset per account for 3 hours. If your data changes, publish it at a new URL to refresh immediately.

sim_params

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"
Note: Only top-level lines that start with sim_params are evaluated before the run.

Plotting (advanced, but useful)

plots:add(title, y, plot_type, opts)

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:

See Plotly Scatter for possible symbols.
-- 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

Complete Examples

Put Credit Spread Strategy

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

Balanced Butterfly With Delta Hedge

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

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

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