OptionForge Lua API Documentation

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.

Quick Example (See Complete Examples for more)

-- Put Credit Spread: A comment like this at the first line will be used as a label for the run

-- 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.price = "mid"

if date.day_of_week == "Mon" and portfolio.n_open_trades < 5 then
    trade = portfolio:new_trade()
    dte = 90; qty = 1
    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 the trades each day 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()

Creates a new empty trade in the portfolio.

Returns: Trade object

portfolio.n_open_trades

Number of open trades in the portfolio.

Returns: number

portfolio:trades()

Returns an iterator over all active trades in the portfolio (yields index, trade).

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.

Parameters: key (string) - key to count, inc (number) - amount to increment by

portfolio:count("ProfitTargetHit", 2) -- increment the count of "ProfitTargetHit" by 2
portfolio:count("ProfitTargetHit") -- increment the count of "ProfitTargetHit" by 1
portfolio:count("ProfitTargetHit", -1) -- decrement the count of "ProfitTargetHit" 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 has_leg 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])

Closes all legs in the trade.

Parameters: reason (string) - optional reason for closing the trade

if trade.pnl > 500 then trade:close("ProfitTargetHit") end
-- NOTE that we can't use trade variable after close (or erase).
		

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

trade:add_leg(Call("LC", Delta(30), 30, 1))
trade:add_leg(Put("SP", Delta(-30), 30, -1))

trade:has_leg(name)

Checks if the trade has a leg with the given name.

Parameters: name (string) - name of the leg

Returns: boolean

if trade:has_leg("LC") then print("Has long call") end

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

if trade:has_leg("LC") and trade:leg("LC").mid > 500 then
    trade:close_leg("LC")
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.

Parameters: name (string) - name of the leg

Returns: Leg object

local my_leg = trade:leg("LC")
print(my_leg.strike, my_leg.delta)

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

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

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
-- Daily ticks at 10:00 (or nearest available that day)
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")
-- plot trade 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.

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
-- 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
sim_params.price = "mid"

-- 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()
    dte = 45; qty = 2;
    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 the trades each day 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

VIX Trading Strategy

-- VIX trading strategy example
local dte = 41

-- Open new trades on Mon/Wed/Fri when VIX is below 20
if (date.day_of_week == "Mon" or date.day_of_week == "Wed" or date.day_of_week == "Fri") 
   and underlying_price < 20 then
    local trade = portfolio:new_trade()
    -- Short call 2 points below current price
    trade:add_leg(Call("Call", Strike(underlying_price - 2.0), dte, -1))
    -- Long call at strike 32 for protection
    trade:add_leg(Call("LC", Strike(32), dte, 2))
end

-- Manage existing trades
for _, trade in portfolio:trades() do
    -- Close long call if mid price > 500
    if trade:has_leg("LC") and trade:leg("LC").mid > 500 then
        trade:close_leg("LC")
        print("LC closed", date)
    -- Close entire trade if less than 7 days to expiration
    elseif trade.dte < 7 then
        trade:close()
    elseif trade.pnl > 600 or trade.pnl < -500 then
        trade:close() -- PT or SL
    end
end

Iron Condor Strategy

-- IRON Condor Ex
function create_iron_condor(dte, short_delta, qty)
    local trade = portfolio:new_trade()
    local width = 25 -- points wide IC
    
    -- Short call 
    trade:add_leg(Call("SC", Delta(short_delta), dte, -qty))
    local sc_strike = trade:leg("SC").strike

    -- Long call
    trade:add_leg(Call("LC", Strike(sc_strike + width), dte, qty))

    -- Check if the short call and long call have the same strike
    if trade:leg("SC").strike == trade:leg("LC").strike then
        portfolio:count("same-strike")
        trade:close_leg("LC", true)
        trade:add_leg(Call("LC", Strike(sc_strike + 2 * width), dte, qty)) -- could also close leg and re-open
        -- now check again and erase trade if still same:
        if trade:leg("SC").strike == trade:leg("LC").strike then
            trade:erase()
            return nil
        end
        print(trade)
    end
    -- log the width we ended up getting in user-counts
    portfolio:count("width:" .. tostring(trade:leg("LC").strike - trade:leg("SC").strike))

    -- Short put
    trade:add_leg(Put("SP", Delta(-short_delta), dte, -qty))
    local sp_strike = trade:leg("SP").strike
    trade:add_leg(Put("LP", Strike(sp_strike - width), dte, qty))

    return trade
end

-- Open new iron condor on Mondays
if date.day_of_week == "Mon" and date.hour == 10 then
    local qty = 1
    local dte = 45
    local short_delta = 20
    local ic = create_iron_condor(dte, short_delta, qty)
end

Dynamic Delta Adjustment

-- Strategy that dynamically adjusts delta based on day of week
-- Open a new trade on Monday
if date.day_of_week == "Mon" and portfolio.n_open_trades < 3 then
    local trade = portfolio:new_trade()
    local dte = 30
    local qty = 1
    trade:add_leg(Put("ShortPut", Delta(-30), dte, -qty))
    trade:add_leg(Put("LongPut", Delta(-50), dte, qty))
end

-- Manage existing trades: 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
short_qty = 2
long_qty = 3
if date.day_of_week == "Tue" then
    trade = portfolio:new_trade()
    dte = 45; 
    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
-- nearly all strategies will want to check the trades each day 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