Language reference
The compiler-owned reference for syntax, types, hosts, actions, functions, diagnostics and runtime semantics.
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 compiler errors are located, carry stable codes, and suggest fixes. Because each run replays exactly, a model can backtest a change and trust the comparison.
Compilation does not promise that external data or execution will always succeed. A run refuses invalid inputs and damaged checkpoints, and stops when it exhausts a resource budget. Hosts must surface that refusal and retain the last successful checkpoint; they must not treat it as a successful bar or replace it with fabricated values. Missing observations and indicator warmup use the language's explicit na value.
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
- Language versions
- 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
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).constbinds a name to a value. Every binding is a series:closeis the close of every hourly bar, andema(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 barfastcrosses aboveslow.
Comments are // … to the end of the line, or /* … */.
Language versions#
Flowscript is versioned as MAJOR.MINOR, and is at 0.3 before its beta. A program says which version it is written in on its first line, before uses (comments may come above it):
flowscript "0.3";
uses { bars: "^1", paper: "^1" }
const close = bars.close("BTC-USD", "1h");
when (crossover(ema(close, 5), ema(close, 20))) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }The version is a requirement string, written as a module's is in uses: "0.1" and "^0.1" mean the same, Cargo's caret. Before 1.0 each minor version may change what a program means, so a 0.x requirement names its minor and compiles only as exactly that version; "0" alone is refused. From 1.0 on, "^1.2" compiles as the newest 1.x the compiler speaks that is at least 1.2.
- A version the compiler does not speak is refused where it is written (
language.version), with the versions it does speak, never compiled under another version's rules. So is a declaration anywhere but the first line, or given twice. - A program that names none compiles as the compiler's newest version. Applications that keep programs record the version each was saved as and compile it as that version from then on (
Options::language, orlanguagein an@flowscript/corehost); the program's own line wins over the record. - New 0.3 artifacts always pin the version they compiled as (
language_version), including programs using the default. Historical artifacts with no marker retain their historical unsigned policy. A runtime refuses an artifact of a version it does not speak.Program.languagein@flowscript/coregives the version a program compiled as. - A trusted host restoring a known historical unversioned artifact may request
ArtifactIdentity::LegacyUnversioned(artifactIdentity: 'legacy-unversioned'in a JavaScript host) with explicitly selected language0.1. Without an own source header, this reproduces the omitted marker and original digest while reporting effective language0.1. An own source header wins with its normal pinned identity. Checkpoint hash checks remain strict; new programs leave this restoration option unset. - A library may name its version on its first line too, and is held to it.
| Version | What changed |
|---|---|
| 0.1 | Versions began (D1543). |
| 0.2 | Arrays (D1544). Their functions (buffer, filter, groupBy, …) are built-in names, so a program's own fn filter is refused. A 0.1 program keeps its own function of such a name, and has arrays besides. |
| 0.3 | Exact integer math: checked signed long and uint256 arithmetic, full-precision multiply-divide, exact units, and safe numeric input rules. Earlier saved versions keep their own names and unsigned-input policy. |
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'ssizeandpnl), 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 at1h. A key can instead be a typed identity: a source declared withidentity: 'pool'('token','market','venue','wallet') takespool("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. Optionalargsfollow 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 thestream.unknownerror, 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'srequired:lists the arguments it cannot run without (pair|tokenfor either of two), after itsdefaults:, and itspositive:the amounts that must be above 0 where the source writes them:paper.buy({ symbol: "BTC-USD" })isaction.requiredandquote: 0isaction.positive, each at the call. Itsevaluated:lists the arguments the host reads as values when the action fires (each decision'svalues), not as the source that computes them: an application's own action, such as a source'semit, gets what they were at that bar. A manifest'sstreamsare the module's reads: the first argument's type is the key's identity (Market,Token,Pool,Wallet, orVenuefor either a pool or a market), the rest are optional arguments (Duration,Bps,Number,Text), andoutisSeries<number>,Series<bool>,Series<text>or a record,"Series<{ size: number }>". - Catalog rows resolve
series("tenant:me/name@1")in Rust hosts throughHost::with_catalog(json)orextend_catalog(json). Each reference is unique and declarestype: "number","bool"or"string"; an optionalkeyedis"pool","token"or"market". String enumvariantsare distinct, nonempty names, using the same word rules as tape/record enums. References contain no whitespace/control characters and use a positive decimal major withinu32. Tenant names occupy one path segment; platform names may use nested nonempty segments, such asplatform/metrics/volume-5m@1. Invalid rows are refused when the host is built, even if no program reads them. A misspelled type never becomes a numeric series. - 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'splatform/stream-units.json. A row'sidentity(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 barintervals, a tape when the row lists itsevents({ "px": "number" }), otherwise a number taking the row'sargs([{ "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'sforms_fromnames the live mark its forming bar is decided on (when (…, intrabar: true)), andunformed: truemarks 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_candlesdeclares 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:
| Source | Type | Keyed | Intervals |
|---|---|---|---|
bars.open, bars.high, bars.low, bars.close, bars.volume, bars.time (each bar's start, unix seconds) | number | yes | 1h, 1d |
news.sentiment | text | no | |
trades.prints | tape: price number, size number, side text | yes |
Two capabilities: paper with buy({ symbol: Text, quote: Number }) and sell({ symbol: Text }), and alerts with send(message: Text).
Values and types#
| Type | Written | Notes |
|---|---|---|
| number | 1, 0.5, 1_000_000 | 64-bit float |
| bool | true, false | |
| text | "BTC-USD" | + concatenates text: "filled @ " + fill.price; a number joins with two decimals, more below 1 ("p" + 1 is p1.00) |
na | na | "not available": the one option type |
| record | { sum: 0, n: 0 } | field access is typed: r.sum; an unknown field is an error |
| list | [a, b, c], [[a, b], [c, d]] | a fixed list of numbers or series, a vector; a list of rows is a matrix. xs[0] is an item; see Lists, statistics and linear algebra |
| array | buffer(e, 100), filter(xs, x => x.usd > 1000) | items a run gathers, as many as its data makes (at most 10,000): numbers, text, conditions, records or arrays; see Arrays |
| series | any of the above, over time | what a source returns, and anything computed from one |
| duration | seconds(30), minutes(5), hours(4) | |
| basis points | bps(25) | 25 basis points, as an action's argument declared in them takes it (max_slippage: bps(25)); in arithmetic it is the number 25, so a rate is written 25 / 10000 |
Quantity constructors such as usdc(25), eth(1), bps(25) and pct(5) take exactly one argument. Extra arguments are compile errors.
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. A condition or a text names what replaces it, nz(cond, false), since 0 is neither. A condition is not a number: functions that take numbers (arithmetic, crossover, the windows such as median) refuse one, and cond ? 1 : 0 is its number. A duration such as "1h" goes only where a clock takes one (cooldown, resample); hours(1) is that many seconds as a number (D1290).
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) * 2is an error, anda > b ? 1 : 0is the number. - Comparisons do not chain.
1 < x < 3is an error; write1 < x && x < 3. - A comparison with
naisnaat every bar, sox == nais an error; test withisNa(x). - Conditional:
cond ? a : b, orif (cond) a else bas an expression. Both arms must have the same type. - History:
close[1]is the previous bar's close,close[k]the valuekbars ago (nabefore there arekbars). - Blocks:
{ let x = …; return …; }where a function or lambda body is expected. Aletor a lambda's parameter cannot shadow a binding or aconst, and afncannot take a built-in function's name.
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.
| Token | Form |
|---|---|
| name | an ASCII letter or _, then letters, digits and _ |
| number | decimal 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, false | the two conditions |
| operators | + - * / % < <= > >= == != && || ! ? : => = . , ; ( ) [ ] { } @ |
Precedence, loosest first. Binary operators on one level associate left, except comparisons, which do not associate at all.
| Level | Operators |
|---|---|
| 1 | c ? 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 | * / % |
| 7 | unary - and ! |
| 8 | history 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 large const of any type is bound once in the binding that reads it, and a small one is 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.
A program's source is at most 1 MB, and a metric's name at most 128 characters. The whole program lowers to at most 4 MB, summed over every const, metric, condition and argument, keeps at most 4,000,000 samples of state, and has at most 1,000 metrics, 2,000 triggers (each handler and each if in a body counts as one) and 2,000 actions. Past any of these the program is refused with program.size, as soon as the compiler passes it; the refusal names the const, metric or trigger that passed the size and the three that lowered to the most before it. A const that lowers to 2,000 bytes or more, whatever its type, is bound once in each binding that reads it, however many times that binding reads it, so a step that reads a scored list of pools through two or three consts holds the list once; a smaller const is copied at every read, as it always was. A const that reads another is still held once per binding that reads either, so a program over many pools keeps the bindings that read its scored list few.
A run is bounded too, in evaluation steps (a node walked, or an element a window scans), which count the same on every replay of the same bars: a bar may take 50,000,000 and a replay 4,000,000,000, and a replay returns at most 1,000,000 decisions and orders. Past one the replay stops with an error that names the bar.
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: number, bool, text or count, or a Series<…> of the first three. Annotations check the element kind: a scalar broadcasts as a constant series, so ewma(3, 5) is valid. count names a whole number from 1. 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, and so is a name that is not one of these (Series<Number>). A parameter with no annotation takes anything. A function has one definition, and its parameters have distinct names.
An annotation names exactly one type. After :, a type is required: x:, bare Series, Series<>, number bool and number + text are errors, rather than an untyped parameter or an alternative type. Series contains one of number, bool or text; count is scalar. The existing spellings boolean and string are aliases for bool and text.
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 holds fn definitions, contract interfaces and imports of other libraries: no uses, metrics or triggers. Its caller owns the sources, actions and contract instances it may use. The compiler itself offers the std/ libraries, such as std/erc20 (see Contract calls) and std/vwap (below). 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));import "std/vwap"; is the VWAP trader's library, built on the prelude's periodVwap, anchoredVwap and their σ: weekStarts(t) and monthStarts(t) (anchors: the first bar of each UTC week from Monday 00:00, and of each month), vwapZ(price, vw, dev) (where price sits in σ), vwapDrift(vw, dev, anchor) (how many σ a bar the VWAP has walked since its anchor: near 0 on a balanced day), ridingAbove(close, vw, dev, len) and ridingBelow(…) (the share of the last len closes beyond the 1σ band: a trend, not a fade), and failedPushUp(high, open, close, upper, within) and failedPushDown(low, open, close, lower, within) (a push into a band that turned back within within bars). Its source and tests are flowscript/std/vwap.flow and vwap.test.yaml.
uses { bars: "^1" }
import "std/vwap";
const close = bars.close("BTC-USD", "1h");
const high = bars.high("BTC-USD", "1h");
const vol = bars.volume("BTC-USD", "1h");
// Anchored at each new 20-bar high: the average price of everyone who bought since.
const top = high >= highest(high, 20);
const vw = anchoredVwap(close, vol, top);
const dev = anchoredVwapDev(close, vol, top);
metric("z", vwapZ(close, vw, dev));
metric("riding", ridingAbove(close, vw, dev, 12));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 library can compose another helper with import "features/base"; before its functions; a strategy then imports only the entry library. Dependencies resolve by those exact host names, including std/ helpers. A host-supplied standard-library source takes precedence over the compiler's installed copy in compilation and editor tools. The compiler performs no filesystem, package-registry or network discovery.
The complete dependency closure may contain at most 15 unique libraries, each subject to the program source-size limit. A shared dependency reached through several libraries loads once; explicitly importing that same dependency beside an entry library also loads it once. Listing an import twice in the same file still refuses. Cycles, unavailable dependencies, malformed libraries and incompatible library language headers refuse before evaluation, at the strategy's importing line with the dependency path in the diagnostic. Library functions share the caller's function namespace: use distinct names when composing packages. Conflicts between two libraries, the prelude or the program's own functions refuse.
Hosts can obtain the exact reached source map, including installed standard libraries, with resolve_libraries(source, options) in Rust or flowscript.resolveLibraries(source, host) in @flowscript/core. This validates source syntax and dependency admission without requiring the strategy's data host or typechecking its function bodies; compile and test it separately. Persist the returned sources together with the program, then supply that frozen map to compilation, testing and editors. Changes to a dependency's used code change the compiled artifact identity and cannot reuse another version's live checkpoint. On ftw.trade a library is the account's, saved with save_library, and a strategy keeps its complete saved dependency closure 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). A constant is a metric too, a number or a word the same at every bar.
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; a count written in the source is a whole number from 1 |
cooldown(d) | false for d after the trigger last fired; d is a duration of 0 or more (hours(6), "6h", a config), and 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.
every, crossover, crossunder, rising, falling, changed, once and triggered are impulses: true for exactly the bar they mean. A when whose condition contains one fires at every bar the condition is true, on consecutive bars too, so when (every("1d")) over daily bars fires every day and when (changed(regime)) at every change. Everything else is a level, and a when over a level fires once per crossing.
Actions and handlers#
An application with a correlated execution coordinator can choose how it waits: async orders.marketBuy({ … }) leaves other strategy decisions running while this action's site remains pending. await orders.marketBuy({ … }, timeout: "30s") holds strategy decisions until settlement or that chain-time wait deadline. The trailing timeout is execution metadata, not a venue argument; write a positive duration literal. Existing unbounded await and action defaults retain their meaning.
An explicitly bounded await may attach .onTimeout { … }. This reports the wait ending, not transaction cancellation or a failed trade. The coordinator keeps its request identity, site exclusivity and unresolved money reserved. A later .onFill/.onRevert still belongs to that same request. Do not fund a replacement order merely because the strategy resumed. Cancelling an upstream request is a separate supported action and must itself settle. Task .onTimeout retains the task invocation's separate timeout semantics.
The language run folds reads/metrics while the coordinator pauses decisions; trigger edges and phase transitions remain held. Its snapshot retains the pause and in-flight observations, not the coordinator's authoritative request ledger. The application must persist request IDs, deadlines and event cursors, validate late/duplicate outcomes, then feed execution state and report outcomes. The legacy uncorrelated connector is not a bounded-wait execution coordinator.
The JavaScript SDK's requestRun supplies a durable request journal for trigger strategies whose managed actions explicitly choose await or async. It refuses phase machines, maintainers and instantaneous paper brokers. The application supplies durable storage, submits requests and reconciles authoritative venue outcomes; the journal performs no IO. The SDK's legacy connect, Rust Connectors and the platform Host refuse bounded waits. Language support alone does not enable a venue or establish live execution.
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, and every action that fires comes out in the order it is written, guarded or not: an if (inPosition()) { sell } written above a buy is placed before it.
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" });
}| Solidity | Flowscript value |
|---|---|
uintN, intN | a whole number; sent as exact decimal text, range-checked by the encoder |
address, bytesN, bytes, string | text ("0x…" for addresses and bytes) |
bool | a condition |
a tuple (T a, U b) or a named struct | a 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,backoffandvaluesit 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 aconstholding 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
naor fractional, refuses the call before anything is signed. - Interfaces.
interface ERC20 = abi(["sig", …], { structs });lists a contract's functions without an address, anduses { usdc: ERC20("0x…") }binds it to one. Two contracts with the same functions are then two names,usdc.balanceOf(…)andweth.balanceOf(…), with no clash. A program declares its own interfaces or imports them from a library. - Standard libraries.
import "std/erc20";gives theERC20interface (transfer, approve, transferFrom, and the balanceOf, allowance, totalSupply and decimals views) andtokenUnits(amount, decimals).import "std/erc4626";givesERC4626: deposit, mint, withdraw and redeem, and views for its assets, balances, conversions and previews. Their sources and tests are inflowscript/std/. The standard'sfromparameter is namedowner, becausefrom: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, andabiFunction(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; like any let, one cannot take a const's name. 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.
Risk limits#
An invariants block bounds the risk a strategy takes. Each line is a comparison of something measured against a literal limit, and the block ends with what a breach does:
uses { bars: "^1", paper: "^1" }
const close = bars.close("BTC-USD", "1h");
when (crossover(close, ema(close, 20))) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }
when (crossunder(close, ema(close, 20))) { paper.sell({ symbol: "BTC-USD" }); }
invariants {
drawdown() <= pct(5);
dailyLoss() <= 50;
trades.perDay() <= 4;
on_breach: pause;
}drawdown()is the percent fallen from the highest the money put to work has been worth: the largest cost basis held, plus everything made or lost. The book counted at cost is a peak too, so a gap on the bar after a fill is drawdown, and fees count.dailyLoss()is how far profit has fallen since the UTC day began, in quote units. Bars with no time are one day.trades.perDay()counts the orders sent today. A bound reads it as the count the next order would make, so<= 4admits four and refuses the fifth.position(P).size,.entryPriceand.pnlread one market's position.
While a bound is breached, or cannot be read, every entry is refused: a buy is not placed, and the run records why (invariant (drawdown: 6.2/5, on_breach pause)). A sell is never refused, because a cage that froze a losing position would be the opposite of one. on_breach: flatten_and_pause also sells every open position at the bar a drawdown() or dailyLoss() bound breaks, and at each bar after while it stays broken. alert refuses entries the same way and sells nothing.
A replay, a live run and a test enforce the block on their paper book the way ftw's host does on its own. A replay lists what the cage refused in refused, and a test expects it with { paused: paper.buy, at: 15, by: drawdown }. A program can read the same measures itself, in a metric or a condition. Without a paper broker there is no book to measure, and unmodelled says the block is not enforced.
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 |
anchoredVwap(price, vol, anchor), anchoredVwapDev(price, vol, anchor) | the VWAP since the last bar anchor held, that bar included (a swing low, a news bar, an entry), and its volume-weighted standard deviation: Pine's ta.vwap(src, anchor). na until the anchor has held and traded. import "std/vwap"; adds week and month anchors and the reads a VWAP trader makes (Libraries) |
periodVwap(price, vol, t, secs), periodVwapDev(price, vol, t, secs) | the session VWAP a chart draws, reset on the first bar of each period (86400: from 00:00 UTC), and its volume-weighted standard deviation, so periodVwap(…) ± 2 * periodVwapDev(…) are its 2σ bands (Pine's ta.vwap(src, anchor, stdev_mult)). na until the period has traded. See the starter "VWAP rotation: fade failed pushes to the mean" |
corr(a, b, len), cov(a, b, len) | correlation and covariance over a window; given a list, cov([a, b, c], len) is the matrix |
beta(y, x, len) | the hedge ratio: the slope of y's least-squares line on x over the window, ols(y, [x], len)[1] |
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) |
nyTime(t), chicagoTime(t), londonTime(t), frankfurtTime(t), sydneyTime(t), tokyoTime(t), hongKongTime(t) | the time on that city's wall clock, daylight saving included, for the calendar above: hour(nyTime(t)) is New York's hour all year |
inSession(lt, start, end), inWeekdaySession(lt, start, end) | whether a wall-clock time is in [start, end), each HHMM, Pine's session "0930-1600"; an end before the start runs past midnight; inWeekdaySession keeps to Monday to Friday, Pine's ":23456" |
sessionStarts(active), sessionOpen(o, active), sessionHigh(h, active), sessionLow(l, active) | a session's first bar, and its open, high and low so far (between sessions, the last one's) |
nyseClose(t) | the New York Stock Exchange's close on t's New York date, HHMM: 1600, 1300 on an early close, 0 on a weekend or a holiday (its rules since 1993, Juneteenth from 2022, and the closures it named, such as 9/11 and Hurricane Sandy) |
nyseSession(t), nyseTradingDay(t), nyseHoliday(t), nyseEarlyClose(t), nyseMinutesToClose(t) | whether the exchange is open at t (9:30 to its close), opens that day, is shut on a weekday, closes at 13:00; the minutes to its close (na outside the session). The overnight hold: buy when nyseMinutesToClose(t) <= 15, sell when sessionStarts(nyseSession(t)) |
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#
| Math | abs, min(a, b, …), max(a, b, …) (na if any is), clamp(x, lo, hi), ln, log10, exp, sqrt, pow, bps |
|---|---|
| Rounding | floor, ceil, round (halves away from zero, as Pine's), sign (1, −1 or 0) |
| Trigonometry | sin, cos, tan, asin, acos, atan, in radians |
na | na, isNa(x), nz(x, default) |
| Series | change(x), rolling_high(x, n), rolling_low(x, n), median(x, n), percentile(x, n, q) |
| Higher timeframe | sample(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 statistics | skew(x, n) and kurtosis(x, n) (the biased skewness and excess kurtosis, scipy's defaults), dev(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 |
| Time | seconds, minutes, hours, every, daily, during |
| Triggers | crossover, crossunder, rising, falling, changed, once, sustained, cooldown, hysteresis |
| Tapes | map, resample, window, sum, mean, count, first, last, distinct |
| Lists | median(xs), mean, sum, min, max, count, stddev, variance, mad, skew, kurtosis, percentile(xs, q), wmean(xs, ws), dot(a, b), solve(A, b), inv(A), det(A), transpose(A), ols(y, xs, n), cov(xs, n), corr(xs, n); see below |
| Arrays | buffer(x, n), push(xs, x, n), concat, size, xs[i], slice(xs, start, end?), reverse, sort, sortBy(xs, f), unique, contains, indexOf, map(xs, f), filter, reduce(xs, seed, f), any, all, find, groupBy(xs, f), and the list reducers and first, last, count, distinct; see Arrays |
| Market profile | tpo.poc, tpo.vah, tpo.val, tpo.vpoc, tpo.vvah, tpo.vval, tpo.vpocShift, tpo.high, tpo.low, tpo.ibHigh, tpo.ibLow, tpo.poorHigh, tpo.poorLow, tpo.singleAbove, tpo.singleBelow, tpo.nakedPocAbove, tpo.nakedPocBelow, each (high, low, close, volume, t, window: n, …); see Market profile |
| Trained models | ml.linear, ml.ridge, ml.lasso, ml.elasticNet, ml.logistic, ml.svm, ml.svr, ml.knn, ml.boost, ml.boostClassifier, each (y, [x1, x2, …], window: n, …); see Trained models |
| Exact integers | u256 and i64 checked arithmetic, u256MulDiv/u256MulDivUp, u256Units, u256Format and safe number conversions; details |
Lists, statistics and linear algebra#
A list, [a, b, c], is a fixed number of numbers or series: three price feeds, a portfolio's weights, the factors of a hedge. A list of equal-length lists is a matrix, row by row. A list has the length it was written with, so every shape is checked when the program compiles, and nothing about it costs anything at run time that its items do not (D1524).
uses { bars: "^1" }
// The median of three readings, skipping one that has no price this bar.
const feeds = [bars.open("BTC-USD", "1h"), bars.close("BTC-USD", "1h"), bars.high("BTC-USD", "1h")];
metric("mid", median(feeds));
metric("spread", max(feeds) - min(feeds));
// Arithmetic on a list applies to each item.
const scaled = feeds / feeds[1] - 1;
metric("open_vs_close", scaled[0]);Across assets, the same tools hedge and size a book:
uses { bars: "^1" }
const btc = bars.close("BTC-USD", "1h");
const eth = bars.close("ETH-USD", "1h");
const sol = bars.close("SOL-USD", "1h");
const returns = [btc, eth, sol] / [btc[1], eth[1], sol[1]] - 1;
// SOL hedged with BTC and ETH: a rolling least-squares fit over 200 bars,
// [intercept, b_btc, b_eth], and the residual the hedge leaves.
const fit = ols(returns[2], [returns[0], returns[1]], 200);
const residual = returns[2] - dot(fit, [1, returns[0], returns[1]]);
metric("sol_beta_btc", fit[1]);
metric("residual_z", (residual - sma(residual, 50)) / stddev(residual, 50));
// Minimum-variance weights: solve Σ·w = 1, then scale them to sum to 1.
const w = solve(cov(returns, 200), [1, 1, 1]);
const weights = w / sum(w);
metric("w_btc", weights[0]);- Reducers take one list and answer one number from its decided items:
median,mean,sum,min,max,count,stddevandvariance(dividing byn, as the windowedstddevdoes),mad(the median absolute deviation),skew,kurtosis(excess) andpercentile(xs, q)withqfrom 0 to 1. Annaitem is skipped, so a feed that has no price this bar does not blank the median of the others; the answer isnaonly when no item is decided. The conventions are numpy's and scipy's defaults: an evenmedianaverages the middle two, andpercentileinterpolates between the two nearest ranks. (The windowedmedian(x, n)andpercentile(x, n, q)pick the nearest rank, as they always have.) - Arithmetic
+ - * /and unary-apply item by item: two lists of the same length pair up, and a number or series on one side applies to every item, as numpy broadcasts. A comparison does not take a list: reduce it first,max(xs) > 100. - Items:
xs[0]is the first item andxs[-1]the last, an index written in the source; on a matrix,A[0]is the first row. On a list,[k]is an item, never a bar back:feeds[1]is the close, andbtc[1]is BTC an hour ago. - Linear algebra at each bar:
dot(a, b)is numpy's (a number for two vectors, a vector for a matrix and a vector, a matrix for two matrices),wmean(xs, weights)the weighted mean,solve(A, b)the vectorxwithA·x = b,inv(A),det(A)andtranspose(A). A singular matrix makessolveandinvnarather than an answer made of rounding error. - Rolling over the last
nbars at which every series is decided:ols(y, xs, n)is the least-squares fit ofyon a constant and the listxs,[intercept, b1, …](Householder QR, so a nearly collinear pair does not lose its digits as the normal equations would);cov(xs, n)andcorr(xs, n)are the covariance and correlation matrices. Each isnauntil its window fills, andolsisnawhile a factor does not move. OLS normalizes each column and the target by an exact power of two before fitting, so very large or small units do not overflow the QR norms or turn a varying factor into a constant. Coefficients are returned in the original units. A rank-deficient fit, a nonfinite coefficient, or a fit whose normalization would lose an input's precision remainsna. - A list is not a value to publish:
metric("w", weights)is an error that asks for an item or a reduction. Afntakes and returns a list like any other value.
Every evaluator answers these with one Rust implementation (flowscript/src/stats.rs), in plain IEEE arithmetic, so a backtest, a live run and a chart study agree to the bit. Generated list and matrix IR is reserved against the compiler's existing size and lowering quotas before eager copying; oversized expansions are located compilation refusals. The live evaluator charges matrix elimination and rolling regression work before execution against its watermark evaluation budget, including computations whose result would be unavailable.
Arrays#
An array is a value a run gathers as it goes, as long as its data makes it: the last hundred trades a source heard, the wallets among them that bought, the book grouped by pool (D1544). A list has the length it was written with and costs nothing at run time; an array is held by the run, so it can be built from a stream, filtered and sorted.
uses { bars: "^1" }
const close = bars.close("BTC-USD", "1h");
const volume = bars.volume("BTC-USD", "1h");
// The last 48 hourly bars, oldest first, each a record.
const bars48 = buffer({ close: close, volume: volume }, 48);
// The bars that traded more than usual, and the five busiest of them.
const usual = mean(map(bars48, (b) => b.volume));
const busy = filter(bars48, (b) => b.volume > usual * 1.5);
const busiest = slice(sortBy(bars48, (b) => -b.volume), 0, 5);
metric("busy_bars", size(busy));
metric("busiest_close", busiest[0].close);
metric("median_close", median(map(bars48, (b) => b.close)));
// What a fold keeps can be an array too: every close above 100, the newest 20.
const highs = fold(close, [], (xs, c) => c > 100 ? push(xs, c, 20) : xs);
metric("highs", size(highs));- Building one.
buffer(x, n)is the lastnsamples ofx, oldest first: a series, or a record such as a source'sinput.event(). A sample that isnais not kept, andbuffersteps when its input ticks, as a window does. Inside afold, an array seed ([]) holds what the step puts in it:push(xs, x, n)isxswithxafter its last item, keeping the newestn(annaxis not added).concat(a, b)isa's items thenb's. A list written in the source,[a, b, c], is an array wherever a function takes one. - Reading one.
size(xs)is how many items it holds;xs[i]is an item, from 0, with −1 the last, andnapast either end (the index may be any number the run computes).first(xs)andlast(xs)are the ends.slice(xs, start, end?)is the items fromstartup toend, −1 counting from the last, soslice(xs, -10)is the last ten.contains(xs, v)andindexOf(xs, v)(nawhen it is not there) look for an item. - Functions of its items.
map(xs, x => …),filter(xs, x => cond),reduce(xs, seed, (acc, x) => next),any,all,find(the first item the condition holds for, orna),sortBy(xs, x => key)(lowest key first, stable,nalast;-x.usdfor the largest first) andgroupBy(xs, x => key), which is{ key, items }for each key in the order it first appears (an item whose key isnais in none).sort(xs)orders numbers or text,reverse(xs)turns it round, andunique(xs)keeps each different item once. - Reducing one. An array of numbers takes the list reducers,
sum,mean,median,min,max,stddev,variance,mad,skew,kurtosisandpercentile(xs, q), skippingnaitems as a list's do; the sum of no numbers is 0.count(xs)is the items that are notna, anddistinct(xs)how many different values it holds. Reduce an array of records by mapping it first:sum(map(xs, x => x.usd)). - A list of markets reads each of them (D1545). A read keyed by a list written in the source,
aerodrome.rewardApr(pools, interval)withconst pools = ["base:a", "base:b"], is the list of the reads, one per market in the list's order:apr[1]is the second's,max(apr)the best, and the artifact names every series read. The keys are literals, as any read's key is; a list of anything else is refused (identity.key). mapover a list runs its function at compile time, once per item, with the item itself bound to the parameter, so, unlike an array'smap, its body may window and read:map(price, (p, i) => stddev(change(p) / p[1], 24))is a list of one volatility per market, and the second parameter is the item's position from 0. The answer is a list, which is an array wherever a function takes one:sortBy(map(pools, (p, i) => ({ pool: p, apr: apr[i] })), (s) => -s.apr)ranks the markets.- A function given to an array runs once for each item, so it keeps no state and reads no feed of its own (
array.lambda):filter(xs, x => x > ema(close, 20))is refused. What it reads from outside is computed once, before it, and the function reads that value, so bind it to aconstand read the const:const level = ema(close, 20); filter(xs, x => x > level). - An array is not a number: arithmetic and comparisons refuse one, and so does
metric. Publish one of its items or a reduction of it. An action argument its capability declaresJsontakes an array, so a source can publish one as a field (below). - Bounds. An array holds at most 10,000 items:
bufferandpushkeep at most that many, written in the source, and aconcatthat would hold more isna. Every item an operator touches, and a sort'sn log n, is charged to the watermark's evaluation budget before the work, and an array a run keeps counts toward its retained state. A program that would walk or keep more than a run may is stopped with a refusal at that watermark, rather than slowing every run beside it.
In a source, each event is one step, so buffer(input.event(), 200) is the last 200 events and every function above works on them. A field declared json publishes an array:
uses { input: "^1", source: "^1" }
const e = input.event();
const recent = buffer(e, 200);
const byPool = groupBy(recent, (s) => s.pool);
const flows = map(byPool, (g) => ({ pool: g.key, usd: sum(map(g.items, (s) => s.usd)) }));
const top = slice(sortBy(flows, (f) => -f.usd), 0, 5);
while (size(recent) == 200) { source.emit({ pool: e.pool, top: top }); }A tape's array field. A host may declare a tape's field an array, of numbers, text, words or records ({ "array": { "record": { "pool": "text", "usd": "number" } } }, as a ftw source declares [{ pool: "pool", usd: "number" }]), and a program reads it as one. buffer(tape, n) keeps a tape's last n events across watermarks as an array of records, newest last, so the newest event's array is flows[-1].top; inside a tape's map, an array function over the event's own field (size(e.top)) is one value per event, which its buckets reduce. An array field left out is empty.
const flows = buffer(sources.top_pools(), 10);
const top = size(flows) > 0 ? flows[-1].top : [];
metric("usd", sum(map(top, (f) => f.usd)));On a chart. A study may keep an array too, as long as each line it draws is a number: metric("up", size(filter(buffer(price, 20), (x) => x > open))). The compiled plan holds no arrays, so such a study is replayed bar by bar by the same evaluator a strategy runs on, and draws what that run computes.
Trained models#
A strategy can train a regression or a classifier on its own series and use its prediction at every bar, in a backtest and live alike (D1561). A model is written ml.<kind>(y, [x1, x2, …], window: n, …): the target y, a list of predictors, and named settings.
uses { bars: "^1", paper: "^1" }
const close = bars.close("BTC-USD", "1h");
const volume = bars.volume("BTC-USD", "1h");
// The return over the last 6 hours: the target, known at each bar.
const ret6 = close / close[6] - 1;
// What the model sees at each bar.
const features = [
close / close[1] - 1,
close / ema(close, 24) - 1,
volume / sma(volume, 24) - 1,
rsi(close, 14) / 100 - 0.5,
];
// Learn the next 6 hours' return from the last 500 hours, refitted every
// 24 hours and whenever volume spikes.
const forecast = ml.ridge(ret6, features, window: 500, horizon: 6, retrain: 24,
retrainOn: volume > sma(volume, 24) * 3, alpha: 0.5);
// The chance the next 6 hours close higher.
const up = ml.logistic(ret6, features, window: 500, horizon: 6, retrain: 24);
metric("forecast", forecast);
metric("p_up", up);
when (forecast > 0.004 && up > 0.6) { paper.buy({ symbol: "BTC-USD", quote: 100 }); }
when (forecast < 0) { paper.sell({ symbol: "BTC-USD" }); }- How it trains. At every bar at which
yand every predictor are decided, the model keeps a row of them, the lastwindow + horizonrows. A training example pairs one row's predictors withyfromhorizonrows later, so the model learns whatywill behorizonbars after what it sees, and never trains on a target that is in the future of the bar it answers at.horizonis 1 when omitted;horizon: 0fitsyto the predictors of the same bar, asolsdoes. - When it retrains. The model is fitted when its rows first fill, again every
retrainnew rows (1 when omitted, so every bar), and again at any row whereretrainOnis true.retrain: 0fits once and keeps that model, unlessretrainOnasks for another. Between fits it keeps its parameters, which are part of the run's state: a resumed run continues with the same model. A fit that the rows do not determine (one class only for a classifier, a predictor that does not move forml.linear) keeps the model it had. - What it answers. At each new row, the model's prediction from that row's predictors; at a bar with no new row it holds its last answer. A regression predicts
y;ml.logisticandml.boostClassifieranswer the probability thatyis above zero;ml.svmanswers its signed margin, above zero for "yabove zero".nauntil the first fit. - Predictors are standardised over each fit's examples (centred and divided by their standard deviation) and a prediction standardises its row with the same numbers, so a penalty treats every predictor alike whatever its units. A predictor constant over the window is left out of that fit. Bounded, power-of-two moment calculations preserve changing predictors even in very small or large units; only exactly constant predictors are omitted. A nonconstant spread that cannot be represented refuses the fit, and a prediction with a nonfinite standardized input is
na.window,horizon,retrainand every setting are numbers written in the source (orconsts), since they set the model's state and work.
| Model | Settings (default) | What it is |
|---|---|---|
ml.linear | Ordinary least squares on a constant and the predictors | |
ml.ridge | alpha (0.1) | Least squares with an L2 penalty, (1/2n)·‖y − Xw‖² + (alpha/2)·‖w‖², on a standardised target |
ml.lasso | alpha (0.01) | An L1 penalty, alpha·‖w‖₁, which drops predictors: one whose correlation with the target is below about alpha gets no weight |
ml.elasticNet | alpha (0.01), l1Ratio (0.5) | The two penalties mixed, alpha·(l1Ratio·‖w‖₁ + ((1 − l1Ratio)/2)·‖w‖²) |
ml.logistic | C (1) | L2-regularised logistic regression on "y above zero", C·Σ logloss + ½‖w‖² (scikit-learn's) |
ml.svm | C (1), kernel ("linear"), gamma | A support-vector classifier on "y above zero"; kernel: "rbf" with gamma (1 / the number of predictors when omitted) for a curved boundary |
ml.svr | C (1), epsilon (0.1), kernel, gamma | Support-vector regression, epsilon in standard deviations of the target |
ml.knn | k (5) | The mean target of the k training examples nearest the row |
ml.boost | trees (100), depth (3), learningRate (0.1), minLeaf (20) | Gradient-boosted regression trees on the squared error of a standardised target: trees trees of depth levels, each fitted to what the trees before it missed and added at learningRate, every leaf holding at least minLeaf examples. Trees find thresholds and interactions (a feature that matters only in a trend, a reversal past an extreme) that a linear model cannot |
ml.boostClassifier | as ml.boost | Gradient-boosted trees on the log loss of "y above zero": the probability that y is above zero |
Every evaluator fits with one Rust implementation (flowscript/src/ml.rs), in plain IEEE arithmetic with no randomness: least squares by Householder QR, ridge by its normal equations, lasso and elastic net by coordinate descent, logistic regression by Newton's method, and support-vector models by dual coordinate descent (LIBLINEAR's), boosted trees by second-order gradient boosting over 32 quantile split points per predictor (XGBoost's leaves with λ = 1), each stopping at a fixed tolerance or iteration cap. So a backtest, the optimizer and a live run fit the same model to the bit. A fit's work is charged against the bar's evaluation budget before it starts, and a model whose fit could exceed it is refused at compile time: reduce window, the predictors, or a boosted model's trees or depth. An RBF model or ml.knn keeps its training rows, so its window is at most 1,000. Refitting every bar is the default and is cheap for ml.linear and ml.ridge; for the iterative models and boosted trees, a retrain cadence keeps backtests fast. window must hold at least two minLeafs of examples.
Market profile#
A market profile (TPO, time price opportunity) counts the time price spent at each level: every bar puts one block in each price row its range touched. tpo.<level>(high, low, close, volume, t, window: n, …) reads a level of the profile of the last whole session (or, with previous: false, of the session still forming) from the last n bars, in a backtest and live alike. The blocks are the bars the program runs on, so the interval sets the resolution: 30-minute bars for a daily profile and 4-hour bars for a weekly one are the conventions. The chart's TPO study draws the same profiles.
uses { bars: "^1", paper: "^1" }
const high = bars.high("BTC-USD", "1h");
const low = bars.low("BTC-USD", "1h");
const close = bars.close("BTC-USD", "1h");
const volume = bars.volume("BTC-USD", "1h");
const t = bars.time("BTC-USD", "1h");
// Yesterday's profile, from the last two days of hourly bars.
const poc = tpo.poc(high, low, close, volume, t, window: 48);
const poorLow = tpo.poorLow(high, low, close, volume, t, window: 48);
// Volume sat below where the time was spent: sellers did the business.
const shift = tpo.vpocShift(high, low, close, volume, t, window: 48);
metric("poc", poc);
metric("shift", shift);
// Buy back into value over a poor low left yesterday, target its POC.
when (poorLow == 1 && crossover(close, tpo.low(high, low, close, volume, t, window: 48))) {
paper.buy({ symbol: "BTC-USD", quote: 100 });
}
when (close >= poc) { paper.sell({ symbol: "BTC-USD" }); }The series are the bars' high, low, close, volume and start time t (unix seconds). The levels:
| Level | |
|---|---|
poc | the time point of control: the middle of the row with the most blocks (a tie goes to the row nearest the middle of the range) |
vah, val | the value area's top and bottom: grown from the POC two rows at a time toward the heavier pair until it holds valueArea of the blocks |
vpoc, vvah, vval | the same over volume, each bar's spread evenly over the rows it touched |
vpocShift | vpoc − poc: positive when the volume sat above where the time was spent |
high, low | the session's range |
ibHigh, ibLow | the initial balance: the first two bars' range |
poorHigh, poorLow | 1 when the extreme row has two or more blocks (no single-print tail: an unfinished auction, which price tends to revisit), else 0 |
singleAbove, singleBelow | the nearest single print (a one-block row inside a profile, not its tail) above or below the close that no later bar has traded through, over every whole session in the window |
nakedPocAbove, nakedPocBelow | the nearest session POC above or below the close that no later bar has traded at |
Settings, each a literal or a const:
window: n(required): the bars kept. A session is read only once it is whole in the window, so keep at least two sessions of bars (96 on 30m for a daily profile); more widens the search for single prints and naked POCs.session:"day"(UTC, the default),"week"(from Monday 00:00 UTC),"window"(the whole window as one profile), or a length in seconds;offsetshifts where a numbered session starts.previous:true(the default) for the last whole session,falsefor the one still forming.row: the row height in price; 0 (the default) picks a round number near a 36th of the median session's range. At most 2000 rows a profile.valueArea: the value area's share, 0.7 by default.
A level not there to read (no session whole yet, no single print above, a session with no volume) is na.
Exact integers#
Ordinary numbers use IEEE 754 binary64. Consecutive integers are safe only through 9,007,199,254,740,991 (2^53 − 1). In an 18-decimal token that is about 0.009 tokens, so balances and contract base units must use exact integers.
Flowscript 0.3 provides checked u256 (0 through 2^256 − 1) and i64 (−2^63 through 2^63 − 1, a signed long) functions. Their integer results are canonical decimal text, preserving every digit through action arguments, JSON, saved state and replay. They are a math library over text, not new operator-overloaded types: use u256Add/i64Add and u256Cmp/i64Cmp. Ordinary + on text concatenates; ordinary text ordering is lexical.
flowscript "0.3";
import "std/math";
const budget = parseUnits("1.000000000000000001", 18);
const minimum = basisPoints(budget, 9970);
metric("minimum", formatUnits(minimum, 18));
metric("signed", i64Add(long("-9223372036854775808"), 1));
metric("aboveHalf", u256Cmp(minimum, parseUnits("0.5", 18)) > 0);- Inputs. Exact integers accept decimal text; unsigned integers also accept
0xhexadecimal text. Use quoted text or exact source fields for large values. In 0.3 integer arguments supplied as numbers must be finite safe integers. A literal beyond that range is refused at compile time; an unavailable or invalid dynamic operand producesna. All numeric literals and expressions are floats before this check; it validates the represented value, not the original decimal spelling. Even a fractional literal can round to an integer first. Text is required when every input decimal digit matters. - Unsigned arithmetic.
u256Add,u256Sub,u256Mul,u256Div,u256Mod,u256Min,u256Maxandu256Cmpare exact and checked. Division rounds down.u256DivUp(a, b)rounds up. - Full precision products.
u256MulDiv(a, b, denominator)computes the complete 512-bit product before dividing, rounding down.u256MulDivUprounds up. An intermediate product beyond uint256 is valid when the final answer fits. Division by zero or a final answer beyond uint256 isna. - Signed long arithmetic.
i64Add,i64Sub,i64Mul,i64Div,i64Mod,i64Neg,i64Abs,i64Min,i64Maxandi64Cmpare checked. Signed division truncates toward zero; the remainder has the dividend's sign.i64Div("-9223372036854775808", -1)overflows; its mathematical remainder is"0". Negating or taking the absolute value of that minimum also overflows. No operation wraps or saturates. - Units and formatting.
u256Units(amount, decimals)converts an unsigned decimal amount to base units without rounding.u256Format(value, decimals)produces exact whole-token decimal text, trimming trailing fractional zeros. Decimal places are whole numbers from 0 to 77. Excess nonzero fractional digits refuse; scientific notation in text refuses. A numeric amount uses its shortest decimal representation and cannot restore digits already lost in floating-point calculations. Use text for exact input. - Display conversions.
u256Number(value, decimals?)andi64Number(value)explicitly round to a float for charts. Never route that result back into a monetary amount.u256SafeNumber(value)andi64SafeNumber(value)instead returnnaoutside the safe integer range. - Failure. Invalid operands, overflow and division by zero produce
na. An action with annaargument refuses before signing. No value is silently replaced with zero.
import "std/math"; adds uint256, long, parseUnits, formatUnits, mulDiv, mulDivUp, basisPoints and basisPointsUp. Basis-point functions divide the full product by 10,000; the Up form rounds up. std/erc20's older tokenUnits helper uses ordinary floating-point math; use parseUnits for exact amounts.
Programs explicitly saved as 0.1 or 0.2 keep their earlier u256 numeric operand semantics and function names. New exact helpers require 0.3, and 0.3 artifacts record distinct exact operations, so a saved historical artifact is not silently reinterpreted with the stricter input policy.
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().
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.
| Code | Slug | What it finds |
|---|---|---|
FS0901 | lint.never | a 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 |
FS0902 | lint.repeat | a while that buys or opens a position with no position guard, so it sends the order at every bar its condition holds |
FS0903 | lint.unused | a const nothing reads (name it _x to keep it) |
FS0904 | lint.na | a divide or remainder by a literal 0, which is na at every bar |
FS0905 | lint.unit | bps(n) under arithmetic or a comparison, where it is the number n, not the rate n / 10000 |
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.
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.
whilerepeats (FS0902).while (rsi(close, 14) < 30) { paper.buy(…) }buys at every bar of a long dip. Entries belong in awhen, which fires once per crossing; keepwhilefor an action meant to repeat, with a guard that turns false once it has done its job.whenfires 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]. nais not false.!(close > avg)isna, not true, whileavgwarms up, so awhenon it waits. Write the condition you mean (close < avg), and usenzorisNawhere a missing value should count as something.- A fold that does not test for
nastaysna.foldcarries its state from bar to bar, so one missing sample makes the statenafor the rest of the run unless the step leaves the state as it was (isNa(x) ? st : …). The prelude's indicators all do this. - Some windows are partial at first, some
na.sma,stddevand the other averaging windows average what they have until the window fills, whilehighest,lowest(rolling_high,rolling_low),medianandpercentilearenauntil it fills, asema,rsiandatrare until they warm up. Gate onbarIndex(close) >= nwhere the first bars must not trade, andnzor scale what a window has (cummax(x) - cummin(x)is the range since the start) where they must. - Time needs the bar's time.
every,cooldown,dailyandduringcount in the time each bar carries (timein a replay's history, thetimeofstep). Without one a bar's time is its number, soevery("1d")fires once andcooldown(hours(1))waits 3600 bars. Run times must be nondecreasing whole seconds from 0 through 9,007,199,254,740,991, the largest consecutively exact integer the numeric representation supports. Closed and forming bars refuse an invalid time before changing state. - A watermark has one input budget. All its sources together may supply at most 1,000,000 values and 64 MiB of logical allocation charges: 64 bytes per value and record entry, plus text/key bytes. Text and keys are limited to 4,096 UTF-8 bytes, nesting to 128 levels. Source keys are billed by their canonical stream/key identity, including when restored. Internally generated clock, position, phase and lifecycle views have a separate allowance of the same size, so they cannot make an admitted external observation impossible to restore. These admission budgets also apply alongside retained state and evaluation-work limits.
- A run has one retained-state allowance. Folds, history, held reads, private caches, shared live state, the last input view and paper-book state share 256 MiB of logical charges. Each value and map/cell entry costs 64 bytes plus text and key bytes, even when allocations are shared. Shared handles and reservation entries also pay for their metadata, so total limit boundaries can differ by that overhead from a private materialization. Retained values may nest at most 32 levels. A resource fault after evaluation begins stops the run; recover explicitly from the last successful checkpoint. Optional shared memo/version/index caches have a separate 64 MiB allowance per shared runtime. Pressure can discard optional cache entries; it never discards a live fold or changes a decision.
- Checkpointing can refuse. Encoded checkpoints are bounded to 128 MiB while writing. Rust
Run::snapshot()returnsResult; npm returns{ ok, snapshot }or{ ok: false, error }. Hosts must handle refusal before replacing a durable checkpoint. Restore checks encoded and expanded input limits and rebuilds live accounting from the actual values. - An order is sized by a literal. A paper broker sizes from a number (or a constant or
parambound 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. flowscript.suite(text) reads a suite's text into the object test takes, or says why it is not one, for an editor that keeps the cases rather than the text.
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). The replay limit of 1,000,000 bars is checked before generated sources allocate. 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. runs must be from 1 to 200. Bar times must fit whole unsigned 64-bit seconds; a generated time that would overflow is refused.
- 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:
| Written | Feeds |
|---|---|
[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 |
Every record row must supply the same fields. Use an explicit null for a numeric field that is na; leaving a field out is refused, so a later observation cannot shift into an earlier bar.
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:
| Written | Holds 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 |
{ paused: paper.buy, at: 15, by: drawdown } | the invariant cage refused that action's entry, by that invariant when by is given; count: 0 is never |
{ 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. Checks must produce numeric or boolean values. Their from and to range must lie within the replay, with from at or before to; an empty range cannot make a never check pass. A metric expectation supplies exactly one of equals or na: true. within is an absolute, finite tolerance at or above zero and requires equals.
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` = 25What 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 astime(replay_timedin Rust) foreveryandcooldownto count in chain time. A metric or trigger that reads only number series is computed for every bar at once by the compiled study plan, and the rest is stepped bar by bar; the answers are the same to the bit, so a long backtest costs about what a chart study does (D1270). - 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:
maintainblocks are listed in the result'sunmodelledwith the reason, never silently dropped. So are invariants in a run with no paper broker.
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.
Parameters and search spaces#
const lookback = param(7, { min: 3, max: 30, step: 1 }); declares a parameter: its value is the default until a run's settings change it. The bounds say what may change it and what an optimizer searches:
minandmaxbound an edit, and the default sits between them.step, with both, makes min to max by step the search space (at most 1000 values).options: [...]lists the values instead, up to 64 of the default's type, the default among them. It is not combined with min, max or step.
The compiler refuses any other bound (param.bounds, param.step, param.options). A host that runs a strategy may bind each declared value in place as a constant before compiling, as ftw.trade does, so a parameter can size a window (close[lookback]); read as a configuration stream it can't. On ftw.trade the declarations are the strategy's parameters, and an optimization with no space named searches the declared steps and options.
Coming from Pine Script#
Most Pine indicators port line for line, and an importer does the porting (D1194): pineToFlowscript in @ftw-trade/charts/pine translates the common v5/v6 subset of an indicator to a chart study and of a strategy to a ftw.trade strategy, and names, by Pine line, what it could not take (while, a for it cannot port, request.security of another symbol, := on a name that is not a var). A for i = 0 to len - 1 that adds, or takes the max or min of, one series' x[i] becomes rolling_sum, highest or lowest; a for over constant bounds (at most 64 steps) that only accumulates is unrolled. A var and its :=, += … updates, under ifs too, become one fold that applies them in order each bar: var int n = 0 with n := up ? n + 1 : 0 is const n = fold(close, 0, (n, _) => nz(up, false) ? n + 1 : 0);. The charts site's Flowscript studio and 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"); }| Pine | Flowscript |
|---|---|
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.correlation | sma, ema, wma, rma, rsi, stddev, vwap(price, vol, len) (a rolling window; anchored, anchoredVwap(src, vol, anchor) or a session's periodVwap), roc, momentum, corr |
ta.atr(14), ta.tr | wilderAtr(high, low, close, 14), trueRange(high, low, close) |
ta.highest, ta.lowest, math.sum, ta.cum | highest, 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.pivotlow | pivothigh(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.variance | sma(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.tsi | the 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.cross | the same names |
[mid, up, lo] = ta.kc(src, 20, 2), ta.kcw, ta.bbw | ema(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.wvad | the same names, with the bars and the volume as arguments |
ta.crossover, ta.crossunder, ta.change | crossover, 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 + 1 | fold(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.none | the @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.new | a 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_index | hour(time) … year(time) in a chart study (UTC), barIndex(price) |
time(timeframe.period, "0930-1600:23456", "America/New_York"), timezone, session.isfirstbar | inWeekdaySession(nyTime(time), 930, 1600); hour(nyTime(time)); sessionStarts(active) |
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.close | the 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 price | a broker rule with rest (D1212), such as 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 market | a read at that market and interval, bars.close("BTC-USD", "1d"), where the host publishes it |
for, arrays and maps | not in the language: a window function, fold, or percentile and median over a window |