# Python indicators

> Write an indicator in Python, the three names, the ta.* helpers, what the sandbox does and does not allow, and the warm-up rule that catches everyone once.

Source: https://docs.texttoquant.com/reference/python-indicators

---

You can write an indicator in Python instead of Pine or JavaScript. It runs in an isolated sandbox on
our servers, computes against the same bars a backtest uses, and, unlike a Pine indicator, is **not
pinned to the symbol or timeframe you wrote it on**. The same saved indicator recomputes for whatever
a run happens to use.

It is the same capability as [JavaScript indicators](/docs/reference/javascript-indicators), in a
different language: the same contract, the same `ta.*` helpers computing the same numbers, the same
limits. Pick whichever you would rather write in.

Everything on this page is enforced by the compiler, so when you break a rule you get a message with a
line number rather than a wrong number.

## The shape of an indicator

Exactly three module-level names. Nothing else is required, and nothing else is special.

```python
INPUTS = {
    "length": 14,
    "overbought": 70,
}

PLOTS = [
    {"name": "rsi", "kind": "line"},
    {"name": "hot", "kind": "signal"},
]


def calc(candles, opts):
    rsi = ta.rsi(candles["close"], opts["length"])
    return {
        "rsi": rsi,
        "hot": [0 if ta.na(v) else (1 if v > opts["overbought"] else 0) for v in rsi],
    }
```

- **`INPUTS`**: editable defaults. Numbers and `True`/`False` only; each becomes a control in the
  settings panel. Up to 24. An input can also [describe itself](#describing-an-input), its range,
  step, label and section.
- **`PLOTS`**: what you draw. Each `name` becomes a chart series *and* something you can reference in
  a query.
- **`calc`**: required. Returns one list per plot, each exactly as long as the bars.

## Describing an input

An input can be a bare default, or a dict that says more about it:

```python
INPUTS = {
    "length": 14,                                                   # just a default
    "mult": {"default": 2.0, "min": 0.1, "max": 10.0, "step": 0.1,  # ...or a description
             "label": "ATR Multiplier", "tooltip": "Stop distance."},
}
```

Both forms mean the same thing to `calc`, `opts["mult"]` is the number `2.0` either way. What changes
is the control the settings panel builds.

**Why bother, in Python especially.** Writing `2.0` instead of `2` looks like it should be enough, and
it is not: the contract reaches the panel as JSON, where `2.0` and `2` are the same number. Without a
description the panel has only that number to go on, and `"length": 14` and `"mult": 2.0` look
identical to it: one is a bar count, the other a band width, and a band width you cannot set to 2.5
is broken.

| Field | What it does |
| --- | --- |
| `default` | Required. The value the indicator runs at until someone changes it. |
| `integer` | Whole numbers only. Without it, a number input accepts fractions. |
| `min` / `max` | The range the control clamps to. |
| `step` | How much the +/− buttons move. Defaults to `1` for a whole-number default, else `0.1`. |
| `label` | What the user reads instead of the variable name. |
| `tooltip` | One or two sentences, shown on the ⓘ beside the control. |
| `group` | Section heading. Inputs sharing a group render under one header. |
| `inline` | Inputs sharing an inline key pack onto one row. |
| `options` / `optionLabels` | A dropdown. Values stay numeric; the labels are what the user reads. |

Grouping and dropdowns:

```python
INPUTS = {
    "fast": {"default": 12, "integer": True, "group": "Lengths", "inline": "ma"},
    "slow": {"default": 26, "integer": True, "group": "Lengths", "inline": "ma"},
    "mode": {"default": 0, "options": [0, 1, 2], "optionLabels": ["EMA", "SMA", "WMA"],
             "group": "Method"},
}
```

`fast` and `slow` sit on one row under a **LENGTHS** heading; `mode` is a dropdown showing the three
names, and `opts["mode"]` is `0`, `1` or `2`.

**What is checked when you save.** A default outside its own `min`/`max`, a `min` above its `max`, a
`step` of zero, a dropdown default that is not one of the options, an `integer` input with a
fractional bound, a range on a `True`/`False` input, or a misspelled key, each is refused with a line
number rather than silently ignored.

**Overrides are held to what you declared.** A value outside the range is clamped to it; one that is
not a listed option falls back to the default. That applies in the chart and in a backtest alike, so
the two cannot run your indicator at different values.

An input with no description behaves exactly as it always has.

## This is MicroPython, not CPython

Indicators run on **MicroPython**, a compact Python. Almost anything you would write inside an
indicator works unchanged: loops, comprehensions, functions, classes, `math`, f-string-free string
formatting, but the standard library is smaller and there is no `pip`.

What that rules out, and what to use instead:

| you might reach for | use instead |
| --- | --- |
| `pandas` | `ta.*`, it already speaks whole series |
| `numpy` | `ulab.numpy`, which ships in the sandbox: `from ulab import numpy as np` |
| `scipy` | `ta.*`, or write the maths out, you have 128 KB |
| `datetime` | `candles["time"]` is epoch milliseconds |
| `re` | plain string methods: `startswith`, `split`, `in` |

### What you can import

The full list of importable standard-library modules:

| module | what you get |
| --- | --- |
| `math`, `cmath` | the usual maths; `cmath` for complex numbers |
| `ulab.numpy` | array maths, the `numpy` stand-in: `from ulab import numpy as np` |
| `itertools` | `accumulate`, `chain`, `islice`, `count`, `cycle`, … |
| `functools` | `reduce`, `partial` |
| `heapq` | `heappush` / `heappop` / `heapify`, rolling min/max in O(log n) |
| `operator` | `attrgetter`, `add`, `lt`, … (this build has no `itemgetter`, use a lambda) |
| `collections` | `deque`, `namedtuple`, `OrderedDict` |
| `array` | compact typed arrays when a list of floats is too heavy |
| `json` | `dumps` / `loads`, if you keep configuration in a string |

Anything not on that list is refused at import, by name, before your code runs. (One footnote:
`ucollections`, the MicroPython-native module `collections` wraps, is importable too, but
`collections` is the spelling to use.)

## `candles` is columnar, not a list of bars

You get lists, one entry per bar, **oldest first**:

```python
candles["time"]      # epoch milliseconds
candles["open"]      candles["high"]      candles["low"]      candles["close"]      candles["volume"]
candles["hl2"]       # (high + low) / 2
candles["hlc3"]      # (high + low + close) / 3
candles["ohlc4"]     # (open + high + low + close) / 4
candles["bar_index"] # 0, 1, 2, …
candles["length"]    # number of bars
```

This is the same shape the `ta.*` helpers take and return, so `candles["close"]` goes straight into
`ta.ema(...)` with nothing to convert at either end.

## Warm-up is `NaN`, and `is not None` will not catch it

This is the one rule that catches everyone, and it fails **silently**, you get a confident-looking
number instead of an error.

A helper has no value until it has enough bars. `ta.ema(close, 26)` has nothing to say for its first
25 bars, and it marks those bars **`NaN`**.

<Callout variant="warning" title="A None check does not exclude warm-up">
`v is not None` is **True** for `NaN`, so the Pythonic-looking guard lets warm-up straight through.
Test a helper's output with **`ta.na(v)`**.
</Callout>

```python
# WRONG: reports a confident downtrend for the first 26 bars, before either EMA exists.
trend = [1 if f > s else -1 for f, s in zip(fast, slow)]

# RIGHT
trend = []
for i in range(len(fast)):
    f, s = fast[i], slow[i]
    if ta.na(f) or ta.na(s):
        trend.append(None)
    else:
        trend.append(1 if f > s else -1)
```

`ta.na(v)` is `True` for `NaN`, for infinity **and** for `None`, so it is one guard for every "no
value" the engine can hand you, which is why it is preferable to `math.isfinite`.

The rule reverses on the way out: what you **return** may use `None` for "no value on this bar", and
any `NaN` or infinity you return is converted to `null` for you, so a stray divide-by-zero cannot
reach the chart as a broken number.

## Plot kinds

| kind | use it for | drawn as |
| --- | --- | --- |
| `line` | a continuous value | a line on the chart |
| `signal` | "this fired on this bar", 0 or 1 | a marker |
| `state` | a small integer regime, e.g. `-1` / `0` / `+1` | a stepped series |
| `color` | one **colour** per bar, painting another layer | nothing on its own; see below |

## Drawing

Your indicator does not have to be grey lines. Everything Pine can draw, you can declare, and each
name below is the equivalent of the Pine call beside it.

```python
META    = {"overlay": True, "precision": 2}                      # indicator(overlay=…)
PLOTS   = [
    {"name": "hist", "style": "histogram", "colors": "histColor"},  # plot(style=, color=)
    {"name": "histColor", "kind": "color"},                          # the per-bar colour channel
]
LEVELS  = [{"value": 70, "color": "red", "linestyle": "dashed", "label": "OB"}]   # hline()
FILLS   = [{"from": "upper", "to": "lower", "color": "blue", "transp": 92}]       # fill()
SHAPES  = [{"plot": "buy", "shape": "triangleup", "location": "belowbar", "text": "BUY"}]
ARROWS  = [{"plot": "netFlow", "colorUp": "green", "colorDown": "red"}]           # plotarrow()
BGCOLOR = "regimeColor"    # bgcolor()
BARCOLOR = "trendColor"    # barcolor()
CANDLES = [{"open": "o", "high": "h", "low": "l", "close": "c"}]                  # plotcandle()
```

**`META["overlay"]` is the field worth setting first.** It is the difference between a moving average
drawn *on* the price and one drawn in an empty pane underneath it, and it decides whether the
preview chart puts your indicator on the price's scale.

A plot may declare `style` (`line`, `stepline`, `histogram`, `columns`, `area`, `circles`, `cross`),
`color`, `transp`, `colors`, `linewidth`, `linestyle`, `overlay`, `display`, `offset`, `precision`,
`histbase` and `joinNulls`.

### Arrows say *how much*

`SHAPES` marks **when** something happened; every marker is the same size. `ARROWS` says
**how much**: the named plot's sign picks the direction, and its magnitude scales the
arrow's length against the largest absolute value in the series.

```python
PLOTS  = [{"name": "netFlow", "kind": "line"}]
ARROWS = [{"plot": "netFlow", "colorUp": "green", "colorDown": "red", "minHeight": 4, "maxHeight": 40}]
```

It needs a `line` plot, a 0/1 `signal` has no magnitude to scale by, and that is refused
rather than drawn at one uniform height.

### Per-bar colour

A `"kind": "color"` plot returns one colour **string** per bar: `"#26a69a"`, `"#26a69a80"`,
`"rgb(38,166,154)"`, or a name like `"red"`, or `None` for "leave this bar alone". It never becomes
a data column and can never be used in a strategy condition, because "above 50" means nothing for
`#26a69a`. It exists only to paint whatever names it:

```python
PLOTS = [
    {"name": "rsi", "kind": "line"},
    {"name": "zone", "kind": "color"},
]
BGCOLOR = "zone"

def calc(candles, opts):
    rsi = ta.rsi(candles["close"], opts["length"])
    zone = []
    for v in rsi:
        if v != v:                 # NaN, still warming up
            zone.append(None)
        elif v > 70:
            zone.append("#ef535020")
        elif v < 30:
            zone.append("#26a69a20")
        else:
            zone.append(None)
    return {"rsi": rsi, "zone": zone}
```

A colour channel that nothing points at is refused at compile time, it would cost a full series per
bar and draw nothing.

### The editor shows you the price

Compile draws your indicator against the real candles it was computed from. An `overlay` study shares
the price's scale, so you can see whether your band actually tracks the price; an oscillator keeps its
own range with the price shown separately for context, so a 0-100 series is not flattened against the
asset's price.

## There are no packages

An indicator is a **single self-contained file**. There is no `pip`, and no way to reach the outside
world:

```python
import pandas as pd          # ✗ module 'pandas' is not available in a Python indicator
import requests              # ✗ module 'requests' is not available
import os                    # ✗ module 'os' is not available
import sys                   # ✗ module 'sys' is not available
open("/etc/passwd")          # ✗ open is not available
import random                # ✗ module 'random' is not available
import time                  # ✗ module 'time' is not available
import datetime              # ✗ module 'datetime' is not available
```

`random`, `time` and `datetime` are absent for a different reason from the rest: not safety, but
**reproducibility**. A backtest run today and the same run in a year must produce identical numbers,
and a clock or a random draw would break that. There is no wall clock inside a historical bar, the
only time that exists is `candles["time"]`, the bar's own epoch milliseconds.

**What to do instead:**

- **Use `ta.*`**: 65 helpers, numerically identical to the JavaScript engine's.
- **Use `ulab.numpy`** for array maths: `from ulab import numpy as np`.
- **Use the standard library that is there**: `itertools`, `functools`, `heapq`, `operator`,
  `collections`, `array`, `json`, `math`, `cmath`. See the table above.
- **Write the function out.** You have 128 KB, and plain Python works exactly as it would anywhere
  else:
  ```python
  def median(values):
      s = sorted(values)
      return s[len(s) // 2]
  ```
- **Import your own indicators**: see below.

## What you *can* import: your own indicators

An indicator may import another one **already saved on your account**, through the reserved `ttq`
package. This is how you build on a baseline, and how you keep shared maths in one place instead of
pasting it into every file.

```python
# The whole indicator, as a module
from ttq import my_baseline

# …or just what you need
from ttq.my_utils import median

INPUTS = {"length": 20}
PLOTS = [{"name": "smoothed", "kind": "line"}]


def calc(candles, opts):
    b = my_baseline.calc(candles, {"length": opts["length"]})
    return {"smoothed": ta.sma(b["rsi"], 5)}
```

`import ttq.my_baseline` works too. `ttq` is the Python spelling of the JavaScript engine's `@name`
a Python import names an identifier rather than a string, so `@` is a syntax error there rather than a
convention.

Imports are resolved **before** your code runs, so a problem is reported up front with the name or the
chain that caused it:

- a name you have not saved → *You have no Python indicator named "x".*
- two indicators importing each other → *Circular import: a → b → c → a.*
- more than 5 levels deep, or more than 16 indicators

Deleting an indicator that others import is refused, and the response names the ones that depend on
it, so you can edit those first.

## Limits

| | |
| --- | --- |
| Run time | 3 seconds |
| Memory | 64 MB |
| Code size | 128 KB |
| Plots | 12 |
| Inputs | 24 |
| Imported indicators | 16, up to 5 levels deep |

Identical to the JavaScript engine's, because they are the same limits enforced in the same place.
A timeout or a memory cap is reported as its own kind of failure: your code is valid, it just could
not finish inside the budget.

## The loop: validate, preview, save

<Steps>
  <Step n={1} title="Validate">
    Checks syntax, the module shape and that every `ttq` import resolves. Loads **no market data** and
    never calls `calc`, so it answers in milliseconds. A green result means "this is a valid
    indicator", not "this works".
  </Step>
  <Step n={2} title="Compile & Preview">
    Runs `calc` against real bars for the symbol, timeframe and range in the toolbar, and plots the
    result. This is the step that finds a runtime error, a timeout, or a series of the wrong length.
  </Step>
  <Step n={3} title="Save">
    Compiles first and saves only if it compiles, a saved indicator that does not run is a landmine
    that would fail inside a backtest, far from the editor where you could fix it.
  </Step>
</Steps>

Drafts autosave as you type, and **History** in the editor toolbar restores earlier ones.

<Callout title="Validate does more work here than in the JavaScript editor">
The JavaScript editor has a full language service, so a typo gets a red squiggle as you type. The
Python editor has syntax highlighting and completion but no live type checking, so **Validate** is
where a typo surfaces. It is instant and loads no data, run it often.
</Callout>

## Using it in a query

Exactly like any other custom indicator, reference the saved name with an `@`-tag:

```text
Buy BTC when @my_osc crosses above 0, 4H last 180 days
```

The `@` here is the QUERY syntax and is the same for every custom indicator, whatever language it was
written in. It is unrelated to imports, which inside a Python file use `ttq`.

A multi-plot indicator exposes each plot name as its own series. See
[Create a custom indicator](/docs/guides/custom-indicators) for the full query side.

## Errors you will meet

| message | what it means |
| --- | --- |
| *Your indicator does not export `PLOTS`* | Declare what you draw, even if it is one line. |
| *`x` is null on every bar* | Usually a warm-up guard that is never satisfied; check for `is not None` where you meant `ta.na`. |
| *`x` is declared in PLOTS, so calc must return it* | Every declared plot needs a list back, `None`-filled during warm-up rather than omitted. |
| *Every series must line up 1:1 with the bars* | Fill warm-up bars instead of skipping them. |
| *module 'pandas' is not available in a Python indicator* | There are no packages; see above. |
| *IndentationError* | Mixed indent widths. The editor uses 4 spaces; MicroPython will not guess. |
| *Your indicator called itself too many times* | Almost always a function calling itself with no stopping condition. |
