ftw.trade
MarketsTradersAutomatePortfolio

Flowscript language reference

Flowscript is a small language for trading strategies and indicators, designed for AIs to write. A program reads time series (prices, volumes, event tapes), computes values over them, and fires actions when its conditions turn true. It is deterministic: the same inputs always produce the same values and the same actions, which is why a backtest over history and a live run over new bars make the same decisions.

What makes it suit a model writing strategies is that the language is small and bounded. A program has no loops and no mutation, so it always terminates. It reaches only what its host declares. It is checked before it runs, and every error is located, carries a stable code, and suggests a fix. Because each run replays exactly, a model can backtest a change and trust the comparison.

This reference describes the language as flowscript-core and @flowscript/core implement it, without ftw. Every program in a flowscript block below is compiled by the library's tests (src/docs_reference.rs) against the host in The examples' host and replayed over a synthetic history, so the examples cannot drift from the compiler. The semantics are specified in FLOWSCRIPT_V2_SPEC.md at the repository root; this is the user's view of them.

  • A first program
  • The host
  • Values and types
  • Expressions
  • Functions, lambdas and fold
  • Metrics
  • Triggers
  • Actions and handlers
  • Enums and match
  • Phase machines
  • Tapes
  • The prelude
  • Built-in functions
  • Diagnostics
  • What runs where
  • Coming from Pine Script

Try a study

A chart study is the body of a program: price, volume, open, high, low and time are the chart’s bars, and each metric(…) is a line. It is checked as you type, in this tab.

// A chart study: price, volume, open, high, low and time are the chart's bars.
const len = 20;
metric("fast", ema(price, 9));
metric("slow", sma(price, len));
 
Tab indents. Press Escape, then Tab, to move to the next field. Suggestions appear as you type a name: arrow keys choose, Enter or Tab accepts, Escape closes them.
Checked here once the study engine has loaded (a build without it does not check).

A first program#

uses { bars: "^1", paper: "^1" }

const close = bars.close("BTC-USD", "1h");
const fast = ema(close, 5);
const slow = ema(close, 20);

metric("fast", fast);
metric("slow", slow);

when (crossover(fast, slow)) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }
when (crossunder(fast, slow)) { paper.sell({ symbol: "BTC-USD" }); }
  • uses { … } names the modules the program calls, with a version range. A module is either a source namespace (bars) or a capability (paper).
  • const binds a name to a value. Every binding is a series: close is the close of every hourly bar, and ema(close, 5) is a new series computed from it, one value per bar.
  • metric(name, value) publishes a value: a replay returns it for every bar, and a live run returns it at each step.
  • when (condition) { … } fires its actions when the condition turns true. crossover(fast, slow) is true at the bar fast crosses above slow.

Comments are // … to the end of the line, or /* … */.

The host#

The language has no built-in data and no built-in actions. The application compiling a program declares what it may read and do. That declaration is the host.

  • Sources are the reads. Each has a namespace.name, a type (number, bool, text, a tape of events with typed fields, or a record of typed fields read one bar at a time, such as a position's size and pnl), whether it takes a literal key as its first argument (a symbol, an address), and which bar intervals it is published at. bars.close("BTC-USD", "1h") is a keyed number source read at 1h. A key can instead be a typed identity: a source declared with identity: 'pool' ('token', 'market', 'venue', 'wallet') takes pool("base:0x…") (wallet("main"), keyed by the wallet's name) and refuses a bare string or another kind of identity (market.identity), so a pool's bars cannot be read with a token's address. Optional args follow the key and the interval, each typed (duration, number, quantity, text): a TWAP's window, twap(pool("…"), "5m"). Reading a source the host does not declare is the stream.unknown error, with the nearest name it does declare; an interval it does not publish is refused too.
  • Capabilities are the actions, declared as YAML manifests: the module's name and version, and each action with its typed arguments. A call that does not match a manifest (an unknown action, an argument it does not take, or one of the wrong kind, such as a number where it takes Text) is refused at compile time. An action's required: lists the arguments it cannot run without (pair|token for either of two), after its defaults:, and its positive: the amounts that must be above 0 where the source writes them: paper.buy({ symbol: "BTC-USD" }) is action.required and quote: 0 is action.positive, each at the call. A manifest's streams are the module's reads: the first argument's type is the key's identity (Market, Token, Pool, Wallet, or Venue for either a pool or a market), the rest are optional arguments (Duration, Bps, Number, Text), and out is Series<number>, Series<bool>, Series<text> or a record, "Series<{ size: number }>".
  • Markets are the names market("…") may take, when the application has any.
  • Units are the numeric reads' scales, Host::with_stream_units(json), in the format of ftw's platform/stream-units.json. A row's identity (pool, token, market, venue, wallet) is what its read is keyed by, and makes the row a read the host declares: a number with the row's bar intervals, a tape when the row lists its events ({ "px": "number" }), otherwise a number taking the row's args ([{ "name": "window", "type": "duration" }]). A record row is not a call, and a read the host already declares keeps its declaration. A row naming any other identity is refused when the host is built. A bar's forms_from names the live mark its forming bar is decided on (when (…, intrabar: true)), and unformed: true marks a bar price the forming bar does not deliver, which an intrabar condition may not read. A tape's row may name a candle read, "candles": { "read": "dex.candles", "price": "price", "volume": "usd" }: bars of any period built from the tape (Host::with_candles declares one directly).

In TypeScript the host is a plain object (see npm/README.md):

const host = {
  sources: [{ name: 'bars.close', type: 'number', keyed: true, intervals: ['1h'] }],
  capabilities: [paperManifestYaml],
};

In Rust it is flowscript_core::Host, built with Host::empty(), .with_source(Source::number("bars.close").keyed().intervals(["1h"])) and .with_capability(yaml).

The examples' host#

Every example in this reference compiles against this host:

SourceTypeKeyedIntervals
bars.open, bars.high, bars.low, bars.close, bars.volumenumberyes1h, 1d
news.sentimenttextno
trades.printstape: price number, size number, side textyes

Two capabilities: paper with buy({ symbol: Text, quote: Number }) and sell({ symbol: Text }), and alerts with send(message: Text).

Values and types#

TypeWrittenNotes
number1, 0.5, 1_000_00064-bit float
booltrue, false
text"BTC-USD"+ concatenates text: "filled @ " + fill.price
nana"not available": the one option type
record{ sum: 0, n: 0 }field access is typed: r.sum; an unknown field is an error
seriesany of the above, over timewhat a source returns, and anything computed from one
durationseconds(30), minutes(5), hours(4)
basis pointsbps(25)bps(25) is 0.0025

na is how the language says a value is not known yet: an ema(close, 20) is na for its first 19 bars rather than a made-up number. It propagates through arithmetic and comparisons, and a condition that is na is not true. A condition is true, false or na: close > open is na where either side is, ! of na is na, and && and || answer na only when the known side cannot decide (false && na is false, true || na is true, true && na is na). c ? a : b and if over an na condition are na, and a when does not fire on one. A chart and a running strategy read conditions the same way. isNa(x) tests for it and nz(x, 0) replaces it.

Scalars lift over series. close * 2, close > open and abs(change(close)) apply bar by bar.

Expressions#

  • Arithmetic + - * / % on numbers, comparison < <= > >= == !=, logic && || ! on conditions. A condition is not a number: (a > b) * 2 is an error, and a > b ? 1 : 0 is the number.
  • Comparisons do not chain. 1 < x < 3 is an error; write 1 < x && x < 3.
  • A comparison with na is na at every bar, so x == na is an error; test with isNa(x).
  • Conditional: cond ? a : b, or if (cond) a else b as an expression. Both arms must have the same type.
  • History: close[1] is the previous bar's close, close[k] the value k bars ago (na before there are k bars).
  • Blocks: { let x = …; return …; } where a function or lambda body is expected. let names cannot shadow.
uses { bars: "^1" }
const close = bars.close("BTC-USD", "1h");
const open = bars.open("BTC-USD", "1h");
const body = abs(close - open);
const bias = if (close > open) "up" else (close < open ? "down" : "flat");
metric("body", body);
metric("bias", bias);
metric("gap", (open - close[1]) / close[1]);

A const is bound once: a second const of the same name is an error, not a replacement.

There are no loops, no recursion and no mutation. That is deliberate: every program terminates at every bar with bounded memory, which is what makes it replayable.

Lexical structure and grammar#

Text. A program is UTF-8. Outside strings and comments it is ASCII: a non-ASCII character there is an error naming it, except Unicode spaces, which separate tokens as ASCII whitespace does. A leading byte-order mark is ignored. Comments are // … to the end of the line and /* … */, which does not nest.

Tokens.

TokenForm
namean ASCII letter or _, then letters, digits and _
numberdecimal digits with an optional fraction and exponent: 42, 0.5, 1_000, 1.5e3, 2E-1. No sign (that is unary -), no leading ., no hex. A number too large for a 64-bit float, or so small it would read as 0, is an error, as is a number run into a name (2px)
string"…", with the escapes \", \\, \n and \t; any other \ is an error
true, falsethe two conditions
operators+ - * / % < <= > >= == != && || ! ? : => = . , ; ( ) [ ] { } @

Precedence, loosest first. Binary operators on one level associate left, except comparisons, which do not associate at all.

LevelOperators
1c ? a : b, if (c) a else b and match (x) { … } (right-associative: a ? b : c ? d : e)
2||
3&&
4< <= > >= == !=, one per expression
5+ -
6* / %
7unary - and !
8history x[k], field r.f, call f(…), parentheses

So -x[1] is -(x[1]), a || b && c is a || (b && c), and x - 1 - 2 is (x - 1) - 2.

Limits. An expression nests at most 128 levels as written: each parenthesis, operand, call and record is one, and so is each operator in a chain (a + b + c is two). Once its consts are substituted and its library calls inlined, one binding may lower to 160 levels and 1 MB. A library call's large argument that its body reads more than once is bound once rather than copied to every read, so stacked indicators (sma(stoch(r, r, r, 14), 3) over an rsi, hma(hma(hma(close, 9), 9), 9)) grow with their nesting, not exponentially. A const is still copied where it is read. The largest program in the repository uses about a sixth of the depth and a fortieth of the size. A program past either limit is refused with syntax.depth or expr.size instead of exhausting the memory of whatever compiles it.

What a program keeps is bounded where it is written: one expression keeps at most 1,000,000 samples of state (a window's length, a history depth [n], a warm-up), and a window over a tape spans at most "7d", since it keeps every event in its span. Both are refused with expr.size. A text value holds at most 4,096 bytes; joining past that is na, so a fold that grows its text cannot grow without end. A NaN or infinity a source feeds is na, and so is an answer with no finite reading, such as log10(0) or asin(2).

Functions, lambdas and fold#

fn defines a function. Parameters and the result are typed; a Series<…> parameter takes a series, count a whole number. A call whose argument, or a body whose result, is a different kind (a number, a condition, text) from what the annotation names is refused. A parameter with no annotation takes anything.

uses { bars: "^1" }
fn spread(a: Series<number>, b: Series<number>): Series<number> {
  return (a - b) / b;
}
const close = bars.close("BTC-USD", "1h");
metric("stretch", spread(close, sma(close, 50)));

fold(series, seed, (acc, x) => next) is the one stateful loop: it steps once per bar, from seed, with the last result and the new sample. The seed may be a record, and the accumulator is typed by it. Every indicator in the prelude is written with it.

uses { bars: "^1" }
const close = bars.close("BTC-USD", "1h");
// Bars since the close last made a new high. A bar with no close leaves
// the state as it was: without the isNa test one missing sample would make
// the state na for the rest of the run.
const since = fold(close, { high: 0, n: 0 },
  (st, x) => isNa(x) ? st
    : (x > st.high ? { high: x, n: 0 } : { high: st.high, n: st.n + 1 }));
metric("bars_since_high", since.n);

A lambda is (a, b) => expr or (a, b) => { let …; return …; }. It captures the names around it by value.

@warmup(n) after a const's expression, or before a fn, states a lookback the compiler cannot derive. It can only ask for more history, never less.

Libraries#

import "name";, after uses, gives a program the functions of a library its host offers (D1214). A library is fn definitions and contract interfaces only: no uses, no import, no metrics or triggers. The compiler itself offers the std/ libraries, such as std/erc20 (see Contract calls). Its functions are checked like the program's own, where the program calls them, and a problem inside one is named as an imported function's.

// The library "swing", as the host offers it.
fn mid(a: Series<number>, b: Series<number>): Series<number> {
  return (a + b) / 2;
}
uses { bars: "^1" }
import "swing";
const high = bars.high("BTC-USD", "1h");
const low = bars.low("BTC-USD", "1h");
metric("mid", mid(high, low));

A host names its libraries when it compiles (Options.libraries in Rust, Host.libraries in @flowscript/core), so the same source compiles to the same program wherever it is given the same libraries. A program imports up to 15. An import the host does not offer (import.unknown), one listed twice, a library function that a second library or the prelude also defines, and a program fn with a library function's name are refused. On build.ftw.trade a library is the account's, saved with save_library, and a strategy keeps the version it was saved with until it is saved again.

Metrics#

metric(name, value) publishes one value per bar, and each metric has its own name. A numeric or boolean metric comes back as numbers (true is 1). A metric that is na (still warming up) reads NaN, and a text metric comes back as words ("" while na).

Triggers#

A trigger is a condition and the actions to fire when it holds.

  • when (cond) { … } fires on the rising edge: the bar the condition turns from false to true, once per crossing.
  • while (cond) { … } fires at every bar the condition holds and that brought something new the condition reads.
uses { bars: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
const volume = bars.volume("BTC-USD", "1h");
when (close > rolling_high(close, 24)[1]) { alerts.send("new 24h high"); }
while (volume > 3 * sma(volume, 48)) { alerts.send("volume still heavy"); }

Combinators, all usable inside any condition:

crossover(a, b), crossunder(a, b)a crosses above, below b
rising(cond), falling(cond)cond turned true, false this bar
changed(x)x differs from its last value
once(cond)true the first bar cond holds, never again
sustained(cond, n)cond has held for n bars
cooldown(d)false for d after the trigger last fired; only in a when or while condition (FS0541)
hysteresis(x, on, off)true once x passes on, until it falls back past off
every("1d"), every("1w", at: "Mon 14:00")each boundary of chain time
uses { bars: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
when (sustained(close > sma(close, 20), 3) && cooldown(hours(6))) {
  alerts.send("three closes above the 20-bar average");
}
when (every("1w", at: "Mon 14:00") && close < sma(close, 200)) {
  alerts.send("weekly buy window");
}

every counts chain time from the Unix epoch, from the time a run is given with each bar (or the bar number when there is none). It fires on the first bar at or after each boundary, once per boundary.

Actions and handlers#

An action is a call to a capability: paper.buy({ symbol: "BTC-USD", quote: 100 }). Each action expression is an action site, named by where it is in the program (when1#1:paper.buy). A decision carries its site, and an outcome reported for it names the site back.

Handlers attach to an action and run when it ends: .onFill, .onRevert, .onAbort and .onStuck. A handler reads the outcome as fill.price, fill.amountOut and outcome.reason, and may fire actions of its own, guarded by if if it likes.

uses { bars: "^1", paper: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
when (crossover(close, ema(close, 20))) {
  paper.buy({ symbol: "BTC-USD", quote: 100 })
    .onFill {
      if (fill.price > close * 1.01) { paper.sell({ symbol: "BTC-USD" }); }
      alerts.send("bought at " + fill.price);
    }
    .onRevert { alerts.send("buy failed: " + outcome.reason); };
}

A bridge transfer takes time to land, so it has one more handler: .onSent runs when the funds leave, reading what is expected to land as fill.amountOut, and .onFill runs when they arrive, reading what did. Between the two the transfer is inFlight(). bridge.inTransit(route) reads how much is on its way along a route, and bridge.held(route) what the route's destination account holds, so a strategy's funds are always somewhere it can read. This example needs a host with a bridge module, such as ftw's, so it isn't one of the examples run against this reference's host.

uses { bridge: "^1" }
const out = market("relay:usdg-robinhood-to-usdc-hyperliquid");
when (bridge.feeBps(out) < 20 && !inFlight()) {
  bridge.transfer({ market: out, amount: 500 })
    .onSent { notify("on its way: " + fill.amountOut); }
    .onFill { notify("arrived: " + fill.amountOut); };
}

An action that signs names its wallet with from: wallet("main"), or takes the strategy's default_wallet. from: pick(group("Group A")) sends it from one wallet of a group, chosen each time it fires; pick takes groups and wallets together (pick(group("A"), wallet("desk"))). pick(group("A"), 3) sends it from three of them at once, each its own order and nonce, as a competing fill needs. The host knows each group's wallets, and chooses by a hash of the action site and the bar, so a replay of the same bars picks the same wallets. A group the host does not know, or one smaller than the count, refuses the action when it fires. The choices are literal, so the wallets a strategy may sign with are read off the program (D1141). This example needs a host with wallet groups, such as ftw's.

uses { orders: "^1" }
const P = pool("robinhood:0x…");
when (crossover(dex.close(P, "5m"), ema(dex.close(P, "5m"), 20))) {
  orders.marketBuy({ pair: P, quote: usdc(25), from: pick(group("snipers"), 3) });
}

An if in a when or while body guards the actions inside it. It is read at the bar the when fires, so when (a) { if (b) { … } } acts the bar a turns true, if b holds then, and not later when b turns true while a still holds (that is when (a && b)). The other actions in the body fire either way.

uses { bars: "^1", paper: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
const volume = bars.volume("BTC-USD", "1h");
when (crossover(close, ema(close, 20))) {
  alerts.send("crossed up");
  if (volume > sma(volume, 24)) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }
}

Who decides the outcome is the application's choice. With a paper broker, a replay or a run fills each order itself. An application with its own venue reports each outcome to the run (run.report in TypeScript, Run::report in Rust), and the handlers fire at the last bar stepped.

Contract calls#

uses { v: abi("Name", "0x…", ["sig", …], { Struct: "(…)" }) } imports exactly the listed functions of the contract at that address (D550b, D1249, D1250). A program can call any function of any contract this way. A call is an action: v.fn({ param: value, … }), with each parameter passed by its name in the signature.

uses {
  bars: "^1",
  v4: abi("Router", "0x…", [
    "swap(PoolKey key, bool zeroForOne, int256 amount, bytes hookData)",
    "swap(PoolKey key, bool zeroForOne, int256 amount) as swapSimple",
  ], { PoolKey: "(address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks)" })
}
const key = { currency0: "0x…", currency1: "0x…", fee: 3000, tickSpacing: 60, hooks: "0x…" };
when (bars.close("ETH-USD", "1h") > 4000) {
  v4.swapSimple({ key: key, zeroForOne: true, amount: 5000, retries: 2, backoff: "3 blocks" });
}
SolidityFlowscript value
uintN, intNa whole number; sent as exact decimal text, range-checked by the encoder
address, bytesN, bytes, stringtext ("0x…" for addresses and bytes)
boola condition
a tuple (T a, U b) or a named structa record with exactly those fields
T[], T[n]a list […], of exactly n items for T[n]
  • Structs. A named struct is declared once in the fourth argument and used by name in any signature, nested up to eight deep. A tuple can also be written inline: "f((address to, uint256 v)[] legs)".
  • Overloads. Two functions with one name are told apart by renaming one with as. The name in the program changes and the selector stays the contract's own.
  • Call options. from, retries, backoff and value sit beside the arguments, so a parameter cannot use those names.
  • Views. A signature ending view returns (T) is read like a stream, with literal arguments, or a const holding one: when (vault.pending("0x…") > 1000).
  • Checks. A missing or extra field, a list of the wrong length, and a literal of the wrong type are refused when the program compiles. A value that does not fit its type, or that is na or fractional, refuses the call before anything is signed.
  • Interfaces. interface ERC20 = abi(["sig", …], { structs }); lists a contract's functions without an address, and uses { usdc: ERC20("0x…") } binds it to one. Two contracts with the same functions are then two names, usdc.balanceOf(…) and weth.balanceOf(…), with no clash. A program declares its own interfaces or imports them from a library.
  • Standard libraries. import "std/erc20"; gives the ERC20 interface (transfer, approve, transferFrom, and the balanceOf, allowance, totalSupply and decimals views) and tokenUnits(amount, decimals). import "std/erc4626"; gives ERC4626: deposit, mint, withdraw and redeem, and views for its assets, balances, conversions and previews. Their sources and tests are in flowscript/std/. The standard's from parameter is named owner, because from: is a call option.
  • Events. The same parser reads event signatures for sources that decode logs: abiEvent("Swap(bytes32 indexed id, int128 amount0, …)") in @flowscript/core, and abiFunction(sig, structs) for functions.

Enums and match#

enum declares a closed set of words. An unknown variant is a compile error with the nearest one, and match must cover every variant or end in _.

uses { bars: "^1" }
enum Regime { hot, quiet, normal }
const close = bars.close("BTC-USD", "1h");
const vol = stddev(change(ln(close)), 24);
const regime = vol > 0.02 ? Regime.hot : (vol < 0.005 ? Regime.quiet : Regime.normal);
metric("regime", regime);
metric("size", match (regime) { hot => 0, quiet => 1, _ => 0.5 });

Phase machines#

phases declares a state machine. Each phase holds the triggers that apply in it, and a when inside one names the phase it moves to. A phase's let bindings start over each time the phase is entered, so cummax(close) inside Long is the high since this entry, the entry bar's close included. A phase's entry { … } block fires when the machine enters it, right after the transition's own actions.

uses { bars: "^1", paper: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
phases Trade {
  phase Flat {
    when (crossover(close, ema(close, 20))) => Long {
      paper.buy({ symbol: "BTC-USD", quote: 100 });
    }
  }
  phase Long {
    entry { alerts.send("long"); }
    let stop = cummax(close) * 0.95;
    when (close < stop) => Flat { paper.sell({ symbol: "BTC-USD" }); }
  }
}
metric("phase", phase("Trade"));

A target that does not exist, a when in a phase with no target, and a phase nothing can reach are compile errors. Every trigger decides against the phases the bar began in, so a machine moves at most once a bar. An if inside a transition's body fires with the move, when its guard holds then; the machine moves either way. An if inside an entry block is a compile error (phases.guard): put the guard on the transition that enters the phase, or in a when inside it.

Tapes#

A tape is a source of events (trades, prints, messages) that arrive whenever they happen, not once a bar. map picks a field off each event, resample or window buckets the events, and a reducer (sum, mean, count, first, last, distinct) turns each bucket back into one number.

uses { trades: "^1" }
const prints = trades.prints("BTC-USD");
const buys = map(prints, (p) => p.side == "buy" ? p.size : 0);
metric("buy_volume_1h", sum(resample(buys, "1h")));
metric("prints_15m", count(window(prints, "15m")));

Using a tape where a bar series is expected is the cadence.events error.

A host may declare a tape field as an enum, { "case": { "enum": ["call", "exit", "note"] } }: the tag of a sum type. Inside a map it reads as an enum, so match (e.case) { call => e.usd, exit => -e.usd, note => 0 } must cover every word or end in _, and e.case == "cal" is refused with the nearest word. An event whose tag is not one of the words is refused when it is fed to the program.

The prelude#

The prelude is Flowscript source (prelude/standard.flow), and its doc comments are the definitions. In brief:

Function
sma(src, len), ema(src, len), ewma(src, len), wma(src, len), rma(src, len)moving averages (ema is SMA-seeded, as charts draw it; ewma seeds on the first close)
stddev(src, len), zscore(src, len)dispersion over a window
rsi(src, len)Wilder's RSI
atr(src, len), trueRange(h, l, c), wilderAtr(h, l, c, len)true range and average true range
vwap(price, vol, len)volume-weighted average price over a window
corr(a, b, len), cov(a, b, len)correlation and covariance over a window
roc(src, len), momentum(src, len)rate of change, and change over len bars
cummax(src), cummin(src)high and low since the start (or since a phase was entered)
highest(src, len), lowest(src, len), rolling_sum(src, len)the high, low and sum of the last len bars (Pine's ta.highest, ta.lowest, math.sum)
cum(src)the running total since the start
barssince(cond), valuewhen(cond, src)bars since cond last held, and src at that bar (na until it first has)
linreg(src, len)the least-squares line's value at this bar over the last len bars
stoch(c, h, l, len)where c sits in the last len bars' range, 0 to 100
pivothigh(src, left, right), pivotlow(src, left, right)the value right bars back when it is the extreme of the left + right + 1 bars around it, else na; known right bars late, as Pine's are
macd(src, fast, slow), macdSignal(src, fast, slow, sig), macdHist(…)the MACD line, its signal and the histogram between them
bbUpper(src, len, mult), bbLower(src, len, mult), variance(src, len)Bollinger bands around sma(src, len), and the window's variance
vwma(src, vol, len), swma(src)volume-weighted and four-bar symmetric averages
wpr(h, l, c, len), cmo(src, len), mfi(src, vol, len), obv(src, vol), tsi(src, short, long)Williams %R, Chande momentum, money flow, on-balance volume, true strength
diPlus(h, l, c, len), diMinus(h, l, c, len), adx(h, l, c, len, adxLen)Wilder's directional movement
supertrend(h, l, c, factor, len), supertrendDir(…), sar(h, l, c, start, inc, max)Supertrend's line and direction (−1 up, 1 down), and the parabolic SAR
hour(t), minute(t), dayofweek(t), dayofmonth(t), month(t), year(t)a unix-seconds time's calendar in UTC (dayofweek is 1 for Sunday to 7 for Saturday, as Pine's); another zone is a shifted time, hour(t + 3600*9)
barIndex(src)the bar's index from 0
hma(src, len), dema(src, len), tema(src, len)the Hull, double and triple exponential averages
cci(src, len), cog(src, len), range(src, len)the commodity channel index, the centre of gravity, and the window's high less its low
kcUpper(src, h, l, c, len, mult), kcLower(…), kcw(…), bbw(src, len, mult)Keltner channels around ema(src, len), and the Keltner and Bollinger widths as a ratio of the basis
cross(a, b)a crossed b either way this bar
pvt(src, vol), accdist(h, l, c, vol), wad(h, l, c), nvi(src, vol), pvi(src, vol), iii(h, l, c, vol), wvad(o, h, l, c, vol)price-volume trend, accumulation/distribution, Williams' A/D, the negative and positive volume indexes, intraday intensity, and Williams' variable A/D

Built-in functions#

Mathabs, min, max, clamp(x, lo, hi), ln, log10, exp, sqrt, pow, bps
Roundingfloor, ceil, round (halves away from zero, as Pine's), sign (1, −1 or 0)
Trigonometrysin, cos, tan, asin, acos, atan, in radians
nana, isNa(x), nz(x, default)
Serieschange(x), rolling_high(x, n), rolling_low(x, n), median(x, n), percentile(x, n, q)
Higher timeframesample(cond, inputs, x => body): body as if the chart had only the bars cond holds on, its answer held between them; inputs (a series or a record of them) is read on every bar and is all body reads. newPeriod(t, secs) and periodEnds(t, secs) pick a period's first and last bars, and periodOpen, periodHigh, periodLow and periodVolume(…, t, secs) gather its bar
Window statisticsdev(x, n) (mean absolute deviation), percentrank(x, n) (the percent of the n bars before this one at or below it), highestbars(x, n) and lowestbars(x, n) (how many bars back the extreme is, as 0 or less), alma(x, n, offset, sigma) (the Arnaud Legoux average); n, offset and sigma are written in the source
Timeseconds, minutes, hours, every, daily, during
Triggerscrossover, crossunder, rising, falling, changed, once, sustained, cooldown, hysteresis
Tapesmap, resample, window, sum, mean, count, first, last, distinct
Exact integersu256(x), u256Add, u256Sub, u256Mul, u256Div, u256Mod, u256Min, u256Max, u256Cmp(a, b) (−1, 0 or 1), u256Units(amount, decimals), u256Number(x, decimals); see below

Exact integers#

A number is a 64-bit float, which is exact only up to 2^53. That is about 9 million tokens of an 18-decimal asset in base units, and a sqrtPriceX96 is far past it. The u256 functions carry an unsigned 256-bit integer as a word of decimal digits, so it reaches a uint256 call argument exactly as it was computed (D1253).

uses { bars: "^1", weth: ERC20("0xc02a…") }
import "std/erc20";
const amount = u256Units(bars.close("ETH-USD", "1h") / 1000, 18);   // exact base units
when (u256Cmp(amount, "500000000000000000") > 0) {
  weth.transfer({ to: "0x…", amount: u256Div(amount, 2) });
}
metric("eth", u256Number(amount, 18));   // back to a number, to chart it
  • Arguments are words of decimal digits or 0x hex, or whole numbers of 0 or more. u256Units(1.1, 18) scales the amount as written, so it is exactly 1100000000000000000, not the float nearest 1.1 times 10^18.
  • Arithmetic is checked. A result past 2^256 − 1, below 0, or divided by 0 is na, never a wrapped value, and a call with an na argument is refused before it is signed. So is u256Units of an amount with more decimal places than decimals allows.
  • u256Cmp and u256Number answer numbers. u256Number(x, 18) rounds once x passes 2^53, so use it for charts and thresholds, never for an amount you send.

The compiler publishes its own list: flowscript_core::vocabulary() returns the built-ins, the prelude source and the diagnostic registry, which is what an editor or a code-writing model should read rather than a copy.

Diagnostics#

Every compile error has a stable code (FS0412), a slug (record.field), a message, and a position. A code never changes meaning and is never reused, so tooling matches on it rather than on the message. The registry is flowscript_core::diagnostic_codes().

Refused by the compiler
uses { bars: "^1" }
const close = bars.clsoe("BTC-USD", "1h");

That is stream.unknown: "did you mean bars.close?".

Warnings#

Some programs compile and then do something other than what they seem to say. flowscript_core::warnings_in(source, &host) (program.warnings in @flowscript/core) lists them, each located and coded like an error, and the program still compiles: the author may mean it.

CodeSlugWhat it finds
FS0901lint.nevera condition that is never true: x > highest(x, n) or x < lowest(x, n) (this bar is in the window), or one written of literals that is false
FS0902lint.repeata while that buys or opens a position with no position guard, so it sends the order at every bar its condition holds
FS0903lint.unuseda const nothing reads (name it _x to keep it)
FS0904lint.naa divide or remainder by a literal 0, which is na at every bar
uses { bars: "^1", paper: "^1" }
const close = bars.close("BTC-USD", "1h");
when (close > highest(close, 20)) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }

That is lint.never, at close > highest(close, 20): compare with highest(close, 20)[1].

A replay says the rest: its quiet lists each when or while with actions that fired at no bar, with how its condition decided (na at every bar, never true, or true and held back by a cooldown, guard or phase). A strategy that never trades says why instead of returning no orders.

Every diagnostic code, from this deployment’s compiler
CodeSlug
FS0001syntax
FS0002string
FS0003number
FS0004if.else
FS0005syntax.depth
FS0006syntax.chain
FS0007string.escape
FS0008expr.size
FS0101type
FS0102arity
FS0103name.unknown
FS0104name.shadow
FS0105window.literal
FS0106defaults.duplicate
FS0107name.reserved
FS0108function.unevaluated
FS0109record.duplicate
FS0201market.identity
FS0202identity.key
FS0203identity.arity
FS0204asset.unsupported
FS0301param.name
FS0302param.bounds
FS0303config.name
FS0304config.conflict
FS0305stream.options
FS0306stream.reference
FS0307stream.from
FS0308observe.window
FS0309observe.reset_on
FS0310observe.duplicate
FS0311stream.unknown
FS0312stream.key
FS0313config.latest
FS0314cadence.events
FS0315invariant.name
FS0316invariant.breach
FS0317invariant.duplicate
FS0318invariant.limit
FS0401fold.lambda
FS0402fold.state
FS0403function.unknown
FS0404function.recursion
FS0405lookback
FS0406lookback.undeclared
FS0407percentile.quantile
FS0411field.type
FS0412field.unknown
FS0413sample.lambda
FS0414sample.reads
FS0415import.unknown
FS0416import.body
FS0501capability.undeclared
FS0502grant.manifest
FS0503handler.action
FS0504handler.unknown
FS0505handler.duplicate
FS0506execution.unknown
FS0507execution.bound
FS0508execution.duplicate
FS0509execution.platform_policy
FS0510in_flight.site
FS0511wallet.label
FS0512wallet.default
FS0521teardown.safe_asset
FS0522teardown.action
FS0523teardown.target
FS0524teardown.duplicate
FS0525abi.address
FS0526abi.functions
FS0527abi.signature
FS0528abi.function
FS0529abi.arg
FS0530abi.retries
FS0531abi.backoff
FS0532abi.value
FS0533wait.nothing
FS0534triggered.name
FS0535order.size
FS0536action.argument
FS0537integration.unsupported
FS0538wallet.pick
FS0539action.required
FS0540action.positive
FS0541cooldown.site
FS0542abi.interface
FS0601metric.name
FS0602pipeline.wallet
FS0603task.run
FS0604task.wallet
FS0605evaluation.query
FS0606task.undeclared
FS0607task.unused
FS0608task.duplicate
FS0609task.incomplete
FS0610task.field
FS0611task.context
FS0612task.result
FS0613task.name
FS0614task.runtime
FS0615task.entry
FS0616task.egress
FS0617pool.every
FS0701every.period
FS0702every.at
FS0703every.option
FS0711phases.body
FS0712phases.duplicate
FS0713phases.empty
FS0714phases.order
FS0715phases.target
FS0716phases.transition
FS0717phases.unreachable
FS0718phase.read
FS0719phase.unknown
FS0721time.window
FS0722while.phase
FS0723while.unclocked
FS0724trigger.option
FS0725intrabar.unbarred
FS0726intrabar.level
FS0727enum.declaration
FS0728enum.variant
FS0729enum.mismatch
FS0730match.subject
FS0731match.exhaustive
FS0732phases.guard
FS0901lint.never
FS0902lint.repeat
FS0903lint.unused
FS0904lint.na

Common pitfalls#

Each of these compiles, and each does something other than what it seems to say. They matter most in a strategy that trades.

  • while repeats (FS0902). while (rsi(close, 14) < 30) { paper.buy(…) } buys at every bar of a long dip. Entries belong in a when, which fires once per crossing; keep while for an action meant to repeat, with a guard that turns false once it has done its job.
  • when fires again on every new crossing. An entry that can turn true twice before its exit adds to the position each time. Gate it on !inPosition() where the host keeps a book, or cap entries in the broker (pyramiding).
  • A window includes its own bar (FS0901). close > highest(close, 20) is never true, since this bar's close is in the window. Compare with the window as of the bar before: highest(close, 20)[1].
  • na is not false. !(close > avg) is na, not true, while avg warms up, so a when on it waits. Write the condition you mean (close < avg), and use nz or isNa where a missing value should count as something.
  • A fold that does not test for na stays na. fold carries its state from bar to bar, so one missing sample makes the state na for the rest of the run unless the step leaves the state as it was (isNa(x) ? st : …). The prelude's indicators all do this.
  • Windows are partial at first. sma, stddev, highest and the other windows average or scan what they have until the window fills, while ema, rsi and atr are na until they warm up. Gate on barIndex(close) >= n where the first bars must not trade.
  • Time needs the bar's time. every, cooldown, daily and during count in the time each bar carries (time in a replay's history, the time of step). Without one a bar's time is its number, so every("1d") fires once and cooldown(hours(1)) waits 3600 bars.
  • An order is sized by a literal. A paper broker sizes from a number (or a constant or param bound to one) and refuses a program with an order sized by anything computed before its first bar. Size by equity with the host's percent order where it has one, and sell a share of a position with its percent-of-position sell (a { position: "pct" } size).
uses { bars: "^1", paper: "^1" }
const close = bars.close("BTC-USD", "1h");
const top = highest(close, 20)[1];
const n = fold(close, 0, (st, x) => isNa(x) ? st : (x > top ? st + 1 : st));
metric("breakouts", n);
when (barIndex(close) >= 20 && close > top && !inPosition()) {
  paper.buy({ symbol: "BTC-USD", quote: 100 });
}
when (close < sma(close, 20)) { paper.sell({ symbol: "BTC-USD" }); }

Testing a program#

A test feeds a program sources you write and says what it should do with them. The same runner is in flowscript-core (testing::run), @flowscript/core (flowscript.test(source, suite, host)) and ftw's CLI (flowscript test program.flow program.test.yaml). A check is compiled into the program as one more metric and replayed by the evaluator a backtest uses, so a passing test says what the real run does.

A suite is JSON or YAML: an optional program (the source every case runs, when none is given beside it), an optional paper broker, and tests. Here is one for the strategy in A first program:

paper:
  cash: 1000
  fee_bps: 10
  orders:
    paper.buy: { side: buy, price: bars.close, size: { quote: quote } }
    paper.sell: { side: sell, price: bars.close, size: all }
tests:
  - name: buys the rally, sells the fall
    bars: 60
    ohlc: { steps: [[0, 100], [20, 120], [40, 90]] }
    paper: true
    expect:
      - { check: "fast > slow", at: 21 }
      - { fires: paper.buy, at: 20, args: { quote: 100 } }
      - { fills: paper.sell, at: 40 }
      - { check: "close > 0", at: always }
      - { warns: none }
  - name: a flat market never trades
    sources: { bars.close: { constant: 100 } }
    expect:
      - { fires: paper.buy, count: 0 }
      - { metric: fast, at: last, equals: 100 }

A case has a name, the sources it feeds and what it expects. Bars are counted from 0. bars is how many (the longest written-out source, or 100). time is a bar's length in seconds or a list of closes, for every and cooldown. paper is a broker's terms, or true for the suite's (or the host's). program gives a case its own source: a library's tests are cases whose programs import it.

runs: 50 runs a case 50 times, each walk with the next seed, so an expectation is held over many random markets rather than one, as a property test is (D1257). It passes only when every run does, and a failure names its run: run 17 of 50: \exposure <= 0.2` is false at bar 31`.

  - name: never more than a fifth of equity in, over any market
    bars: 200
    runs: 50
    ohlc: { walk: { from: 100, step: 2 } }
    paper: true
    expect:
      - { check: "exposure <= 0.2", at: always }

A source is named as the program reads it (bars.close, or bars.close|BTC-USD for one key of it) and is:

WrittenFeeds
[100, 101, null, 103]numbers; null is na; a short list holds its last value
[true, false], ["hot", "cold"]a bool or text series
[{ a: 1, b: "x" }, …]a record, one per bar
[[3, { price: 10, size: 2 }], …]a tape: each event at its bar
{ constant: 100 }the same number at every bar
{ ramp: [100, 120] }a straight line from the first bar to the last
{ sine: { mid: 100, amp: 5, period: 20, phase: 0 } }a wave
{ steps: [[0, 100], [20, 120]] }each value from its bar on
{ walk: { from: 100, step: 1, seed: 7 } }a random walk; a seed walks one path everywhere

Any generator takes na: [bar, …] to blank those bars. ohlc: <a close series> feeds bars.close and derives bars.open (the close before), bars.high, bars.low and bars.volume (1000) from it; ohlc: { source: pool, close: … } does the same for another source's bars. A read the host adds for an action and the program never names (an order's live price, its pool) is fed na when a case leaves it out, and the report lists it under unfed. A read the program names is the case's to give.

An expectation is one of:

WrittenHolds when
{ check: "<condition>", at: 12 }the condition is true at bar 12
{ check: …, at: ever | always | never | last }at some bar; at every bar it is decided, and at least one; at none; at the last (ever without at). from and to bound the bars
{ metric: fast, at: 12, equals: 101.5, within: 0.01 }a metric's value (within 1e-9 relative unless within); at: last
{ metric: fast, at: 3, na: true }a metric is na there
{ fires: paper.buy, at: 20, count: 1, args: { quote: 100 } }an action (paper.buy, buy, or its site) fired: at a bar, that many times (count: 0 is never), with those arguments. A contract call's arguments are compared as they were at the bar it fired, { amount: 250000000 } for tokenUnits(250, 6); a record or list is compared field by field
{ fills: paper.buy, at: 20, count: 1 }, { reverts: … }the paper broker filled, or reverted, that action's orders
{ refuses: FS0103, line: 3 }the program is refused with that code or slug, on that line
{ warns: FS0901 }, { warns: none }the compiler warns with that code or slug; or not at all

A check reads the program's consts and sources, not its metric names. Every case but a refuses one must compile.

The report gives each case's failures, each saying what happened instead (`paper.buy` fired 0 times, not 1, `fast > slow` is false at bar 21), or why the case could not run (expect: null). The CLI exits 1 when one fails.

A fires … at that fails also says what the action's when read at that bar: each comparison in its condition and the values it compared (D1255).

`paper.buy` did not fire at bar 3; it fired at 7. `fast > slow && close < 20` at bar 3:
`fast > slow` = true, `fast` = 18.5, `slow` = 17.2, `close < 20` = false, `close` = 25

What runs where#

flowscript-core and @flowscript/core compile any program in this reference, and replay or run all of it except the parts each says it does not model:

  • Replay (replay, flowscript.replay) evaluates a whole history at once. With a paper broker it fills the orders and runs their handlers. Give it each bar's close as time (replay_timed in Rust) for every and cooldown to count in chain time.
  • Live run (live::Run, flowscript.run) steps one bar at a time and makes the replay's decisions on the same bars. Outcomes come from a paper broker or from the application (report).
  • Not modelled here yet: maintain blocks and invariants are listed in the result's unmodelled with the reason, never silently dropped.

Some of the language is for a full execution host such as ftw's and needs data this library does not supply on its own: position context (inPosition(), avgEntry(), unrealizedPnl() …) reads the strategy's own execution stream, param(…) and config(…) read a configuration stream, and pool(…), token(…) and market(…) name an application's venues.

Coming from Pine Script#

Most Pine indicators port line for line, and an importer does the porting (D1194): pineToFlowscript in @flywheel/chart/pine translates the common v5/v6 subset of an indicator to a chart study and of a strategy to a build.ftw.trade strategy, and names, by Pine line, what it could not take (var, :=, loops, request.security of another symbol). The charts site's Flowscript studio and build.ftw.trade's Strategies page take a pasted script, and an agent calls get_pine_translation over MCP. The differences are deliberate:

  • Data is read by name, so a program says which market and interval it reads.
  • Every run is replayable, so there are no loops and no mutation.
  • Orders are a host's capabilities, not a built-in simulator.
uses { bars: "^1", alerts: "^1" }
const close = bars.close("BTC-USD", "1h");
const high = bars.high("BTC-USD", "1h");
const low = bars.low("BTC-USD", "1h");
const k = sma(stoch(close, high, low, 14), 3);
const d = sma(k, 3);
metric("k", k);
metric("d", d);
metric("bars_since_oversold", barssince(k < 20));
when (crossover(k, d) && k < 20) { alerts.send("stochastic turned up from oversold"); }
PineFlowscript
close, high, close[1]bars.close("BTC-USD", "1h"), read once into a const; history is the same x[1]
ta.sma, ta.ema, ta.wma, ta.rma, ta.rsi, ta.stdev, ta.vwap, ta.roc, ta.mom, ta.correlationsma, ema, wma, rma, rsi, stddev, vwap(price, vol, len), roc, momentum, corr
ta.atr(14), ta.trwilderAtr(high, low, close, 14), trueRange(high, low, close)
ta.highest, ta.lowest, math.sum, ta.cumhighest, lowest, rolling_sum, cum
ta.stoch, ta.linreg(src, len, 0)stoch, linreg(src, len)
ta.barssince, ta.valuewhen(c, src, 0)barssince, valuewhen(c, src)
ta.pivothigh(src, l, r), ta.pivotlowpivothigh(src, l, r), pivotlow
[m, s, h] = ta.macd(src, 12, 26, 9)macd(src, 12, 26), macdSignal(src, 12, 26, 9), macdHist(src, 12, 26, 9): one function per line
[mid, up, lo] = ta.bb(src, 20, 2), ta.variancesma(src, 20), bbUpper(src, 20, 2), bbLower(src, 20, 2), variance
[p, m, adx] = ta.dmi(14, 14)diPlus(high, low, close, 14), diMinus(…), adx(high, low, close, 14, 14)
[st, dir] = ta.supertrend(3, 10), ta.sar(0.02, 0.02, 0.2)supertrend(high, low, close, 3, 10), supertrendDir(…), sar(high, low, close, 0.02, 0.02, 0.2)
ta.vwma, ta.swma, ta.wpr, ta.cmo, ta.mfi, ta.obv, ta.tsithe same names; a read Pine makes itself (the bars, the volume) is an argument
ta.hma, ta.alma, ta.cci, ta.dev, ta.percentrank, ta.highestbars, ta.lowestbars, ta.cog, ta.range, ta.crossthe same names
[mid, up, lo] = ta.kc(src, 20, 2), ta.kcw, ta.bbwema(src, 20), kcUpper(src, high, low, close, 20, 2), kcLower(…), kcw(…), bbw(src, 20, 2)
ta.pvt, ta.accdist, ta.wad, ta.nvi, ta.pvi, ta.iii, ta.wvadthe same names, with the bars and the volume as arguments
ta.crossover, ta.crossunder, ta.changecrossover, crossunder, change
math.abs, round, floor, ceil, sign, log, log10, pow, sqrt, sin …the same names without math.; log is ln
na(x), nz(x, y)isNa(x), nz(x, y)
var float x = 0 then x := x + 1fold(series, seed, (acc, x) => next)
import user/lib/1 as lib, export f(x) => …a library of fn definitions and import "lib";, its functions called by name
plot(x, "name")metric("name", x); in a chart study, // @plot name histogram #26a69a styles it
plot(x, color = x > 0 ? green : red)// @plot x up=green down=red, or by=other to colour by another metric's sign
plotshape, barcolor, bgcolor, display = display.nonethe @plot styles up and down, bars, bg and hidden
plotshape(c, location = location.belowbar, text = "B")// @plot c up below text="B" (above for over the bar), with metric("c", cond ? 1 : 0)
if c … label.new(bar_index, high, "H " + str.tostring(x))// @plot l down above text="H {{x}}" with metric("l", c ? 1 : 0) and x published as a metric (D1213)
line.new from pivot to pivot (a zigzag)// @plot p line connect, with metric("p", …) set only at the pivots and na between
box.new, table.newa box is a // @fill between two lines; a table's values read in the legend
fill(p1, p2, color), hline(70)// @fill upper lower #2962ff, // @hline 30 70
input.int(14)a top-level const len = 14; in a chart study, which its settings edit; param(14, { min: 2, max: 50 }) in a strategy
input.int(14, "Length", minval = 2, group = "MA", tooltip = "…")// @input len "Length" min=2 group="MA" tooltip="…" over the const
input.bool(true, "Show"), input.string("EMA", options = ["SMA", "EMA"])// @input show bool "Show" over const show = 1;; // @input kind options="SMA,EMA" over const kind = 1; (the picked option's index)
hour, minute, dayofweek, dayofmonth, month, year, bar_indexhour(time) … year(time) in a chart study (UTC), barIndex(price)
alertcondition(c), alert(msg)when (c) { alerts.send(msg); }; ftw's host names it notify(msg)
alertcondition(c, message = "RSI {{plot("rsi")}} at {{close}}")in a chart study, // @alert c "RSI {{rsi}} at {{close}}": {{value}}, the bar's {{open}} … {{volume}}, {{time}} and any line by name
strategy.entry, strategy.closethe host's order actions in a when body, such as ftw's orders.marketBuy and orders.sell
strategy.entry(limit=, stop=), strategy.exit(stop=, limit=) at a pricea broker rule with rest (D1212), such as build.ftw.trade's paper.buyLimit({ quote, at }) and paper.sellStop({ at }): it rests at at, a number or an expression the program also publishes with metric(), and fills at the first later close through it
request.security(syminfo.tickerid, "D", ta.sma(close, 20))sample(periodEnds(time, 86400), close, c => sma(c, 20)): the closed day's value, known on the bar that closes it and held, so it never repaints
request.security(syminfo.tickerid, "D", high - low)sample(periodEnds(time, 86400), {h: periodHigh(high, time, 86400), l: periodLow(low, time, 86400)}, b => b.h - b.l)
request.security(sym, "1D", close) of another marketa read at that market and interval, bars.close("BTC-USD", "1d"), where the host publishes it
for, arrays and mapsnot in the language: a window function, fold, or percentile and median over a window
ftw.trade · Robinhood Chain markets from our own index
Top tradersMulti-chartBuild a strategyStatusTermsPrivacy
Loading prices…
Connecting…
Top tradersMulti-chartBuild a strategyStatusTermsPrivacy
MarketsTradersAutomatePortfolio