Skip to content

Custom Indicators

MarketHeist lets you write your own technical indicators in Python. Custom indicators run in a secure WebAssembly (WASM) sandbox — no data is sent to any server.

The compute contract

Every custom indicator must define a single function with this exact signature:

python
def compute(df, params):
    ...
    return {...}

df — the input DataFrame

df is a pandas DataFrame with one row per bar (day/week/month). The columns available depend on the requires declaration (see below), but always include at minimum:

ColumnTypeDescription
openfloatOpening price
highfloatHigh price
lowfloatLow price
closefloatClosing price
volumefloatVolume (may be 0 for indices)

The index is a DatetimeIndex in ascending chronological order.

params — the parameter dict

params is a plain Python dict mapping parameter names to their current numeric values. You declare parameters and their defaults in the indicator metadata (see below). At runtime, params contains the values the user has entered in the UI.

python
# Example: params = {"window": 20, "threshold": 0.02}
window = params["window"]      # int or float
threshold = params["threshold"]

Return value

compute must return a dict of named pandas Series, each with len == len(df). The series names become the signals available for the position rule.

python
return {
    "signal": my_series,          # primary signal (required)
    "upper_band": upper_series,   # optional extra series
    "lower_band": lower_series,
}

All returned series must have the same index as df. It is fine for the first window - 1 values to be NaN — the backtest treats NaN values as "no signal" (flat/cash position).

INFO

You must return at least one series named "signal". This is the series the position rule will evaluate. Additional series are available for display in the chart overlay.

Indicator metadata

Above the compute function, declare your indicator's metadata as module-level variables:

python
NAME = "My Custom RSI"
DESCRIPTION = "RSI computed from close prices with configurable period."
REQUIRES = ["close"]   # which OHLCV columns df will contain

PARAMS = [
    {"name": "window", "label": "RSI Period", "default": 14, "min": 2, "max": 200, "step": 1},
    {"name": "smooth", "label": "Smooth Period", "default": 3, "min": 1, "max": 20, "step": 1},
]

REQUIRES can contain any subset of ["open", "high", "low", "close", "volume"]. Only the declared columns will be present in df.

Full example: Custom RSI

Here is a complete custom indicator reimplementing RSI from scratch:

python
NAME = "Custom RSI"
DESCRIPTION = "Relative Strength Index reimplemented from scratch."
REQUIRES = ["close"]

PARAMS = [
    {"name": "window", "label": "Period", "default": 14, "min": 2, "max": 200, "step": 1},
]

def compute(df, params):
    import pandas as pd

    window = int(params["window"])
    close = df["close"]

    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = (-delta).clip(lower=0)

    avg_gain = gain.ewm(com=window - 1, min_periods=window).mean()
    avg_loss = loss.ewm(com=window - 1, min_periods=window).mean()

    rs = avg_gain / avg_loss.replace(0, float("inf"))
    rsi = 100 - (100 / (1 + rs))

    return {"signal": rsi}

This indicator produces a signal series ranging 0–100. You can then apply a threshold position rule (e.g., "long when signal > 50") to turn it into a backtest.

Another example: Price / MA Ratio with band signals

python
NAME = "Price/MA with Bands"
DESCRIPTION = "Price relative to its moving average, with configurable deviation bands."
REQUIRES = ["close"]

PARAMS = [
    {"name": "window",    "label": "MA Period",         "default": 50,   "min": 5,  "max": 500, "step": 1},
    {"name": "band_pct",  "label": "Band Width (%)",    "default": 5.0,  "min": 0.5,"max": 30,  "step": 0.5},
]

def compute(df, params):
    import pandas as pd

    window   = int(params["window"])
    band_pct = float(params["band_pct"]) / 100.0

    ma     = df["close"].rolling(window).mean()
    ratio  = df["close"] / ma          # primary signal
    upper  = pd.Series(1 + band_pct, index=df.index)
    lower  = pd.Series(1 - band_pct, index=df.index)

    return {
        "signal":     ratio,
        "upper_band": upper,
        "lower_band": lower,
    }

WASM sandbox limitations

Custom indicators run inside a Pyodide (Python-in-WebAssembly) sandbox. This provides strong security and privacy guarantees but imposes some constraints:

AllowedNot allowed
pandas, numpy (standard library)Network requests (requests, urllib, socket)
Pure Python computationFile I/O (open(), pathlib)
Standard math operationsSubprocess / OS calls
NaN / infinite values in outputImporting arbitrary third-party packages

The sandbox has no access to the internet and no access to your filesystem. Your indicator code is never sent to any server.

WARNING

Do not attempt to fetch external data inside compute. The network is blocked at the WASM level. All data your indicator needs must come from the df argument.

Warm-up periods and NaN handling

It is expected and correct to return NaN for the first window - 1 bars when your indicator needs a minimum number of observations. The backtest engine treats NaN signals as "flat" (no position). You do not need to fill or interpolate warm-up NaNs.

Next steps

MarketHeist Backtest Engine