Thesis language specification, version 1#

Thesis is the language indicators and bots are written in. A source file is text; the engine never compiles it to native code and never spawns a compiler. thesis build turns sources into a package holding a compiled program, and the interpreter in Interpreter executes that program bar by bar.

This document is the normative specification. It is written to be read by a person in one sitting and by an agent without a second source: every construct appears here with its exact behaviour, and every behaviour that could differ between two implementations is stated rather than left to the host.

1. Design rules#

These rules decide every open question in this document. Where a convenience and a rule conflict, the rule wins.

# Rule Consequence
R1 Deterministic. The same program, the same bars and the same run key (§2.1) produce the same outputs, bit for bit, on every host and every implementation, except the eleven library functions §14.1 excludes by name. No clock, no randomness, no I/O, no ambient culture, no iteration over an unordered collection. §14 is the full contract.
R2 Small. A construct earns its place by being needed for indicators or trading. No classes, no user types, no closures, no recursion, no reflection, no dynamic dispatch, no imports of anything but Thesis.
R3 Declarative. The common script is a few assignments and one plot or one trade.*. Series are implicit, history is an operator, state is var, the bar loop is the host's.
R4 Statically typed, invisibly. Every expression's type is known at compile time; the author rarely writes one. No boxing and no type checks at run time; a type error is a compile error with a position.
R5 Fast and flat. Execution per bar is a walk over an instruction array with no allocation. Registers, series buffers and call-site state are all assigned at compile time (§15).
R6 Explicit failure. A script that cannot do what it says fails with a position and a hint. No silent 0 for a missing value, that is na, and no silent truncation: an integer literal past 2^53 is AS0020 rather than a number quietly rounded (§3).

2. Execution model#

A script runs once per bar, oldest bar first, index 0 to last_bar_index. Within a bar it runs top to bottom, exactly once; there is no event queue and no reordering.

                   bar 0      bar 1      bar 2            bar n
host: load bars ->  run   ->   run   ->   run    ...  ->   run   -> collect outputs

Indicator. Every plot of every bar is recorded, warm-up included. The result is one named double? series per plot, which is what the mobile app reads and what thesis indicator --stream emits one JSON document per line.

Bot. A trade.* call reaches the trading context and is executed at the moment it is reached: there is no order queue in version 1, and nothing is deferred to the next bar. The host runs one bar in three steps, in this order, and a script sees exactly the state that order produces:

  1. the bar arrives, and every open position's protection is tested against this bar's high and low (§13.3), so a stop or a target set on an earlier bar closes its position before the script runs, and position.count already reflects that;
  2. the script runs. It reads position.*, account.* and trade.error as they stand at that instant, and a trade.* call changes them immediately, so a position.count read after a trade.buy() on the same bar includes the position that call opened;
  3. the bar ends.

Where an order fills. A market order fills against the close of the bar the script is running on, plus half the spread for a buy and minus half for a sell (§13.2). Why: that close is the last price the script has legitimately seen, so no later bar's data can reach the fill, which is the property that matters. A live run has no simulated price at all: the order goes to the venue and fills at whatever the venue fills it at, which is why a live result is comparable to a backtest but never identical to one.

Every trade.* call returns true when the host accepted and performed it, and false with a reason in trade.error when it did not (§13). Positions still open after the last bar of a backtest are closed at that bar's close, less the half spread (§13.2), and appear in the report as ordinary closed trades.

Warm-up. A bot runs a prefix of the history with trading disabled, so ta.* state is built up without orders being placed. The effective warm-up is:

A host may extend it further. During warm-up every trade.* call returns false and leaves trade.error as "closed", and position.* and order.* read as flat: a live run warms up on history, and a bar from last month must not see the positions the account holds today. A paper or live run counts the warm-up from the first bar it is given and keeps trading off until it is past, however few bars of history it started with.

A bar is closed. Thesis v1 has no forming-bar execution and therefore no roll-back of state between ticks: calc_on=tick is reserved in the bot(...) declaration and rejected with AS2110 if used. The reason is R1, a tick-recalculated bar makes an indicator's output depend on how many ticks the host happened to deliver, which no two implementations would reproduce. Paper trading over live bars is unaffected: it runs the script once per closed bar as the data arrives.

time is the bar's open time in UTC, as a millisecond epoch, and timeframe.minutes is how long one bar lasts. There is no way to read the host's clock.

The calendar is the bar's calendar and nothing else. year, month, dayofmonth, dayofweek, hour, minute, second, weekofyear and time_close are read from this bar's own open time in UTC, dayofweek ISO numbered, Monday 1 to Sunday 7; weekofyear the ISO 8601 week; time_close the open time plus the timeframe. time.timestamp builds an epoch from written-out parts and answers na for a date that does not exist, and time.in_session tests this bar's minute of day against a fixed UTC window. There is no time zone, no daylight-saving table and no now: a DST database is a clock by another name, and R1 forbids it.

2.1 What identifies a run#

R1 promises the same outputs for the same program and the same bars. The complete list of what the outputs depend on, the run key, is:

Part What it covers
the program the package's SHA-256, which covers the bytecode and the sources it was built from
the input values every input.* by name; one the host does not supply takes its declared default
the bars the instrument, the timeframe and the exact bar window, including the bars before the first reported one
the instrument's facts symbol.*, digits, tick size, lot size, and minimum, maximum and step quantity
the account starting balance, leverage and account currency
the fill model the spread and the commission per lot the host simulates (§13.2)
the effective warm-up the number above, after any host extension

Two runs that agree on all seven agree bit for bit. A backtest report echoes the bot, the instrument, the timeframe, the bar count, the warm-up, the window and the starting balance; recording the rest is the host's job, and a host that reports a number without its run key has reported an anecdote.

3. Lexical structure#

A source file is UTF-8, with or without a BOM. Line endings may be LF or CRLF and are equivalent; a file need not end with a newline. A tab is 4 columns wide for indentation and 1 column wide in a diagnostic position.

Comments start with // and run to the end of the line. There is no block comment. A comment on the first line of the form #thesis 1 is the optional version pragma (§18).

Indentation defines blocks. One level is 4 spaces or 1 tab, and a file may not mix the two (AS0010). An indent that is not a multiple of a level is a continuation of the previous line, which is how a long expression is wrapped:

total = ta.sma(close, 20) +
  ta.sma(close, 50)

A line also continues when the break falls inside ( ) or [ ], where indentation is not significant at all, or when the line's last token is one of exactly these:

+  -  *  /  %  =  :=  +=  -=  *=  /=  %=  ==  !=  <  <=  >  >=  ?  :  ,  .  and  or  not  (  [  to  by

=> is deliberately not in that list. It ends the header of a function or of a switch arm, so what follows it on the next line is a block.

Literals.

Kind Examples Notes
int 14, 0, 1_000 an integral number, exact to ±2^53 (§14.1); _ separates digits
float 1.5, .5, 2., 1e-3, 6.02e23 IEEE-754 binary64
bool true, false
string "text", 'text' escapes \\ \" \' \n \t \r \0 \uXXXX; no interpolation
color #RRGGBB, #RRGGBBAA case-insensitive hex
na na the absent value, §4

An integer literal past 9_007_199_254_740_992, 2^53, the largest whole number an integral binary64 holds exactly, is AS0020, and the hint names the decimal form. Why: the alternative is a literal that compiles and then quietly means a different number, which R6 forbids. A float literal that overflows binary64 is AS0021. There is no octal, hex or binary integer literal, a colour is the only # form.

\uXXXX is one UTF-16 code unit, which is the unit str.length counts and str.substring indexes (§14.6). A character outside the basic multilingual plane is written as its two surrogate escapes, and a lone surrogate is a legal code unit rather than a lexical error.

Identifiers match [A-Za-z_][A-Za-z0-9_]* and are case-sensitive. Unicode letters are not identifier characters, which keeps one spelling per name across editors.

Keywords, reserved and unusable as identifiers:

and  as  bot  break  by  continue  else  export  false  for  if  import  in
indicator  module  na  not  or  return  simple  switch  to  true  var  while

return is a keyword with no statement form in v1 (§8): it is in this list so that a script written with one fails as a syntax error instead of declaring a variable called return.

const, enum, fn, let, method and type are reserved for future versions and produce AS0030 rather than being parsed as identifiers, so a v1 script keeps working when they gain meaning.

The five type names, int float bool string color, are not keywords. They are read as a type where a declaration or a parameter list expects one, and as ordinary names everywhere else, so color = 1 declares a variable called color and color.blue afterwards reads a member of that variable and fails with AS2121. A script SHOULD NOT use them as names.

4. Types and na#

Five types: int, float, bool, string, color. There are no others, no arrays, no maps, no user-defined types in v1 (§16).

Every value is a series: it has one value per bar, and §6 reads its history. "A float" and "a series of float" are the same thing; the distinction that matters is the qualifier:

Qualifier Meaning Written
series may differ on every bar the default, never written
simple fixed for the whole run, a literal, an input, or arithmetic over those simple int length in a parameter list

simple exists because a window length must be known before the first bar: a buffer of ta.sma(close, n) cannot be resized per bar. Passing a series where simple is required is AS2010, and the message names the input or literal that would make it simple.

Conversion. int widens to float implicitly and never the other way. math.floor, math.ceil, math.idiv and math.sign return int; math.round returns float, because it takes an optional number of digits and math.round(x, 2) is not a whole number, §14.6 fixes its rounding rule. Nothing else converts implicitly: a number is not a bool (if count is AS2011, write if count != 0), a number is not a string ("n=" + count is AS2012, write "n=" + str.tostring(count)), and a color converts to nothing.

na is the absent value, and it is a value of int, float or string. No operator and no built-in ever produces an absent bool or color; the one way to hold one is a history read that reaches past the bars that exist, or a slot a branch did not reach (§6). In that state every consumer of a bool, if, while, not, and, or, ?:, a comparison, a marker condition, reads it as false, and na(x) is the only way to tell it from a false the script computed. Why: a condition stays decidable on bar 0, so if needs no third branch.

A bare na takes the type of the place it is written in: the declared type of a typed declaration, the variable it is assigned to with :=, or the other arms of a ?:, an if or a switch used as a value. So int x = up ? 1 : na, count := na for an int, and up ? "up" : na hold an absent int, int and string. Anywhere else it is a float.

The core functions, with their exact types. na and nz each read their argument's type and take the matching form, so a script writes one name for numbers and for text:

Call Produces Meaning
na(value) bool true when a number, or a bool read from history, is absent
na(text) bool true when a string is absent
nz(value[, replacement]) float the number, or the replacement, 0 by default
nz(text[, replacement]) string the text, or the replacement, "" by default
fixnan(value) float the last value that was not absent, carried forward

nz_string and na_string are the catalog ids of those two text forms, what BuiltinId calls them and what the conformance corpus keys them by, not names a script writes: nz_string(x) is AS2050. builtins.md lists both forms under the spelling a script uses, because the compiler reaches the text form through na/nz and the argument's type.

nz of a number yields a float even for an int argument, so nz(count) cannot serve as a window length or a loop bound; keep the int and test it instead, na(count) ? 0 : count.

na + 1          // na
na == na        // AS2013     - use na(x); == on na is a mistake, not false
na(na)          // true
nz(na)          // 0.0        - a float, whatever the argument's type was
nz(na, -1)      // -1.0
close > na      // false      - every comparison against na is false
math.max(na, 5) // na         - a function propagates na unless builtins.md says otherwise

A float that becomes a non-finite IEEE value, 0.0 / 0.0, math.log(-1), 1e308 * 10, is na. There is no NaN and no infinity in Thesis: x / 0 is na for every x, including 0 / 0, and no plot, no report and no JSON document ever carries one. That rule is why a mobile client can parse every number this engine emits.

4.1 na where a value decides something#

Position What happens
a for bound, for i = 0 to n, n absent the loop runs zero times: the test is i <= n, and every comparison with na is false
a while condition reads as false, so the loop does not run
a switch subject no constant arm matches, so the default arm runs; a statement switch without one does nothing
a history index, close[n], n absent the run stops with AS3010, which an absent index shares with a negative one
an argument inside a window built-in the result stays na while an absent value sits anywhere in the window: a window is not ready until it is full of real values. ta.vwma is the exception, it answers from however many values it holds, which the bytecode specification states per function
ta.cum the absent value contributes 0 and the running total survives
fixnan, ta.barssince, ta.valuewhen, ta.crossover and friends each is defined over absent values, and builtins.md states what each one does

5. Variables and assignment#

length = 20                 // declare, inferred int, re-evaluated every bar
float basis = na            // declare with an explicit type
var int bars_up = 0         // declare once, survives across bars
basis := ta.sma(close, 20)  // assign to an already-declared variable
bars_up += 1                // compound: += -= *= /= %=

= declares and infers; re-declaring a name in the same scope is AS2020. := assigns and requires the name to exist (AS2021) and the types to match (AS2022). A plain = on an existing name is the most common mistake an agent makes, so AS2020's hint is use := to assign.

A declaration without var is re-initialised on every bar. var initialises on the first bar the declaration is reached and keeps its value afterwards, which is how counters and state machines are written. A var declaration must be at the top level of the script or of a function body, not inside if, for or while (AS2023), otherwise its initialisation would depend on a condition and R1 would be lost.

Scope is lexical and block-based: a name declared in an if, for or while body leaves scope at the end of that body. Shadowing an outer name is AS2024; the engine prefers a different name to a subtle bug. There is exactly one global scope, the script's top level.

6. Series and history#

x[n] is the value of x on the bar n bars ago; x[0] is x. n must be an int ≥ 0 (AS2030 for a negative constant; a negative or absent value at run time is AS3010). Reading further back than the available history is na, never an error, a script's first bars legitimately have no past, and for a bool or a color §4 states what that absent value reads as.

change = close - close[1]           // na on bar 0
prev_sma = ta.sma(close, 20)[1]     // history of a function's result

History is available for any expression, not only a variable, and the compiler allocates a buffer for each call site that needs one. A buffer's depth is the largest constant index used on it, plus what a ta.* call needs internally; it is fixed at compile time (R5). A non-constant index, close[n] where n is a series, makes the depth unknowable, so the compiler reserves max_history (§15) and AS2031 warns when that is larger than the script is likely to need. An index past max_history reads as na rather than failing.

One slot per bar, holding the bar's last value. A series slot is written once per bar, on the bar the expression is reached, and a variable assigned several times in one bar overwrites that slot each time. Its history therefore records the value it holds when the bar's execution finishes, or when its scope ends, for a name declared inside a block:

x = close
x := x * 2
y = x[1]            // the previous bar's close times two, not the previous close

An expression's history records the value at the point the expression was evaluated, which is the same rule: that is where its slot is written. A slot inside a branch the bar did not take is written with na by the compiler, so a history index always means "that many bars ago" and never "that many evaluations ago", which is why AS2060 is a warning rather than an error: the gaps are visible as na instead of silently shifting a series.

The declaration order rule follows from the bar loop: an expression may only read history that has already been written, so a variable may not be read before its declaration is reached, even one bar back. The compiler reports that as AS2021, at the point of the read, the name is not declared, and AS2032 is reserved for a message that says so in those words.

7. Operators#

Highest precedence first. All binary operators are left-associative; ?: is right-associative.

Operators Notes
x[n] f(...) a.b history, call, member
unary - + not
* / % / is always float division; % is the remainder with the sign of the left operand; both are na when the right operand is 0
+ - + also concatenates string + string
< <= > >= numbers only; na on either side is false
== != any matching pair of types; na on either side is false, and a literal na operand is AS2013
and short-circuit
or short-circuit
cond ? a : b cond is bool; a and b must have one common type

There is no !, &&, ||, ++, --, **, no bitwise operator and no assignment inside an expression. math.pow(x, y) is the power. not/and/or apply to bool only.

int op int is int for + - * %, and float for /. Mixing int and float gives float. An int is an integral binary64 (§14.1), so an integer operation truncates its result towards zero and never wraps: past ±2^53 it stops being exact, which no script reaches because the largest integer any of them holds is a bar index. There is no wraparound anywhere in the language, and AS0020 keeps a literal from entering that range in the first place (§3).

8. Control flow#

if is both a statement and an expression. As an expression every branch must produce the same type and an else is required (AS2040).

if close > open
    up += 1
else if close < open
    down += 1

regime = if z > 1
    1
else if z < -1
    -1
else
    0

switch matches a value against constant arms, in source order, and => with no value is the default arm. As an expression it must be exhaustive, which for v1 means it must have a default (AS2041). Duplicate arms are AS2042. A subject that is na matches no constant arm, so the default arm runs (§4.1).

label = switch kind
    "SMA" => sma_value
    "EMA" => ema_value
    => hma_value

A switch with no subject reads its arms as bool conditions instead, in source order, and the first one that holds wins; a bare => is still the default arm. It is an if/else if ladder written as a table, and it carries the same rules: a condition that is na reads as false (§4), an expression form still needs a default (AS2041), and nothing after the winning arm is evaluated.

regime = switch
    z > 1 => 1
    z < -1 => -1
    => 0

for iterates an inclusive int range. Both bounds are evaluated once, before the loop, and by gives the step:

Written Step The test What it does
for i = 0 to 4 1 i <= to counts up
for i = 0 to 10 by 2 2 i <= to counts up in twos
for i = 10 to 0 by -2 -2 i >= to counts down in twos
for i = 5 to 0 1 i <= to runs zero times

A step is written out as a whole-number literal, optionally signed, and never computed: a by that is anything else is AS2044, and so is a literal 0, which would never reach its bound. Why a literal: the sign of the step decides whether the test is i <= to or i >= to, and that choice is made once, when the program is compiled, a step taken from an input would be a loop whose direction is not known before the first bar, which R5 forbids. An absent bound runs the loop zero times in either direction, because every comparison with na is false (§4.1).

Without by the step is 1 and the loop counts up only, so for i = 5 to 0 runs zero times rather than counting down. Assigning the loop variable inside the body is accepted in v1 and changes the iteration, because the counter is the register the loop increments; AS2043 is reserved for the diagnostic that will refuse it, so do not write one.

float weighted = 0.0
for i = 0 to 4
    weighted += close[i] * (5 - i)

float recent = 0.0
for i = 10 to 0 by -2
    recent += close[i]

while repeats while a bool holds. break and continue apply to the innermost loop. Every loop is bounded by max_iterations per bar (§15) and exceeding it is AS3020, naming the loop's line, an infinite loop is a hang, and a hang in a backtest of 100 000 bars is indistinguishable from a slow one.

There is no return in v1: a function's value is its last expression, and return is a keyword with no statement form, so writing one is a syntax error (§3).

9. Functions#

slope(x) => x - x[1]                        // single line

zscore(x, n = 20) =>                        // default argument, block body
    mean = ta.sma(x, n)
    dev = ta.stdev(x, n)
    dev == 0 ? 0.0 : (x - mean) / dev       // the last expression is the value

channel(float x, simple int n) =>           // explicit types and qualifiers
    hi = ta.highest(x, n)
    lo = ta.lowest(x, n)
    [hi, lo, (hi + lo) / 2]                 // a tuple

[hi, lo, mid] = channel(close, 20)          // destructuring, all three required

A function must be declared before it is called (AS2050), may not call itself or form a cycle (AS2051, R2), may not be passed as a value (AS2052: there are no function values in v1), and must end with an expression that produces its result (AS2053).

A function call keeps state per call site. Two calls to zscore are two independent series of ta.sma state, exactly as if the body had been written twice. This is the rule that makes a helper function safe to use in an indicator, and it is why a call inside a condition is a warning:

fast = ta.ema(close, 9)          // runs every bar - correct
if something                     // AS2060: ta.ema inside a branch
    slow = ta.ema(close, 21)     //   its history has gaps on the bars the branch was false

AS2060 covers a stateful call inside if, switch, a ternary arm, or the right side of and/or; AS2061 covers one inside a loop. Both are warnings, not errors: a script may want exactly that, and a warning that cannot be silenced would be worse than the bug.

9.1 What a body may reach#

Every rule here follows from the one mechanism: version 1 compiles the body again at each call site (bytecode §4), so a call is a block of the caller's own code.

Question Rule
May a body read a global? Yes, any name declared above the call site, because that is where the body is compiled.
May it assign one? Yes, with :=, and it assigns the caller's variable. A function that does this is not pure; prefer returning a value.
Are parameters mutable? Yes, and assigning one writes the caller's variable when the argument was a plain variable, because inlining binds the parameter to that variable's register rather than to a copy. A body that does p := p * 2 therefore doubles the caller's x for the rest of the bar. Treat a parameter as read-only.
May a parameter shadow a global? Yes. AS2024 stops at the function boundary, so slope(close) needs no new name for close.
Is var per call site? Yes, and so is every history buffer and every ta.* state cell the body uses.
What is x[1] of a parameter? The value the argument expression held one bar ago, read from a buffer this call site owns.
How is an omitted parameter type resolved? Per call site, from the argument. One call with an int and one with a float compile two specialised bodies, which inlining makes free.
How deep may calls nest? 32 levels (§15); deeper is AS2130, because the nesting is resolved at compile time.

10. Modules and imports#

A package has one main file, the one that declares indicator(...) or bot(...), and any number of module files. The normative extension is .thesis.

// Volatility.thesis
module("Volatility")

export atr(simple int length) =>
    tr = math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1])))
    ta.rma(tr, length)
// Main.thesis
#thesis 1
indicator("Channel", on_price=true)
import "Volatility.thesis" as vol

width = vol.atr(14) * input.float(1.5, "Multiplier", min=0.1, step=0.1)
plot(close + width, "Upper")
plot(close - width, "Lower")

Rules. A module declares module("Name") and exports with export; a non-exported function is private to its file. An import is a statement and MAY appear wherever a statement may, but SHOULD sit directly after the declaration, where a reader looks for it. The alias is an identifier and is required, there is no wildcard import, so every call says where it comes from. A module may import other modules, to a depth of 8 (§15). A module may not declare indicator/bot, inputs, plots or trade.* calls: a module computes, the main file decides. AS2073 covers what a module writes at its top level; inside an exported function the same mistakes are caught one layer down, an input as AS2080 and a plot as AS2090, and a trade.* call is refused only when the MAIN file is an indicator (AS2142), so a module that trades does compile into a bot. Do not write one: it makes the module unusable from an indicator. Each call to an imported function gets its own state, per call site, like any other function (§9).

A path is matched exactly. An import path is relative to the importing file, uses / as its separator, may not leave the package root (AS2070), may not be absolute or carry a drive letter (AS2071), and must equal the path of one of the files the build was given, byte for byte, including case (AS2074). import "volatility.thesis" therefore does not find Volatility.thesis on any machine, which is deliberate: the alternative is a package that builds on Windows and macOS and fails on Linux. Import cycles exhaust the import depth and are reported as AS2130; AS2072 is reserved for the dedicated message.

A module imported twice under two aliases is one module: it is identified by its path, its functions are registered once, and every call site still keeps its own state.

11. Script declaration and inputs#

The main file's first statement after the optional version pragma declares what it is. Every argument of a declaration is written out rather than computed, because the package's manifest carries them and a host reads the manifest without running the script.

indicator("Title", label="T", on_price=true, precision=4)
bot("Title", qty_type=percent_equity, qty_value=2, warmup=200)
indicator argument Type Default Meaning
title string required shown by the app
label string title short label
on_price bool false drawn on the price chart or in its own pane; also the default for each plot
precision int 2 digits the app renders
bot argument Type Default Meaning
title string required
label string title short label
qty_type units | percent_equity units how a trade.* call with no quantity reads qty_value
qty_value float 1 the default quantity (§11.2)
warmup int computed bars to run with trading disabled (§2)
calc_on - - reserved; passing it is AS2110

Inputs become the engine's parameters, the things thesis optimize searches and the app renders as controls. They must be called at the top level of the main file and assigned to a variable (AS2080), because a parameter that appears conditionally has no stable name.

Input Yields Extra arguments
input.int(14, "Length", min=2, max=200, step=1) simple int min max step group tooltip
input.float(1.5, "Mult", min=0.1, max=5, step=0.1) simple float the same
input.bool(true, "Show") simple bool group tooltip
input.string("EMA", "Type", opts=["SMA", "EMA"]) simple string opts group tooltip
input.source(close, "Source") series float group tooltip
input.color(#FF9800, "Colour") simple color group tooltip

input.source accepts open high low close volume hl2 hlc3 ohlc4 and nothing else (AS2081). opts= takes a constant list: square brackets holding string literals, which is the only place v1 accepts that form, there are no arrays (§16), and the brackets here are read by the compiler rather than evaluated.

11.1 What thesis optimize searches#

An int or float input is searched when it declares all three of min, max and step; one that does not is published without a range and held at its value.

Any input may also be searched over an explicit list of values, whatever its type, and that is how input.bool and input.string are searched: the host is given the values rather than bounds, and searches exactly those, in the order written. A list is the only way to search a bool or a text input, because neither has a min, a max or a step to walk; an int or a float may use either form. A list carries at least two values and none of them is empty.

use_filter=false|true                       a true/false input
method=sma|ema|wma                          a text input, usually its own opts= values
length=10|20|50                             an int searched over three values rather than a grid

The values of a searched parameter are, for k = 0, 1, 2, ...:

value(k) = min(min_value + k * step, max_value),   for k = 0 .. floor((max_value - min_value) / step + 1e-9)

Three consequences, each deliberate: a value is one multiplication from min, so repeated addition cannot accumulate error and 0.1 + 0.1 + 0.1 never becomes 0.30000000000000004; the last value is clamped down to max, so max is searched only when it lies on the grid, min=0, max=1, step=0.3 searches 0, 0.3, 0.6, 0.9 and never 1; and a step of 0 or a max at or below min yields the single value min. The combinations are enumerated with the first declared parameter varying fastest, and a pass's warm-up uses the largest window any searched value produces, the values the grid really visits, not the range's upper bound, so every pass sees the same number of warm-up bars (§2.1). A pass with no losing trade has no profit factor, and a pass whose trades all returned the same has no Sharpe ratio; under those objectives each ranks above every finite value, ordered by its net profit, and reports a fitness of 1 000 000 plus that profit. The result reports the warm-up as warmup, and thesis backtest --warmup <n> with a pass's parameters reproduces that pass exactly.

What a pass is ranked by is a LIST of criteria, each with a direction. Twelve are defined, and each is read off the run's own report:

Criterion Is
netprofit the run's net profit
profitfactor gross profit over gross loss
sharperatio the return-to-variability ratio of its trades
winrate the share of trades that won
averagetrade net profit over the number of trades
totaltrades, winningtrades, losingtrades the three trade counts
equitydrawdown, equitydrawdownpercent the worst fall from a peak of EQUITY, in money and as a share of the starting balance
balancedrawdown, balancedrawdownpercent the same measured on the BALANCE, so an open position's mark does not count

A criterion written alone takes the direction that reads as better: a drawdown and a losing trade count are minimised, and every other criterion is maximised. name:max and name:min say so explicitly, and must be written whenever the default is not what is meant.

Several criteria rank lexicographically, in the order given. The first decides; the second decides only between passes the first scored equally, and so on. The first criterion alone drives the search, a genetic search selects on it, and a strided grid samples by it, so a tie-breaker changes which pass is reported best, never which passes were visited. Why lexicographic rather than a weighted sum: a weight has no unit a script or a user could defend, and two hosts would choose different ones, which §14 does not allow.

A criterion the run cannot produce a number for is ranked as the worst possible value rather than skipped, so a pass never wins a comparison by being unmeasurable.

11.2 How a quantity is read#

A quantity is in instrument units (§13). The host divides by symbol.lot_size to reach the size a venue quotes, and rejects the call with trade.error == "invalid_volume" when the result is zero, negative, not finite, or below symbol.min_qty. A trade.* call does not round the quantity it is given to symbol.qty_step, and does not test it against symbol.max_qty: a quantity the venue would refuse is refused, never silently moved, because a size the script did not ask for is a position it did not ask for.

Three calls do the rounding and the arithmetic for it, and they are the whole of what sizing by risk needs - the pip value in the account currency, the venue's own step, and the account's leverage are all facts the host knows and the script does not:

Call Produces What it answers
symbol.qty_for_risk(money, stop_distance) float the largest quantity whose loss at a stop that far away is at most money, in the account currency. Rounded DOWN to symbol.qty_step; 0 when even symbol.min_qty would risk more
symbol.round_qty(units) float that quantity rounded DOWN to a whole multiple of symbol.qty_step, or 0 when it falls below symbol.min_qty
symbol.margin_for(units) float the margin that size would tie up at this bar's price, in the account currency - what to compare with account.margin_free before asking

All three answer 0 rather than na for "nothing tradeable", so if size > 0 is the whole guard, and na only for an argument that is absent or not a positive number.

stop = ta.atr(14) * 2
size = symbol.qty_for_risk(account.equity * 0.01, stop)

if size > 0 and symbol.margin_for(size) < account.margin_free
    trade.buy(size, sl_dist=stop)

The three price facts sizing needs are readable. symbol.pip_size and symbol.tick_size are fixed for the run; symbol.pip_value and symbol.tick_value are what one pip and one tick are worth in the account currency for a position of ONE unit, so a stop of d price units on q units risks q * d * symbol.pip_value / symbol.pip_size. symbol.spread, symbol.bid and symbol.ask answer the current quote; a host that quotes no spread answers na for all three, and a script that divides by one must say what it does then (§4.1).

With qty_type=percent_equity, a trade.* call that omits its quantity uses

units = account.equity * qty_value / 100 / close

evaluated on the calling bar, with no leverage and no currency conversion: it is a share of equity at this bar's price, in instrument units. A call that states its quantity always states units, whatever qty_type says.

12. Indicator outputs#

plot(value, "Name", color=color.blue, width=2, style=line, on_price=true)
hline(70, "Overbought", color=color.red, style=dashed)
marker(ta.crossover(r, 30), "Cross up", shape=triangle_up, position=below, color=color.green, value=r)

plot is the only output the engine reads: each call becomes one named series in IndicatorResult, in call order, and na means "not calculated on this bar", which is what a warm-up prefix produces and what the app draws as a gap. on_price= on a plot overrides the declaration's own on_price for that series. hline and marker are chart decoration carried through to the app untouched. marker's optional value= is compiled and carried in the program for a host that places a mark at a value rather than at a bar; this engine's host records only which bars were marked, so the number has no effect on its output today.

An hline's level is a literal number written here, it is part of the package's description, which is read without running the script, so a level that moves is a plot, not an hline (AS2022, whose hint says exactly that). §16 holds the rule a level taken from an input will arrive under.

Every output call must be at the top level of the main file with a constant name (AS2090), and two outputs may not share a name (AS2091), because the name is the key the app looks the series up by. An indicator that declares no plot is AS2143.

style is line | histogram | points | area | step, shape is circle | square | triangle_up | triangle_down | arrow_up | arrow_down | cross | label, position is above | below | absolute, and an hline's style is solid | dashed | dotted. These are bare words read from the source, not identifiers: the compiler reads the word the argument is written with, so a variable of the same name neither hides them nor is consulted, and an unknown word is AS1040 with the accepted list in the message. §16 holds what happens to these words if drawing namespaces arrive and need the same spellings.

12.1 Colours#

A colour is an exact integer packed as 0xAARRGGBB. A color= argument of plot, hline or marker is written out: a #RRGGBB/#RRGGBBAA literal or one of the seventeen color.* names, and nothing computed, a color.new(...) or color.rgb(...) call there is AS2022, whose hint names the reason: the colour lives in the manifest, which is read without running the script. Those two functions are for a colour a script computes and compares, not for an output argument.

color.new(base, transparency) takes a transparency of 0..100, clamped, and produces

alpha = round_half_away_from_zero(255 * (1 - clamp(transparency, 0, 100) / 100))

keeping the low 24 bits of base. The order of that expression is normative, not cosmetic: binary64 does not hold 0.9, so at a transparency of 90 this gives 25, while the algebraically equal 255 * (100 - 90) / 100 is exactly 25.5 and rounds to 26. So 0 is opaque, 100 is invisible, 50 is 128 rather than 127, and 90 is 25. color.rgb(red, green, blue[, alpha]) takes four channels of 0..255, each rounded half away from zero, clamped into range, and read as 0 when it is absent. == on two colours compares the packed integers exactly, two spellings of one colour are equal, and nothing about perceptual distance is implied.

13. Trading#

The trading surface is platform and market agnostic: the same script runs against FX, CFDs, crypto, futures, stocks and options, because it never mentions a venue, an account type, a lot or a currency pair. Quantity is in instrument units, lots are an FX presentation detail the host applies through symbol.lot_size, coins are coins, shares are shares, contracts are contracts (§11.2).

A run addresses ONE instrument. symbol is that instrument, close is its series, and every position.* value and every trade.* call is scoped to it, including trade.close_all(), which closes this run's positions and not the other positions of an account that happens to hold some. That is what makes a backtest and a live run of the same script comparable: a backtest has one instrument by construction, so a live run must behave as if it does too. A portfolio across instruments is §16.

trade.buy()                              // qty_value units from the bot(...) declaration
trade.buy(2)                             // 2 units
trade.buy(1, sl=1.0950, tp=1.1050)       // absolute protection prices
trade.sell(1, sl_dist=0.0050)            // protection at a distance in price units
trade.close(position.id)                 // close one position
trade.close_all()                        // close every open position of this instrument
trade.modify(position.id, sl=1.0960, tp=na)   // na clears a level

Every trade.* call returns a bool: true when the host accepted and performed it. A rejection, not enough margin, a quantity below the instrument's minimum, a protection level on the wrong side of the price, is a value, not an exception: the script decides what to do, and trade.error names the last rejection. A bot that ignores the result is not wrong, it is just a bot that does not care.

trade.error When
"none" the last call was accepted
"invalid_volume" the quantity is zero, negative, not finite, or below symbol.min_qty (§11.2)
"insufficient_margin" the required margin is more than account.margin_free
"invalid_protection" a stop is past the entry price, or a target is not strictly beyond it, a stop AT the entry is a break-even stop and is accepted
"not_found" trade.close or trade.modify named a position that is not open, or an absent id
"market_closed" the instrument declares trading sessions (§13.2) and this bar's open time falls outside every one of them. The bar is still a bar - indicators compute on it, protection and pending fills are still tested against it - only a NEW position or order is refused, which is what a venue does
"not_supported" the call asked for something this host cannot do rather than something wrong: a partial take-profit ladder on a live account, for instance. It is refused rather than placed without the ladder, because an order that silently lost its scale-out is worse than an order that was never placed
"closed" trading is not available: the warm-up prefix (§2), or no bar is being processed yet

13.1 One position per call: the model is hedging#

trade.buy() and trade.sell() each open a new position. Nothing nets off: selling while long leaves both positions open, position.count is 2, and position.qty is their signed sum. Why: it is the model a script can reduce to netting itself, close before you open, while the reverse, recovering two positions from one net, is impossible.

Expression Type Meaning
position.count int open positions
position.is_long / position.is_short bool any open position on that side
position.qty float net quantity in instrument units, negative when net short
position.entry_price float the volume-weighted entry of every open position, na when flat
position.profit float the sum of each open position's profit, net of commission, as marked at the start of this bar; a position opened on this bar reads 0 until the next one
position.id int the oldest open position's id, na when flat. What trade.close takes
position.bars int bars since the oldest open position was opened, na when flat
position.last_id int the id of the position opened most recently by this script, na before the first one. Read it on the same bar as trade.buy to address what was just opened
account.balance float realized only: it moves when a position closes
account.equity float balance plus every open position's profit at this bar's close
account.margin_free float equity minus the margin the open positions tie up
account.currency string

trade.close(id) closes one position; trade.close_all() closes them oldest first and returns true only if every one succeeded. trade.modify(id, sl=, tp=) replaces both levels, a level left out or passed as na is cleared, not kept.

Addressing one position of several. position.count, position.qty and position.profit aggregate, and position.id is only ever the oldest. A grid, a pyramid, an averaging-down rule or a hedge lock needs its SECOND position, not the sum. Two forms reach it, and neither needs an array: position.id_at(index) walks the open list oldest first, and each position.*_of(id) reads one fact of one position. An index past the last position, and an id that is not open, both read na rather than refusing, so a script that has just gone flat reads na instead of a wrong number.

Call Type Meaning
position.id_at(index) int the id of the open position at index, oldest first; na past the last
position.qty_of(id) float that position's size in instrument units, negative when it is short
position.entry_of(id) float the price it was opened at
position.profit_of(id) float its profit, net of its financing, as marked at the start of this bar
position.bars_of(id) int how many bars ago it was opened
position.is_long_of(id) bool whether it is long
position.sl_of(id) float its stop level, na when it has none
position.tp_of(id) float its target level, na when it has none
position.swap_of(id) float the financing it has accrued so far
position.pips_of(id) float how far it has moved in its favour, in pips; negative when it is losing
position.label_of(id) string the label it was opened with, na when it has none
position.comment_of(id) string the comment it was opened with, na when it has none

A label is how a position says which rule opened it. trade.buy and trade.sell take label= and comment=, both written as text, both optional, neither interpreted by the host, and the position, the resting order and the closed trade all carry them. A label is what makes a bot's positions findable among the positions of an account it shares, and what lets a report group trades by entry signal; a comment is free text for a human reading that report. Three calls read the book by label, and all three answer na or 0 rather than refusing when nothing matches:

Call Type Meaning
position.id_of(label) int the id of the OLDEST open position carrying that label
position.last_id_of(label) int the id of the NEWEST one
position.count_of(label) int how many open positions carry it

Labels are compared byte for byte, like an import path (§10) and like == on text (§7): there is no case folding and no trimming, because a host that folded them would make two labels one on some machines and not others. An account the host cannot label, a venue that carries no such field, reads na for every label, and a script written against labels must therefore treat na as "not mine to know" rather than as "no position".

if ta.crossover(close, slow)
    trade.buy(1000, label="trend", comment="fast crossed slow")

if position.count_of("trend") > 2
    trade.close(position.id_of("trend"))        // retire the oldest leg of that rule only
newest = position.id_at(position.count - 1)

if position.count > 0 and position.count < 3 and position.pips_of(newest) > 30
    trade.buy(1000)                          // pyramid onto the newest leg

if position.count > 1 and position.pips_of(newest) < -20
    trade.close(newest)                      // and cut only that leg

13.2 The fill model a backtest simulates#

A host simulates a small set of numbers, and every one of them is part of the run key (§2.1): the spread, the commission, the leverage, the slippage, the financing, the stop-out level and the data mode of §13.8. Only two of them are readable from a script: the spread, through symbol.spread, symbol.bid and symbol.ask, because sizing a position by the money at risk cannot be done without it (§11.2); and the leverage, through symbol.margin_for. The rest are not, and none of them has to be: every one is part of the run key, so two runs that agree on the key agree on the numbers whatever the script read.

Quantity Rule
half spread spread_pips * pip_size / 2, where the pip size is an instrument fact the host supplies and a script cannot read. A host may instead draw the spread per bar between a floor and a ceiling, or take it from the bid and ask of tick data; which it did is part of the run key
entry price the calling bar's close plus the half spread for a buy, minus it for a sell, then moved a further slippage_pips * pip_size against the position
exit price for a close the script asks for, and for the end of the data: the closing bar's close minus the half spread for a buy, plus it for a sell. A stop or a target fills by the rule of §13.3 instead
commission 2 * commission_per_lot * lots by default, charged in full when the position closes and already subtracted from position.profit while it is open. A host may charge per million traded units, as a percentage of notional, or a flat amount per side, and may put a floor under one side; the default is the per-lot rule version 1 had
gross profit (exit - entry) * direction * lots * symbol.lot_size
required margin lots * symbol.lot_size * entry / leverage, tested against account.margin_free. An instrument may declare dynamic leverage as ascending bands of (units, leverage): the band a position's size falls into decides its leverage, a size past the last band uses that one, and the account's own leverage is always the ceiling - so a bigger position can only ever tie up proportionally MORE margin, never less. With no bands the account leverage is used, which is what version 1 did
trading sessions an instrument may declare the weekly windows it trades in, as UTC minutes of a day. A bar whose open time falls outside every window still exists - it is served, indicators compute on it, and protection and pending fills are still tested against it - but trade.buy, trade.sell and the four pending calls are refused with "market_closed". With no windows every bar in the data is tradeable, which is what version 1 did. Why only the opening calls: a position already open must still be closable, by the script and by its own stop, or a weekend would trap it
financing one charge per rollover, from the instrument's long and short swap rates: crossing into a new UTC day charges one day, crossing into the triple-swap weekday charges three, and crossing into a Saturday or a Sunday charges nothing. It accrues into position.profit while the position is open and into the closed trade when it closes. Both rates default to zero, which is what version 1 charged
account currency the currency the balance, every profit, every charge and the margin are denominated in. It defaults to the instrument's quote currency, which is why an unconfigured USDJPY run keeps its balance in yen; a host may be told another currency instead, and must then be given the rate that converts the quote currency into it. account.currency reports whichever it is
conversion one constant rate per run, applied to the gross profit, the commission, the financing and the required margin - so --balance 10000 means the same amount of money whatever the instrument is quoted in, and two reports can be compared. A host that cannot convert must refuse the run rather than report quote-currency numbers under another currency's name. A rate that varies over the window is §16
stop out when the host is given a stop-out level and equity falls below that share of the margin the open positions tie up, the worst position is closed at the market - paying the spread and the slippage like any other market close - repeatedly, until the level is met again. A level of zero, the default, never stops a run out

A protection distance, sl_dist, tp_dist, is measured from the calling bar's close, not from the filled entry price, so a buy's stop sits sl_dist below the close while its entry is half a spread above it.

A level is judged against the price the position would exit at right now, not against its entry. A long's stop must be at or below the current bid and its target strictly above it; a short's stop at or above the current ask and its target strictly below it. On the bar that opens a position those two prices are the entry, so nothing about an opening call changed, but trade.modify and trade.trail may now move a stop past the entry and into profit, which is what makes a trailing stop real (§13.6). A level on the wrong side of the market is still refused with invalid_protection rather than silently clamped, and a stop exactly at the current price is accepted.

13.3 Where a stop and a target fire#

Protection is tested once per bar, before the script runs (§2), against that bar's high and low on the side the position exits at, the bid for a long, the mid price less the half spread, and the ask for a short, the mid plus it, and a position opened on bar i is therefore first tested on bar i + 1.

  1. For a long position the stop loss is tested first: if low - half_spread <= stop, the position closes. Only then is high - half_spread >= target tested. For a short position, high + half_spread >= stop first, then low + half_spread <= target. Why the spread: a long is sold at the bid, so a target the mid price merely touched was never a price the position could have been sold at.
  2. When one bar contains both levels, the stop wins. Why: a bar says nothing about the order its extremes happened in, so the pessimistic reading is the only one that cannot flatter a backtest. That is the rule the bars data mode is held to. Under a minute or tick path the bar is walked as an ordered sequence of narrower windows and the first window holding a level decides, which is exactly the accuracy those modes exist to buy (§13.8).
  3. A stop fills at its level, or at the bar's open when the bar opened past it, whichever is worse for the position. A target fills at its level, never better. Why the asymmetry: a bar that opens past a level says the market did not trade there at that bar's start, so a stop could not have been filled at it; but protection is first tested on the bar AFTER a fill, and the market may have crossed a target inside the filling bar, so taking the better open for a target would flatter, and taking the worse open for a stop cannot. It is also why a stop moved to just under the entry of a losing position closes at the next open, at the loss the market really shows.
  4. Positions still open after the last bar close at that bar's close, less the half spread (§13.2), as ordinary closed trades with their own exit reason.

A live run has none of this: the order goes to the venue through the platform API, and the venue decides the fill, the spread, the commission and the financing. The rules above are what a backtest and thesis run's paper trading do, and §2.1 is why a live result is comparable to them but never identical.

13.4 Pending orders#

A pending order rests until the market reaches it. Four calls place one, and the order type is derived from the prices given rather than named:

trade.buy_limit(1, 1.0950)                  // rests below the market, fills when it falls
trade.sell_limit(price=1.1050)              // rests above it, quantity from bot(...)
trade.buy_stop(1, 1.1050, tp_dist=0.0040)   // rests above, fills when it rises
trade.sell_stop(1, 1.0950, limit=1.0940)    // a stop that becomes a limit when it triggers
trade.cancel(order.id)                      // cancel one
trade.cancel_all()                          // cancel every resting order of this instrument
Given Is Fills when
price on a _limit call a limit order the market reaches price from the far side
price on a _stop call a stop order the market reaches price from the near side
price and limit on a _stop call a stop-limit order the stop is reached, and then the limit

Why derived rather than named: these three are the whole of what FX, CFDs, crypto, futures and stocks have in common. A venue that models more, post-only, reduce-only, market-on-close, is reached through its own settings, and a venue that models fewer refuses the order as a value (§13), never as an exception. The language does not grow a type per venue.

price is required; omitting it is AS1042. The price must be beyond the market on the side the order waits on: a buy limit at or below the bar's close, a buy stop at or above it, and the mirror for a sell. An order placed on the wrong side would fill on the next bar at a price the venue would never have given, so it is rejected with invalid_order_price rather than silently becoming a market order. A stop-limit's limit must be past its stop in the direction of travel, and its protection must hold at its stop and at its limit alike, because it may fill at either.

Protection works as it does for a market order, except that sl_dist and tp_dist are measured from the order's own price, not from the calling bar's close, the order may fill days later, and the close of the bar that placed it means nothing by then.

trade.error Added by pending orders
"invalid_order_price" no price, a non-finite one, one on the wrong side of the market, or a stop-limit whose limit is not past its stop
"not_found" trade.cancel named an order that is not resting

A script reads the book through order.*: order.count is how many orders are resting, order.id is the oldest one's id, what trade.cancel takes, or na when nothing is resting, and order.last_id is the id of the order placed most recently by this script. One resting order of several is addressed exactly as a position is:

Call Type Meaning
order.id_at(index) int the id of the resting order at index, oldest first; na past the last
order.price_of(id) float the price it rests at, its stop price when it is a stop-limit
order.qty_of(id) float its size in instrument units
order.is_buy_of(id) bool whether it buys
order.label_of(id) string the label it was placed with, na when it has none
order.id_of(label) int the id of the oldest resting order carrying that label
order.count_of(label) int how many resting orders carry it
order.cancelled int how many orders have left the book without filling in this run
order.last_cancel string why the most recent one left: script, expired, insufficient_margin, invalid_protection or end_of_data. na while none has

The four placing calls take label= and comment= as well, and a position opened by a fill inherits both from the order that filled, along with trail=, breakeven= and the whole partial ladder of §13.6.1.

An order may be given a life, and may be moved rather than replaced. expire= takes a millisecond epoch in UTC, what bar_index's companion time answers and what time.timestamp builds, and the host withdraws the order at the first price event at or after that moment. trade.modify_order(id, price=, limit=, qty=, sl=, tp=, expire=) moves a resting order keeping its id, so a script that re-prices an order every bar does not have to track a new id each time. Every argument left out keeps what the order already had; the moved order is validated exactly as a new one is, so a price on the wrong side of the market is refused with invalid_order_price and the order stays where it was.

An order can also leave the book without the script asking. It expired, the host had no margin at the moment it triggered, or its protection no longer held at the price it would have filled at, and under the model of version 1 a script would see only a smaller order.count and no reason. order.cancelled counts those departures and order.last_cancel names the most recent one, so a bot can tell "my order filled" from "my order was taken away" without guessing.

if order.count == 0 and barstate.isfirst == false
    trade.buy_limit(1000, price=close - 0.0010, expire=time + 3600000, label="dip")

if order.count > 0 and close > open
    trade.modify_order(order.last_id, price=close - 0.0005)

if order.last_cancel == "expired"
    log.info("the dip never came")

13.5 Where a pending order fills#

Tested once per bar, before the script runs, like protection (§13.3), and after protection, so a position opened by a fill on bar i is first protected on bar i + 1, exactly as if a market order had opened it.

  1. A buy limit fills when low + half_spread <= price, a sell limit when high - half_spread >= price: a buy is filled at the ask and a sell at the bid, so the half spread (§13.2) is added to every price of the bar before it is compared.
  2. A buy stop fills when high + half_spread >= price, a sell stop when low - half_spread <= price.
  3. A stop-limit needs its stop reached on the bar, and then behaves as its limit allows (item 5).
  4. The fill is at the order's price, or the bar's open when the bar gapped past it, whichever is worse for a stop and better for a limit. A buy limit therefore fills at min(price, open) and a buy stop at max(price, open). Why: a gap says the market never traded at the resting price, and the open is the first price that existed.
  5. A stop-limit fills as its stop would, at the worse of the stop and the open, when that price is inside its limit. When the bar opened past the limit, it fills at the limit if the bar comes back to it, and not at all otherwise. Why: a triggered stop-limit is a limit order at limit, which fills at the first price that satisfies it; filling it at the limit itself while the market traded better put a fill outside the bar's own range. It never fills better than the plain stop inside it.
  6. An order whose protection would no longer be valid at the price it actually filled is cancelled, not opened. A gap moves the entry: a stop that sat below it when the order was placed can end up above it, and such a position then exits on its own stop for a profit booked as a loss. The protection rule a market order is held to therefore applies again, to the price the order really filled at.
  7. An order whose margin is no longer available when it fills is cancelled, not held.
  8. Orders still resting after the last bar are cancelled, and never become positions.

A bar that would fill several orders fills them all, oldest first. Why: a bar is not a sequence, so there is no honest way to say one of them happened first, and holding the rest back would be a silent reordering.

13.6 Managing a position that is already open#

Three things work on a position the script already holds. Each takes the position's id, which position.id answers for the oldest one, and each is a value rather than an exception, like every other trade.* call.

Call Produces What it does
trade.close_partial(id, qty) bool Closes qty instrument units of the position and leaves the rest open at the same entry price.
trade.trail(id, dist) bool Moves the stop to dist below this bar's close for a long and above it for a short, and only when that is tighter than the stop already set.
position.stop_loss, position.take_profit float The oldest open position's two levels, na when it has none.

A partial close books a closed trade. The part that closes appears in the report as an ordinary closed trade, carrying the position's own entry price and entry time with commission charged on the closed size; the remainder keeps the original id, entry price and protection, so position.count does not change. A qty at or above the whole position is a full close. A qty below symbol.min_qty, or one that would leave less than symbol.min_qty open, is refused with "invalid_volume" rather than silently rounded, §11.2 does not round a quantity and neither does this.

trade.trail is a ratchet the script drives, not an order the venue keeps. It is one trade.modify with the level computed for you: it never loosens a stop, and calling it on every bar is the intended use. It is bound by the protection rule of §13.2, which judges a level against the price the position would exit at now, so a long's stop tightens as the market rises and passes the entry into profit once the market has moved far enough. The entry-anchored ceiling version 1 had is gone; what is still refused is a level on the wrong side of the market, which would close the position the instant it was set.

A trade.trail call that would loosen the stop changes nothing and answers true, because there was nothing to refuse. Calling it on every bar is therefore safe and, once the position is in profit, productive: each call locks in more of it. A call naming a position that is not open answers false with trade.error as "not_found", and a context that cannot close part of a position, a live run, today, answers "closed".

13.6.1 Protection the host keeps between bars#

A script runs once per closed bar (§2), so everything in the table above is decided once per bar. Three arguments on the six opening calls hand a rule to the host instead, and the host applies it at every price event of §13.8, which is what makes a scale-out, a break-even stop and a trailing stop as precise as the data mode allows rather than as coarse as the bar.

Argument On What the host does, at every price event
trail= the six opening calls tightens this position's stop to trail from the price it would exit at, and never loosens it. Unlike trade.trail it needs no call per bar, and it belongs to the position rather than to the run
breakeven= the six opening calls once the position has moved breakeven in its favour, moves the stop to the entry price, once, and never back. A stop already past the entry is left alone
tp1=/tp1_qty= … tp4=/tp4_qty= the six opening calls up to four partial take-profits. tpN is a distance in price units from the reference price of §13.2, the calling bar's close for a market order, the order's own price for a pending one, and tpN_qty is the quantity, in instrument units, to close there. Each level books a closed trade whose exit reason is the target, exactly as trade.close_partial books one, and is then spent

The order within one price event is fixed, and pessimistic first: the stop and the final target of §13.3 are tested, then the partial ladder, then trail=, then breakeven=, then pending fills, then the margin stop-out. Why that order: a bar that reaches both a stop and a partial target must report the stop, for the same reason §13.3 gives; and a stop moved by trail= or breakeven= within a price event cannot be the stop that event is then tested against, because the move happened after the price did.

A level whose quantity is at or above what is left of the position closes the position outright. A level whose quantity would leave less than symbol.min_qty open closes it outright too, rather than leaving an unclosable remainder. A ladder entry with a quantity and no distance, or a distance and no quantity, is ignored, there is nothing to close or nowhere to close it.

A host that cannot keep a ladder must refuse the order, not place it without one. That is "not_supported" in the table of §13, and it is the one failure mode here that silently loses money: a scale-out that never happens turns a plan with four exits into a plan with one.

A host may also trail every position for the script. Given a trailing distance in its own settings, it tightens each position's stop by that distance at every price event of §13.8 and never loosens it, from the moment the position opens. That is a setting of the run, part of its key (§2.1), and invisible to the script, a bot written against trade.trail behaves the same whether or not it is on, except that the stop may already be tighter than the level it asks for, in which case its call changes nothing and answers true.

13.7 What the run has already closed#

trades.* is the run's own realized record, and like everything else it is a series: each value is what it was when this bar's script ran, so a bot reads its own history without keeping it.

Expression Type Meaning
trades.closed int positions closed so far; a partial close counts as one
trades.wins, trades.losses int how many closed above and below zero, a flat trade is neither
trades.net_profit float the sum of every closed position's profit, net of commission
trades.gross_profit, trades.gross_loss float the winning and losing halves of it, the loss as a positive number
trades.last_profit float the most recently closed position's profit, na before the first one
trades.last_exit_bar int the bar index it closed on, na before the first one
trades.max_drawdown float the largest fall from a peak of account.equity so far, in the account currency

Three rules make these reproducible. A position closed by its own protection closes before the script runs (§13.3), so the bar that closes it is the bar that reports it. trades.max_drawdown is sampled once per bar before the bar is processed, and again whenever the script reads it, so it is the worst drop over the bars the run has seen and never over the ticks inside them. And an indicator has no trading context at all: every count reads 0 and trades.last_profit reads na, which is what the conformance corpus freezes.

A bot whose host keeps no closed-trade history, a live run, today, reads every one of them as na, not as 0. na is both the honest answer and the safe one: a guard written trades.max_drawdown < limit is false against na, so a bot that gates on its own realized record stops opening positions rather than opening them with the guard silently disabled. An indicator's 0 is specified separately above because an indicator has no positions to have closed.

13.8 The price path inside one bar#

A bar is a summary, and a summary cannot say in which order its prices happened. The data mode says what a host walks inside each bar before the script runs, and it is part of the run key (§2.1):

Data mode What the host walks inside one bar
bars one window: the bar itself. The rules of §13.3 and §13.5 apply to its own high and low, and a stop and a target in the same bar resolve as the stop. This is the only mode version 1 had, and it is still the default
open_prices one window at the bar's open price alone. The fastest mode and the least faithful: nothing inside the bar can trigger
m1_bars every one-minute bar inside it, in time order, each tested as its own window
ticks_from_bars a deterministic path open → nearer extreme → farther extreme → close, interpolated to as many events as the host is asked for. A rising bar goes open, low, high, close; a falling bar goes open, high, low, close
ticks every real quote inside it, in time order. The most faithful mode, and the only one in which the spread can move quote by quote

Every event of a path is timestamped inside the bar it belongs to, so a position closed by a stop halfway through a bar carries that moment as its exit time rather than the bar's. One consequence is worth knowing: trades.last_exit_bar (§13.7) answers the index of a bar whose open time matches the exit, so under a minute or tick path it reads na for an exit that did not land on a bar boundary.

What the mode never changes is the script's own execution model. A script still runs once per closed bar, after the whole path has been walked, and still reads one bar per run: tick-level execution, a script body that runs per quote, is §16. What the mode changes is when protection, a pending order, a trailing stop and a margin stop-out fire, and therefore at what price. A run reports how many price events it walked, so a tick run that silently fell back to one window per bar is visible rather than assumed.

14. The determinism contract#

An implementation that breaks any of these is not a Thesis implementation. The conformance suite in the bytecode specification tests every line of this section.

  1. Arithmetic is IEEE-754 binary64, in the order the source and §7's precedence imply. No reassociation, no fused multiply-add, no extended precision: a sum written (a + b) + c is computed that way. An int is an INTEGRAL binary64 - exact to ±2^53, which is every bar index, window length and position count a script will ever hold - and integer operations truncate towards zero rather than wrapping. It is specified that way because a JavaScript implementation has no other number type, and a rule it could not keep would make it non-conformant by definition. A literal cannot enter the inexact range in the first place: §3 rejects one past 2^53 with AS0020.
  2. Every non-finite result is na, at the point it is produced, so na cannot be mistaken for a number later.
  3. No clock. time, the bar's calendar fields and everything derived from them come from the bar. There is no now and no time zone (§2).
  4. No randomness. math.random does not exist. The optimizer's own seed lives in the host, not in the script.
  5. No I/O and no network. The only inputs are bars, inputs (§11), symbol and account facts, and positions. The only outputs are plots, markers, log lines and trade.* calls.
  6. Culture-invariant text. str.tostring, str.tonumber, str.format and str.upper/lower behave as if the culture were invariant, everywhere, always. str.upper("i") is "I". The exact format of every one of them is the table below, because "invariant" names a culture and not a format.
  7. Defined evaluation order. Arguments are evaluated left to right, exactly once. and/or short-circuit; ?: evaluates its condition once and then one arm. Statements run in source order, and a variable's history records the value it holds when the bar ends (§6).
  8. Stable state identity. A call site's state is identified by its position in the compiled program, not by a name or a hash, so adding a comment cannot move it.
  9. Deterministic compilation. The same source bytes produce byte-identical bytecode, which is what makes the package hash a usable cache key and lets two implementations be diffed.
  10. na propagates as §4 states, comparisons against it are false, and §4.1 fixes what it means in every position where a value decides something.
  11. Eleven library functions are the one exemption from "bit for bit", and they are excluded from bit-exact conformance: math.exp, math.log, math.log10, math.pow, math.sin, math.cos, math.tan, math.asin, math.acos, math.atan and math.atan2. An implementation MUST produce a result within 1 ulp of the exact mathematical value, and SHOULD say which library it uses. Why: IEEE-754 requires a correctly rounded result only for + - * / and sqrt; every runtime takes these eleven from its own math library, and those already differ in the last bit between platforms, so bit-exactness here is a promise no implementation can keep today. Requiring correct rounding instead would mean shipping a correctly-rounded libm before running a script. math.sqrt is NOT in this list, because IEEE-754 requires it exact.
  12. One stated algorithm per stateful built-in. "The average of the last length values" fixes a value, not an order of operations, and a running sum and a re-summed window do not agree in the last bit. the bytecode specification is normative for every ta.* and for math.sum: a window is recomputed from its oldest value to its newest on every bar, never updated incrementally, and each recursive filter's recurrence and seeding are written out there.

14.6 Numbers as text, and text as text#

This table is what rule 6 means. Every row is the invariant culture's, and none of them is a host's to choose.

Call Exact rule
str.tostring(x) the shortest decimal text that reads back as the same binary64, with an exponent for very large and very small magnitudes
str.tostring(x, digits) fixed-point with exactly digits digits after the point, rounding half to even; digits above 15 is clamped to 15, and a NEGATIVE digits means "omitted" and gives the round-trip form above
str.tostring(na) absent text, which na(text) reports and + propagates
str.tonumber(text) the invariant parse of a decimal or exponential number, allowing a leading sign and surrounding whitespace, and na for anything else, including empty text, a group separator such as 1,000, and anything with a trailing unit
str.format(template, a, b, c, d) {0} to {3} replaced by str.tostring of each argument, an absent one rendering as the two letters na; any other brace sequence is left as it stands
math.round(x, digits) the value rounded to digits digits, half away from zero, digits clamped to 0..15
str.length, str.substring counted and indexed in UTF-16 code units (§3); start is clamped into the text, and a negative length means "to the end of the text" rather than an empty result
str.upper, str.lower the invariant simple case mapping, so str.upper("ß") is "ß" and never "SS"
str.contains, str.startswith, str.endswith, == on text ordinal, code unit by code unit: no collation and no case folding
str.tostring(0.1 + 0.2)     // "0.30000000000000004"
str.tostring(1e-7)          // "1E-07"
str.tostring(-0.0)          // "-0"      - negative zero keeps its sign
str.tostring(2.5, 0)        // "2"       - half to even
math.round(2.5)             // 3         - half away from zero
str.tonumber("1e3")         // 1000
str.tonumber(" 1 ")         // 1         - surrounding whitespace is allowed
str.tonumber("1,000")       // na        - a group separator is not

The two rounding rules differ, and that is stated rather than hidden. math.round rounds a midpoint away from zero; str.tostring(x, digits) rounds it to even, which is what IEEE-754 and every fixed-point formatter default to. A script that needs one rule for both rounds first and formats afterwards. Neither can change in version 1 without changing numbers that scripts have already printed and reports that already exist (§18), so both are frozen and the changelog records the difference.

Why these exact rules: a plot value, a log line, a package manifest and a CLI argument all pass through them, and a number that renders as 1,5 on one machine and 1.5 on another is a wire format with two meanings.

15. Limits#

Every limit is a compile-time or per-bar constant, so a script's memory is known before the first bar. Exceeding a compile-time limit is a diagnostic; exceeding a run-time limit is AS30xx naming the construct.

Limit Value Why
source size 1 MiB per file
exact whole number 9 007 199 254 740 992 (2^53) an int is an integral binary64 (§14.1); a literal past it is AS0020
instructions per program no fixed cap; bounded by the register limit functions are inlined (see the bytecode specification
files per package 64
import depth 8 AS2130, which is also what an import cycle exhausts (§10)
identifiers per scope 4 096
registers per program 65 536 the instruction's operand width
history depth per series 5 000 (max_history) the buffer a non-constant index reserves; a deeper index reads na
iterations per loop per bar 100 000 (max_iterations) AS3020
call depth 32 recursion is banned, so this bounds how deeply calls may NEST while being inlined (AS2130)
nesting depth 256 every operator, call, index, member access, parenthesis and indented block is one level, and a statement is the first; deeper is AS2130, because a compiler that walks the tree recursively would otherwise exhaust its stack and crash rather than answer. A host that still runs short of stack while compiling, calls inlined into one another multiply the depth, answers AS2130 too, never a crash
plots per indicator 32
inputs per script 64
string length 64 KiB AS3050 when a concatenation, str.repeat, str.replace or str.format would build a longer one

One row above is declared and not enforced. Nothing counts the identifiers in a scope, so exceeding 4 096 names is not a diagnostic in v1: that number states the bound a host may size for, and the check that would refuse it is still owed. Every other row is enforced where the table says.

What is NOT limited in version 1: the number of log lines a bar writes, the number of log lines a run writes, the number of trade.* calls one bar makes, and the number of orders resting at once. A log.info of a 64 KiB string on each of 100 000 bars really does produce 6.4 GB, a trade.buy() inside a loop really does open as many positions as the loop has iterations, bounded only by margin, and a trade.buy_limit() inside a loop really does rest as many orders as it has iterations, bounded by nothing at all, because nothing counts the book. A host MAY truncate its own log; the language does not, and §16 names the limits that would change that.

16. Not in version 1#

Each of these is absent on purpose, and using it is a diagnostic rather than a parse error: alert answers AS2150 and names what to use instead, trade.limit and trade.stop answer AS2100 naming the four calls of §13.4, every other absent name answers AS2050 with the closest spellings the catalog can offer, and an array literal used as a value is a type error. The list: arrays, matrices and maps · user-defined types and methods · enums · closures and function values · recursion · request.* (another symbol or timeframe) · tick-level execution · drawing objects · bgcolor and barcolor · alerts · str.split and regular expressions · math.random · for ... in · while without a bound · imports of anything that is not a Thesis file.

The roadmap. Each row names what will arrive, the rule it MUST arrive under, and when. A rule is written here before the feature exists so that it cannot be decided by whichever implementation ships first, lookahead in a multi-timeframe request is the example that matters, because a version that allows it produces backtests that cannot happen.

What Stage The rule it arrives under
a variadic maximum and minimum v1.1 a third argument to the two-argument maximum and minimum needs an omitted argument to be distinguishable from a real na, and v1's propagation makes that impossible: the maximum of an absent value and 5 is absent by §4, so a third argument defaulted to absent would poison every two-argument call. It arrives with the arity recorded per call site instead. The rest of that helper set, rounding a price to the tick size, an average, a clamp, text trimming and a colour gradient, is in version 1.
a named const tier v1.1 the tier below simple that already exists in practice, a literal, a plot name, an hline level, gets the name const, which §3 reserves, so every "must be written out here" rule has one spelling.
an hline level from an input v1.1 the manifest stores which parameter the level came from instead of a number, and the app redraws the level when the parameter changes. An overbought level of 70 is a parameter in most scripts that have one.
a shaded band between two plots v1.1 plot returns a compile-time handle and a fill call records the pair in the manifest; the engine computes nothing extra.
output volume limits v1.1 a bound on log lines per bar and per run, and on trade.* calls per bar, each raising an AS30xx rather than letting a runaway script fill a disk (§15).
alerts v1.1 an alert call at the top level is an output event recorded per bar, exactly like a marker. Delivery is the host's job, so the language stays deterministic.
a conversion rate that varies over the window v1.1 the account-currency conversion of §13.2 is ONE constant rate per run. A rate that moves bar by bar is a second instrument's closes, which needs request.*, so it arrives with that and under the same no-lookahead rule: the rate of bar i is the one that was known at bar i.
a script body per tick v2 calc_on=tick on bot(...), written out - never a run-time flag - because the warm-up, every ta.* window and every var history are indexed by BAR, and a run that could switch would need two series spaces. A per-tick body reads the quote it was called for and the bar fields of the LAST CLOSED bar, never of the bar it is inside: barstate.isfirst/islast keep their bar meaning, bar_index does not advance between ticks, and a ta.* call still advances once per bar, so an indicator written for bars keeps its values. The simulation already walks ticks (§13.8) and already keeps a per-position trailing stop, break-even and partial ladder for the script (§13.6.1), which is what most per-tick bodies were for.
bounded arrays v2 the capacity is a compile-time constant, so memory is known before the first bar, and pushing onto a full array drops its oldest element. R5 survives only with a bound.
request.* v2 a higher-timeframe value becomes visible on the first lower-timeframe bar after that higher bar closes, with no option to change it. Three rules arrive with it and are fixed now, because each is a decision a later version could not take back: the instrument and the timeframe are written out at the call site, so the set of series a run reads is known before the first bar and is part of the run key (§2.1); a request for a timeframe SHORTER than the run's own is refused at compile time, since compressing upwards is defined and interpolating downwards is not; and a request whose window the host cannot serve answers na for every bar of it rather than refusing the run, so one absent instrument does not take a portfolio down. Trading stays single-instrument until a request.* run exists to trade against.

Drawing namespaces are the one open question this table does not settle: line.* and label.* would want the words line and label, which §12 already uses as bare style and shape words. Whichever arrives first, the other spelling changes, and that is a version boundary (§18).

18. Versions and compatibility#

#thesis 1 is optional in v1 and will become required in v2. A pragma naming a higher version than the implementation supports is AS0040, with the engine version in the message.

Once a version ships, the rules of this document are frozen for it: a script that compiles and runs today produces the same numbers against a later engine, or the engine is wrong. A new construct raises the version; it never changes what an existing program means. The reserved keywords of §3, the reserved calc_on of §2 and the reserved trade.limit/trade.stop of §13 exist so that the common additions need no new version at all, and §16 states the rule each of them will arrive under.

A correction to this document is not a version change when it describes what the engine already did, several of the rules in §2, §13 and §14.6 were written down for the first time after the engine had been answering them one way for a year. Where a correction changes a number a script computes, it ships with the conformance value it changes, in the same commit, and says so in the changelog.

19. Glossary#

Term What it means here
bar one closed candle: open, high, low, close, volume and an open time in UTC
series a value with one entry per bar. Every value in Thesis is one (§4)
simple a value fixed for the whole run, a literal, an input, or arithmetic over those (§4)
const the tier below simple: written out in the source. Unnamed in v1, reserved as a keyword (§3, §16)
call site one textual occurrence of a call. It owns its own state, its own var cells and its own history buffers (§9)
slot one series buffer, written exactly once per bar (§6)
window the last length values a stateful built-in holds. It is not ready until it is full and free of absent values (§4.1)
warm-up the prefix of the history a bot runs with trading disabled (§2)
run key everything a run's outputs depend on, beyond the program and the bars (§2.1)
host the program that owns the bar loop, the trading context and the outputs: a backtest, a paper or live run, an indicator calculation
package one .thesispkg file: a manifest, a compiled program and, by default, the sources. Its identity is the SHA-256 of its payload (the bytecode specification)
manifest the part of a package a host reads without an interpreter: kind, title, inputs with ranges, outputs, levels, warm-up and limits
instrument units the quantity a script speaks in, coins, shares, contracts, or the units of a currency pair (§13)
lot a venue's quoting unit. symbol.lot_size converts; the language never mentions lots (§11.2)
hedging one position per opening call, with no netting off (§13.1)

21. Three complete programs#

Fragments appear throughout this document; these three are whole, and a test compiles each one from this file, so they cannot rot. Each block names its own file.

An indicator. Where the price sits inside its own range, with a band and a breakout mark.

#thesis 1
// Channel.thesis
indicator("Channel position", label="CP", precision=1)

length = input.int(20, "Channel length", min=2, max=200, step=1)
source = input.source(close, "Source")

upper = ta.highest(source, length)
lower = ta.lowest(source, length)
span = upper - lower
position_in_range = span == 0 ? 50.0 : (source - lower) * 100 / span

plot(position_in_range, "Position", color=color.blue, width=2)
hline(80, "High", color=color.red, style=dashed)
hline(20, "Low", color=color.green, style=dashed)
marker(ta.crossover(position_in_range, 80), "Breakout", shape=triangle_up, position=above, color=color.red)

A bot. Two averages, one position at a time, protection as a distance, and a refused order that the log records rather than hides.

#thesis 1
// Crossover.thesis
bot("Average crossover", qty_type=units, qty_value=1, warmup=200)

fast_length = input.int(20, "Fast length", min=2, max=100, step=1)
slow_length = input.int(50, "Slow length", min=5, max=400, step=5)
stop_distance = input.float(0.0040, "Stop distance", min=0.0005, max=0.0200, step=0.0005)

fast = ta.sma(close, fast_length)
slow = ta.sma(close, slow_length)
turns_up = ta.crossover(fast, slow)
turns_down = ta.crossunder(fast, slow)

if turns_up and not position.is_long
    trade.close_all()
    accepted = trade.buy(1, sl_dist=stop_distance, tp_dist=stop_distance * 2)

    if not accepted
        log.warn("buy refused: " + trade.error)

if turns_down and position.is_long
    trade.close_all()

A module and the main file that imports it. The module computes; the main file decides.

// Range.thesis
module("Range")

export average_true_range(simple int length) =>
    bar_range = math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1])))
    ta.rma(bar_range, length)
#thesis 1
// Bands.thesis
indicator("Volatility bands", on_price=true)
import "Range.thesis" as volatility

length = input.int(14, "Length", min=2, max=100, step=1)
multiplier = input.float(2.0, "Multiplier", min=0.5, max=5.0, step=0.5)

basis = ta.sma(close, length)
width = volatility.average_true_range(length) * multiplier

plot(basis, "Basis", color=color.gray)
plot(basis + width, "Upper", color=color.red)
plot(basis - width, "Lower", color=color.green)