eTrader Tide
Home eTrader AI Open eTrader Web

The Tide manual.

Tide is the language your eTrader trading robots and chart indicators are written in. This page is the whole of it: every type, every event, every call, with a worked example for each. Read it top to bottom and you can write a bot.

Complete reference .etb robots .eti indicators 45 built-in indicators
bot "Golden Cross" { version "1.0" }

input int  fast   = 50
input int  slow   = 200
input lots volume = 0.10

fn onBar() {
  guard positions.count() == 0 else { return }
  if crossesOver(ema(close, fast), ema(close, slow)) {
    trade.buy(volume,
      sl: symbol.ask - 40 * symbol.pip,
      tp: symbol.ask + 90 * symbol.pip)
  }
}

What Tide is

Tide is a programming language with one job: describing what a trading strategy does, and then doing it. You write a plain text file with a .tide extension, compile it, and get a small bundle that eTrader runs for you.

There are two kinds of thing you can build.

You write It compiles to It does
A bot.etbWatches the market and places trades on your account, from eTrader's servers, with your computer switched off.
An indicator.etiDraws on the chart in the desktop terminal. Lines, bands, arrows, histograms, panels.

Two smaller kinds exist for organising code: a library (.etl) holds functions you import into several bots, and a script runs once and exits, for one-off jobs like closing everything.

What makes it different

Most trading languages were designed when a strategy was a C program that happened to run on a chart. Reading a moving average takes a handle, a global variable, an array, a copy call and a release call. Placing one order takes a request structure, a send call, a return code and a result structure. Most of a robot ends up being paperwork rather than trading.

In Tide an indicator is an expression and an order is one call with named arguments:

let trend = ema(close, 50)

if close[0] > trend[0] {
  trade.buy(0.10, sl: 1.0800, tp: 1.0950)
}

That is the whole idea. Everything below is detail.

What you need to know already

If you have written anything in JavaScript, Python, Swift or C, you can read Tide today. If you have never programmed, this page is written to be read start to finish: every concept is introduced before it is used, and every section has a runnable example.

↑ Back to top

Your first bot, line by line

Complete and runnable

Here is a complete robot. Nothing is left out, nothing is simplified away. It buys when a fast moving average crosses above a slow one, and it never holds more than one position at a time.

bot "Golden Cross" {
  version "1.0"
}

input int  fast   = 50
input int  slow   = 200
input lots volume = 0.10

fn onBar() {
  guard positions.count() == 0 else { return }

  if crossesOver(ema(close, fast), ema(close, slow)) {
    trade.buy(volume,
      sl: symbol.ask - 40 * symbol.pip,
      tp: symbol.ask + 90 * symbol.pip)
  }
}

Now the same file, one piece at a time.

1. The program declaration

bot "Golden Cross" {
  version "1.0"
}

Every file starts with exactly one of these. The word bot says this compiles to a robot. The text is the name a trader sees in their bots list. The block holds metadata: version, author, description and a few others covered in how a file is laid out.

2. Inputs

input int  fast   = 50
input lots volume = 0.10

An input is a setting the trader fills in when they install the bot. Each line becomes one field in a form, automatically. You never write form code. int gives a number field, lots gives a volume field that snaps to the instrument's step size, and the value after = is the default.

3. An event handler

fn onBar() {
  ...
}

fn declares a function. A function named onBar is special: eTrader calls it once at the start of every new candle. There are other handlers for every tick, for a timer, and for trade events, all listed in events.

4. The guard

guard positions.count() == 0 else { return }

Read it out loud: carry on only if there are no open positions, otherwise leave. Guards say what must be true, instead of nesting the real work inside an if. The else block must exit, so a guard can never silently fall through.

5. The signal

if crossesOver(ema(close, fast), ema(close, slow)) {

ema(close, fast) is an exponential moving average of the closing price. It is not a number, it is a series: one value per candle, going back through history. crossesOver(a, b) is true on exactly the candle where a moves from below b to above it. Series are the one idea worth learning properly, and they get their own section next.

6. The order

trade.buy(volume,
  sl: symbol.ask - 40 * symbol.pip,
  tp: symbol.ask + 90 * symbol.pip)

One call. The first argument is the volume. sl: and tp: are named, so you cannot get them the wrong way round. symbol.pip is the size of one pip on whatever instrument the bot is running on, so the same file works on EURUSD and on gold without a change.

Try it. Change crossesOver to crossesUnder and trade.buy to trade.sell. You now have the mirror strategy, and nothing else needs touching.

↑ Back to top

How a file is laid out

A .tide file always reads top to bottom in the same order. Only the first line is required.

bot "Name" { ... }          // 1. what this is, and its metadata

permissions { ... }         // 2. what it is allowed to reach

import "risk.tidelib" as risk   // 3. code from other files

input int length = 14       // 4. the trader's settings

let trend = ema(close, 200) // 5. file-level values and state
var lastEntry: time         //    these live for the life of the bot

fn onBar() { ... }          // 6. event handlers and your own functions

The metadata block

Everything here is optional except the name in quotes.

Key Example What it does
version"1.2.0"Shown to the trader and stamped into the bundle.
author"Your Name"Shown in the bots list.
description"Buys pullbacks in an uptrend."One line under the name in the install screen.
icon"icon.png"A small image packed into the bundle.
website"https://yoursite.com"A support link on the install screen.
timeframesM15, H1The bot refuses to start on any other timeframe.
symbols"EURUSD", "GBPUSD"The bot refuses to start on any other instrument.
minBars500Waits until this much history is loaded before the first event.
tags"trend", "swing"Search keywords.

Indicators take three more: panel (overlay to draw on the price chart, separate for its own pane), scale (price, auto, percent or fixed(0, 100)) and precision.

Comments

// a line comment, to the end of the line

/* a block comment,
   over several lines */

Naming

Names are case sensitive. Values and functions are written likeThis, types and structs LikeThis, and compile-time constants LIKE_THIS. Underscores are allowed in numbers for readability: 1_000_000 is one million.

↑ Back to top

Values and types

Tide checks types when it compiles, not when it trades. A mistake in a unit is caught on your desk instead of on a live account.

The basic types

Type What it holds How you write one
intA whole number42 -7 1_000_000
numA number with decimals3.14 1e-6 .5
boolTrue or falsetrue false
textA string of characters"EURUSD" "line\n"
timeA moment in time2026.09.11 2026.09.11 14:30 now
durationA length of time30s 5m 4h 2d 1w
priceA number that rounds to the instrument's digits1.08452
lotsA number that snaps to the instrument's volume step0.10
colorA colour#3d7bf5 #3d7bf5aa red
Why price and lots are separate types. Passing a volume where a price belongs is the single most common way to lose money in an automated strategy. In Tide it does not compile.
let stop: price = 1.0850
let size: lots  = 0.10

trade.buy(stop)      // compile error: expected lots, found price
trade.buy(size, sl: stop)   // fine

Containers

Type What it is Example
series<T>One value per candle, going back in timeclose, ema(close, 50)
array<T>An ordered list you can grow[1, 2, 3]
map<text, T>Values looked up by name{"a": 1, "b": 2}
T?A value that might be missingPosition?
structYour own named fieldsSetup(entry: ..., stop: ...)
enumA fixed set of choicesBias.long

Values that might be missing

Anything that can fail to produce a value has a type ending in ?. You cannot use it until you have dealt with the empty case, which is what stops a strategy reading a value that was never there.

let pos = positions.byTicket(12345)     // Position?

if pos is none {
  log.warn("That position is gone")
  return
}

let profit = positions.byTicket(12345)?.profit ?? 0
//                                    ^ only if present
//                                              ^ otherwise use this

Converting

text(1.234)          // "1.234"
text.parseNum("1.5") // 1.5
int(3.9)             // 3   (truncates)
num(3)               // 3.0
lots(0.137)          // snaps to the instrument step, e.g. 0.13
price(1.084523)      // rounds to the instrument digits, e.g. 1.08452

↑ Back to top

Series, the one idea to learn properly

Start here

A series is a value that has one reading per candle. The closing price is a series. A moving average is a series. Whether this candle is green is a series. Almost everything you touch in a strategy is one.

You read a series with square brackets, and the index counts backwards in time. Always. There is no setting to reverse it.

close[0]     // this candle's close, the one still forming
close[1]     // the candle before it
close[10]    // ten candles ago

Series do arithmetic

Add, subtract, compare or combine two series and you get another series, computed candle by candle. You never write a loop for this.

let gap     = close - open        // series<num>: the body of each candle
let bullish = close > open        // series<bool>: was each candle green
let body    = abs(close - open)
let strong  = body > atr(14) * 0.8

if strong[0] && bullish[0] {
  log.info("Big green candle")
}
The rule: an expression made of series is a series. You only add [0] at the moment you need one actual number, usually inside an if.

The built-in series

The candles of whatever instrument and timeframe the bot is running on are always available by name, with no setup:

Series What it holds
open high low closeThe four prices of each candle
timeThe opening time of each candle
volumeTick volume of each candle
spreadThe spread recorded on each candle
hl2 hlc3 ohlc4The usual average prices, ready made

Looking at another instrument or timeframe

let daily      = series.of("EURUSD", D1)
let dailyTrend = ema(daily.close, 50)
let gold       = series.of("XAUUSD").close

if close[0] > dailyTrend[0] {
  log.info("Above the daily trend")
}

Timeframe names are M1 M2 M3 M4 M5 M6 M10 M12 M15 M20 M30 H1 H2 H3 H4 H6 H8 H12 D1 W1 MN1.

Reaching across candles

highest(high, 20)[0]      // highest high of the last 20 candles
lowest(low, 20)[0]        // lowest low
highestBar(high, 20)[0]   // how many candles back that high was
sum(volume, 10)[0]        // volume over ten candles
avg(close, 20)[0]         // same as sma(close, 20)[0]

// how far into the 20-candle range we are, 0 to 1
let position = (close - lowest(low, 20)) / (highest(high, 20) - lowest(low, 20))

Series are worked out only when asked

A series is lazy and cached. Writing let e = ema(close, 200) at the top of a file costs nothing until something reads it, and reading it twice on the same candle only computes it once. Declare your indicators at file level and use them freely.

let fast = ema(close, 12)
let slow = ema(close, 26)
let rs   = rsi(close, 14)

fn onBar() {
  if fast[0] > slow[0] && rs[0] < 70 { ... }   // no recomputation
}

Series against plain numbers

A series and a single number are different things, and mixing them up is the mistake beginners make most. The compiler catches it.

let e = ema(close, 50)

if e > 1.08     { }    // compile error: that compares a series to a number
if e[0] > 1.08  { }    // correct: compares this candle's value

↑ Back to top

Variables and constants

let  x = 10          // cannot be changed after this line
var  y = 10          // can be changed
const MAX = 100      // fixed at compile time, usable in input limits

let  z: num = 10          // say the type when it is not obvious
var  buf: array<num> = []

let is the default and reassigning one is a compile error. Reach for var only when a value genuinely has to change.

Where a variable lives

Declared Lives for Use it for
Inside a functionThat one callWorking values
At file level with letThe life of the botIndicators and settings worked out once
At file level with varThe life of the botState that must survive between candles
In storeForever, across restartsState that must survive a redeploy or a machine move
var tradesToday = 0
var lastEntry: time?

fn onBar() {
  if clock.startOfDay(now) > clock.startOfDay(lastEntry ?? now) {
    tradesToday = 0            // a new day, reset the counter
  }
}
A bot in the cloud can be moved between machines or restarted after a deploy. A var starts again from its default when that happens. Anything that must genuinely outlive the process belongs in store.

↑ Back to top

Operators

Group Operators Notes
Arithmetic+ - * / % **** is power. % is remainder.
Comparison== != &lt; &lt;= &gt; &gt;=Work on numbers, text, time and duration.
Logical&amp;&amp; || !Short-circuit: the right side is skipped when the answer is already known.
Choicecond ? a : bThe short form of an if.
Fallbacka ?? bUse b when a is missing.
Range0..10 0..=10Up to ten, and up to and including ten.
Assign= += -= *= /= %=
Access. [ ] ( )Field, index, call.
Pipe|&gt;x |> f(y) is the same as f(x, y).

Every operator lifts over series

let norm = (close - lowest(low, 20)) / (highest(high, 20) - lowest(low, 20))
// norm is a series, computed candle by candle. No loop was written.

Two rules worth remembering

  • Dividing one int by another gives an int and throws away the remainder. Write num(a) / b when you want decimals.
  • Dividing by zero gives nothing rather than crashing, so ?? handles it in one character: let r = a / b ?? 0.

The pipe

Useful when a value passes through several steps and the nesting would read backwards.

let s = close[0] |> round(2) |> text() |> text.pad(8)
// the same as: text.pad(text(round(close[0], 2)), 8)

↑ Back to top

Control flow

Choosing

if spread > 20 {
  log.warn("Spread too wide")
} else if spread > 10 {
  log.info("Spread is workable")
} else {
  trade.buy(0.1)
}

match bias {
  .long  => trade.buy(volume)
  .short => trade.sell(volume)
  .flat  => {}
}

if and match are also expressions, so they can produce a value:

let size = if account.equity > 10_000 { 1.0 } else { 0.5 }

Repeating

for i in 0..10       { ... }    // 0 to 9
for i in 0..=10      { ... }    // 0 to 10
for pos in positions.mine() { ... }
for key, value in myMap     { ... }

while spread > 20 { ... }       // still bounded by the step budget

break        // leave the loop
continue     // skip to the next turn
There is no way to write a loop that hangs the platform. Every event runs under a step budget, and a loop that exceeds it is stopped and written to the bot's journal. See permissions and limits.

Blocks made for trading

Four things every strategy needs, which in other languages you have to build by hand out of static variables and timestamps.

once {
  log.info("This runs on the very first tick and never again")
}

atNewBar {
  recount()          // the first tick of each new candle
}

every(5m) {
  checkNews()        // throttled by the wall clock, not by candles
}

guard positions.count() < 3 else { return }
guard symbol.sessionOpen  else { return }

Why guard instead of if

A guard states the condition you need and exits when it fails, so the real work stays at the left margin instead of drifting right inside three levels of nesting. The else block has to leave the function, so a guard can never fall through by accident.

// nested, and getting harder to read with each rule
fn onBar() {
  if positions.count() == 0 {
    if symbol.sessionOpen {
      if spread < 20 {
        trade.buy(volume)
      }
    }
  }
}

// the same rules, flat
fn onBar() {
  guard positions.count() == 0 else { return }
  guard symbol.sessionOpen      else { return }
  guard spread < 20             else { return }
  trade.buy(volume)
}

↑ Back to top

Functions

fn pipsBetween(a: price, b: price) -> num {
  return abs(a - b) / symbol.pip
}

fn quiet() { log.debug("no return type needed") }

fn half(x: num) -> num => x / 2      // one expression, no braces

Default values and named arguments

fn openTrade(volume: lots, stopPips: int = 200, comment: text = "") {
  trade.buy(volume, sl: symbol.ask - stopPips * symbol.pip, comment: comment)
}

openTrade(0.1)
openTrade(0.1, stopPips: 300)
openTrade(0.1, comment: "breakout")

Named arguments are what make the trading API readable. trade.buy(0.1, sl: 1.0800, tp: 1.0900) cannot be got the wrong way round, and it still reads correctly a year later.

What gets copied and what does not

Numbers, text, booleans, times and durations are passed by value: the function gets its own copy. Arrays, maps and structs are passed by reference: the function sees the same object you do, and changing it changes yours. There are no pointers and no address operator.

Functions as values

fn applyToAll(f: fn(Position) -> void) {
  for pos in positions.mine() { f(pos) }
}

applyToAll((pos) => trade.breakEven(pos, offsetPips: 5))

Making a function public

In a library, pub marks what other files may import. Everything else stays private to the file.

pub fn positionSize(percent: num, stopPips: int) -> lots { ... }

↑ Back to top

Structs and enums

A struct groups related values under one name. Build one by calling its name with named arguments.

struct Setup {
  entry:  price
  stop:   price
  target: price
  score:  num
}

let s = Setup(
  entry:  symbol.ask,
  stop:   symbol.ask - 20 * symbol.pip,
  target: symbol.ask + 60 * symbol.pip,
  score:  0.8)

log.info("Risking " + text(pipsBetween(s.entry, s.stop)) + " pips")

An enum is a fixed set of names. Inside a match you can drop the enum name and write just the dot.

enum Bias { long, short, flat }

fn currentBias() -> Bias {
  if close[0] > ema(close, 200)[0] { return Bias.long }
  if close[0] < ema(close, 200)[0] { return Bias.short }
  return Bias.flat
}

match currentBias() {
  .long  => trade.buy(volume)
  .short => trade.sell(volume)
  .flat  => {}
}
A match over an enum has to cover every case. Add a name to the enum later and the compiler tells you which matches now have a hole in them.

↑ Back to top

When things go wrong

Catching a failure you expect

try {
  let r = http.get("https://licence.mysite.com/verify")
  log.info("Licence server said " + text(r.status))
} catch e {
  log.warn("Licence server unreachable: " + e.message)
}

What happens to one you did not

A runtime error inside an event handler does not stop the bot. The runtime catches it, writes it to the bot's journal with the file and line, and carries on with the next event.

Three errors in a row on the same line is treated differently: the bot is stopped and the trader is notified. A robot that quietly does nothing while its owner believes it is working is worse than one that stops.

Refusing to start

onInit can return a status. Failing there stops the bot before it can place a single order, which is where a bad licence key or a missing setting belongs.

fn onInit() -> status {
  if account.currency != "USD" {
    return status.failed("This bot only runs on a USD account")
  }
  return status.ok
}

Stopping on purpose

if account.equity < 500 {
  bot.stop("Equity below the floor, refusing to trade")
}

↑ Back to top

Inputs, the settings form you never write

No form code

Every input line becomes one field in the form the trader fills in when installing the bot. You describe the setting; eTrader builds the control, validates it, remembers it and passes it in.

input <type> name [= default] [{ metadata }]
input group "Heading" [{ advanced: true, hint: "..." }]

A form, in eight lines

input group "Signal"
input int    fastLength  = 12   { label: "Fast EMA", min: 2, max: 400 }
input int    slowLength  = 26   { label: "Slow EMA", min: 2, max: 400 }
input source priceSource = close

input group "Risk"
input lots volume   = 0.10 { min: 0.01, max: 100, step: 0.01 }
input int  stopPips = 200  { label: "Stop loss (pips)" }

Every input type

Type The trader sees
int numA number field, with the minimum, maximum and step you set
boolA switch
textA text field
lotsA volume field, snapped to the instrument's step size
priceA price field, at the instrument's number of digits
timeA date and time picker
durationA duration field
colorA colour swatch
symbolAn instrument picker, filled in live by the platform
timeframeA timeframe picker, M1 through MN1
sourceA price source picker: close, open, high, low, hl2, hlc3, ohlc4
selectA dropdown, from an enum or from an options: list
secretA masked field. Encrypted at rest and never returned by any API
modelAn AI model picker, filled in live by the platform
fileA small file upload, read back with resource.read()

Metadata keys

label, hint, min, max, step, options, group, advanced, required, dependsOn and unit.

input select style = "balanced" {
  label: "Trading style",
  options: [ "scalp": "Scalping", "balanced": "Balanced", "swing": "Swing" ]
}

input int maxSpread = 20 { label: "Max spread", unit: "points", min: 0, max: 500 }

input bool   notifyByWebhook = false
input secret webhookUrl { label: "Webhook URL", dependsOn: notifyByWebhook }

dependsOn hides a field until the field it names is switched on, so the form stays short. advanced: true on a group folds it away behind a disclosure.

Secrets

A secret input is masked in the form, encrypted where it is stored, decrypted only inside the worker running your bot, and never returned by any API, including to you. Use it for licence keys and for API keys.

input secret aiKey      { label: "AI API key", hint: "Encrypted. Never leaves the runtime." }
input secret licenceKey { label: "Licence key", required: true }
Inputs are read-only inside the bot. If you need to change a value as the bot runs, copy it into a var in onInit.

↑ Back to top

Events

A bot does nothing on its own. eTrader calls your handlers when something happens.

Bot handlers

Handler Called
fn onInit() -&gt; statusOnce at start. Return status.ok or status.failed(reason)
fn onDeinit(reason: text)Once at stop
fn onTick()On every price change
fn onBar()On the first tick of a new candle on the bot's timeframe
fn onTimer()On the interval set with timer.every(...)
fn onTrade(e: TradeEvent)On any change to orders, positions or deals
fn onFilled(d: Deal)When an order belonging to this bot fills
fn onClosed(p: ClosedPosition)When a position belonging to this bot closes
fn onWebhook(m: Webhook)On an inbound HTTP call to this bot instance

onTick or onBar

onBar is the right default. It runs once per candle, so the same signal cannot fire twice, and a strategy built on candle closes behaves the same in a backtest as it does live. Use onTick only when you genuinely need every price change, such as managing a trailing stop.

fn onBar() {
  if crossesOver(fast, slow) { trade.buy(volume) }   // once per candle
}

fn onTick() {
  for pos in positions.mine() {
    trade.trail(pos, distancePips: 40, stepPips: 5)   // needs every tick
  }
}

Reacting to your own fills

fn onFilled(d: Deal) {
  notify("Filled", d.symbol + " " + text(d.volume) + " at " + priceText(d.price))
}

fn onClosed(p: ClosedPosition) {
  store.set("lastResult", p.profit > 0 ? "win" : "loss")
  log.info("Closed for " + money(p.profit))
}

A timer

fn onInit() -> status {
  timer.every(5m)
  return status.ok
}

fn onTimer() {
  log.info("Equity " + money(account.equity))
}

Indicator handlers

Handler Called
fn onInit() -&gt; statusOnce
fn onCalculate(b: Bars) -&gt; intWhen candles change. Return how many you computed
fn onChartEvent(e: ChartEvent)On a click, drag, key press or panel button
fn onDeinit(reason: text)On removal from the chart
Most indicators need none of these. A plot line on its own is a complete indicator. See writing an indicator.

↑ Back to top

Placing trades

One call per order

Market orders

trade.buy(0.10)
trade.sell(0.10)

trade.buy(0.10,
  sl:       symbol.ask - 40 * symbol.pip,
  tp:       symbol.ask + 90 * symbol.pip,
  comment:  "breakout",
  magic:    1234,
  slippage: 3,
  symbol:   "EURUSD")

Only the volume is required. Every other argument is named and optional.

Pending orders

trade.buyLimit(1.0800, 0.10, sl: 1.0750, tp: 1.0900)
trade.sellLimit(1.0900, 0.10)
trade.buyStop(1.0950, 0.10)
trade.sellStop(1.0750, 0.10)
trade.buyStopLimit(1.0950, 1.0940, 0.10)

Several take-profits at once

A take-profit ladder is native, because the eTrader engine supports one. Pass a list and the volume is split across the rungs.

trade.buy(0.30, sl: stop, tp: [ t1, t2, t3 ])

Managing what is open

trade.close(pos)
trade.closePartial(pos, 0.05)
trade.closeAll(symbol: "EURUSD")
trade.closeAll(magic: 1234)
trade.closeProfitable()
trade.closeLosing()

trade.modify(pos, sl: newStop, tp: newTarget)
trade.modifyPending(ord, price: 1.0810, expiry: now + 4h)
trade.cancel(ord)
trade.cancelAll(symbol: "EURUSD")

trade.reverse(pos)
trade.breakEven(pos, offsetPips: 5)
trade.trail(pos, distancePips: 40, stepPips: 5)

Did it work

Every trade call returns a result. Check it, because a broker can refuse an order for a dozen ordinary reasons.

let r = trade.buy(volume, sl: stop)

if !r.ok {
  log.error("Order refused: " + r.message + " (" + text(r.retcode) + ")")
  return
}

log.info("Ticket " + text(r.ticket) + " filled at " + priceText(r.price))

The result carries ok, ticket, price, volume, error, retcode and message. Every call is written to the bot's journal with the instance id, so a trader can always see which bot opened which position.

Checking before you send

let margin = trade.calcMargin("buy", symbol.name, volume, symbol.ask)

guard margin < account.freeMargin * 0.5 else {
  log.warn("Not enough free margin, skipping")
  return
}

↑ Back to top

Positions, orders and history

What is open now

positions.all()                    // every position on the account
positions.mine()                   // only the ones this bot opened
positions.count()                  // how many, in total
positions.count("EURUSD")          // how many on one instrument
positions.bySymbol("EURUSD")
positions.byTicket(12345)          // Position?
positions.byMagic(1234)
positions.profit()                 // sum of open profit
positions.volume("EURUSD")
positions.longVolume()  positions.shortVolume()
positions.oldest()      positions.newest()

positions.mine() is the one to reach for. It returns only what this bot instance opened, so two bots on the same account never touch each other's trades.

What a position holds

Position {
  ticket, symbol, type, volume,
  openPrice, currentPrice, sl, tp,
  profit, swap, commission,
  openTime, magic, comment, botId
}

Pending orders

orders.all()   orders.mine()   orders.count()
orders.bySymbol("EURUSD")   orders.byTicket(12345)   orders.byMagic(1234)

What already closed

let since  = clock.startOfDay(now)
let closed = history.positions(since, now)
let today  = history.profit(since, now)

log.info(text(closed.len) + " trades today for " + money(today))

for deal in history.deals(now - 7d, now) {
  log.debug(deal.symbol + " " + money(deal.profit))
}

A worked example: one position, trailed

input lots volume   = 0.10
input int  trailPips = 40
input int  stepPips  = 5

fn onBar() {
  guard positions.mine().len == 0 else { return }
  guard crossesOver(ema(close, 12), ema(close, 26)) else { return }

  trade.buy(volume, sl: symbol.ask - 60 * symbol.pip)
}

fn onTick() {
  for pos in positions.mine() {
    if pos.profit > 0 {
      trade.trail(pos, distancePips: trailPips, stepPips: stepPips)
    }
  }
}

↑ Back to top

The account and the instrument

The account

account.balance      account.equity      account.profit
account.margin       account.freeMargin  account.marginLevel
account.currency     account.leverage    account.isDemo
account.login        account.name        account.server
account.company      account.credit      account.hedging
account.marginCall   account.stopOut     account.tradeAllowed

The instrument

symbol.name          symbol.bid          symbol.ask
symbol.last          symbol.spread       symbol.digits
symbol.point         symbol.pip          symbol.tickSize
symbol.tickValue     symbol.contractSize symbol.stopsLevel
symbol.volumeMin     symbol.volumeMax    symbol.volumeStep
symbol.swapLong      symbol.swapShort    symbol.freezeLevel
symbol.tradeAllowed  symbol.sessionOpen  symbol.time
symbol.high          symbol.low                     // today's range
Point or pip. symbol.point is the smallest price step. symbol.pip is what a trader means by a pip, which on a five-digit or three-digit quote is ten points. Use symbol.pip for stops and targets and the same file works on EURUSD, on USDJPY and on gold.

Rounding correctly

symbol.normalize(1.084523)    // 1.08452, at the instrument's digits
symbol.normalizeLots(0.137)   // 0.13, snapped to the volume step

Other instruments

symbols.list()                 // every instrument you can trade
symbols.info("XAUUSD")
symbols.select("XAUUSD", true)
symbols.tick("XAUUSD")
symbols.book("EURUSD")         // depth of market, where the broker provides it

Trading hours

guard symbol.sessionOpen else { return }
guard !clock.isWeekend(now) else { return }
guard clock.inSession(08:00, 17:00) else { return }

↑ Back to top

Sizing a trade by risk

Worked example

This is the calculation every serious strategy needs and the one most beginners get wrong. Risk a fixed percentage of equity, and let the stop distance decide the volume.

input num percentRisk = 1.0 { label: "Risk per trade (%)", min: 0.1, max: 5 }
input int stopPips    = 200

fn positionSize(percent: num, stopPips: int) -> lots {
  let riskMoney = account.equity * percent / 100
  let perPip    = symbol.tickValue * (symbol.pip / symbol.tickSize)
  let raw       = riskMoney / (stopPips * perPip)

  return lots(clamp(raw, symbol.volumeMin, symbol.volumeMax))
}

fn onBar() {
  guard positions.mine().len == 0 else { return }
  guard crossesOver(ema(close, 12), ema(close, 26)) else { return }

  let volume = positionSize(percentRisk, stopPips)

  log.info("Risking " + money(account.equity * percentRisk / 100)
         + " over " + text(stopPips) + " pips, so " + lotsText(volume))

  trade.buy(volume,
    sl: symbol.ask - stopPips * symbol.pip,
    tp: symbol.ask + stopPips * 2 * symbol.pip)
}

Reading it back: turn the percentage into money, work out what one pip is worth on this instrument at one lot, divide, then clamp to what the broker will accept. Returning lots(...) snaps the answer to the volume step, so the broker never rejects the size.

Put this function in a library and import it into every bot you write. See libraries and imports.

↑ Back to top

Writing an indicator

An indicator draws on a chart. The same language writes it, and simple ones need no logic at all.

indicator "Triple EMA" { panel: overlay }

input int a = 8
input int b = 21
input int c = 55

plot ema(close, a) { title: "Fast",   color: #3d7bf5 }
plot ema(close, b) { title: "Medium", color: #e8a33d }
plot ema(close, c) { title: "Slow",   color: #d0342c, width: 1.6 }

That is the whole file. There is no calculation loop, no buffer to size, no initialisation and no cleanup. Each plot takes a series and draws it.

Its own pane, and a scale

indicator "RSI with bands" {
  panel: separate
  scale: fixed(0, 100)
  precision: 1
}

input int length = 14

plot rsi(close, length) { title: "RSI", color: #3d7bf5, width: 1.4 }

hline(70) { color: #d0342c, style: dashed }
hline(50) { color: #86868b, style: dotted }
hline(30) { color: #1a7f37, style: dashed }

When you do need logic

Write onCalculate and fill buffers by hand. Return how many candles you computed.

indicator "Custom band" { panel: overlay }

buffer upper
buffer lower

fn onCalculate(b: Bars) -> int {
  let basis = sma(close, 20)
  let width = atr(14) * 1.5

  upper[0] = basis[0] + width[0]
  lower[0] = basis[0] - width[0]

  return b.count
}
Buffers exist mainly so a bot can read your indicator with custom("myind.eti").buffer(0). If nothing else needs to read it, a plain plot is simpler and faster.

Reacting to the chart

fn onChartEvent(e: ChartEvent) {
  match e.kind {
    .click => log.info("Clicked at " + priceText(e.price))
    .key   => if e.key == "r" { chart.redraw() }
    .drag  => {}
  }
}

↑ Back to top

Plots, styles and colour

plot <series> { title:, color:, width:, style:, panel:, visible:, precision: }

Styles

line stepline area histogram columns dots cross arrows candles bars zigzag section fill none

Colour that changes with the value

Pass a series to color: and every candle is coloured on its own reading.

let r = rsi(close, 14)

plot r {
  title: "RSI",
  color: r[0] > 70 ? #d0342c : r[0] < 30 ? #1a7f37 : #3d7bf5
}

Lines, bands and shading

let bb = bands(close, 20, 2)

plot bb.upper { title: "Upper", color: #3d7bf5 }
plot bb.basis { title: "Basis", color: #86868b, style: dashed }
plot bb.lower { title: "Lower", color: #3d7bf5 }

fill(bb.upper, bb.lower, color: #3d7bf522)

hline(0) { color: #86868b, style: dotted }
vline(clock.startOfDay(now)) { color: #86868b }

An indicator that returns several series

Some indicators produce more than one line. They return a struct, and you read the parts by name.

let m = macd(close, 12, 26, 9)

plot m.line      { title: "MACD",   color: #3d7bf5 }
plot m.signal    { title: "Signal", color: #e8a33d }
plot m.histogram { title: "Hist",   style: histogram,
                   color: m.histogram[0] > 0 ? #1a7f37 : #d0342c }

↑ Back to top

Drawing on the chart

Everything a trader can draw by hand, a script can draw too. Each call takes its anchors, then a block of options.

draw.trendline("t1", time[20], low[20], time[0], low[0],
  color: #3d7bf5, width: 1.4, ray: true)

draw.rect("zone", time[30], 1.0850, time[0], 1.0880,
  color: #3d7bf522, fill: true, back: true)

draw.arrowUp("entry", time[0], low[0] - 10 * symbol.pip, color: #1a7f37)

draw.label("hud", "Trend up", corner: topLeft, x: 12, y: 12,
  color: #1a7f37, size: 11)

draw.text("note", time[5], high[5], "Failed breakout", color: #d0342c)

Everything available

hline vline trendline ray channel regression stddevChannel pitchfork fibo fiboFan fiboArc fiboTimes fiboChannel expansion gannLine gannFan gannGrid cycles elliott3 elliott5 rect triangle ellipse arrow arrowUp arrowDown arrowCheck arrowStop arrowThumb arrowPrice text label button edit bitmap rectLabel, each under draw.

Managing what you drew

draw.move("t1", 1, time[0], close[0])
draw.set("t1", "color", #d0342c)
draw.get("t1")
draw.find("t1")
draw.count()
draw.delete("t1")
draw.deleteAll(prefix: "zone")
Give every object an id you choose, as the first argument. Redrawing with the same id updates the object instead of stacking a second one on top of it, which is what makes a chart slow.

↑ Back to top

Panels on the chart

Building a heads-up display out of rectangles and labels, positioning each one by hand, is a day of work in most trading languages. Tide has a panel block.

panel "Risk" {
  at: topRight, width: 220, theme: auto

  row { label("Equity");    value(money(account.equity)) }
  row { label("Open risk"); value(pct(openRisk),
                                  color: openRisk > 2 ? red : green) }
  row { label("Today");     value(money(history.profit(
                                  clock.startOfDay(now), now))) }
  separator()

  button("Close all",  onPress: () => trade.closeAll())
  button("Break even", onPress: beAll, enabled: positions.count() > 0)
  slider("Lots", bind: riskLots, min: 0.01, max: 5, step: 0.01)
  toggle("Auto-trail", bind: autoTrail)
}

Panels follow the terminal's own theme in both light and dark, so a panel written once looks correct for every trader without a colour setting.

Pieces you can put in a panel

Element What it is
row { ... }A line, laid out left to right
label(text)Static text
value(text, color:)A value, right-aligned, recomputed live
separator()A dividing line
button(text, onPress:, enabled:)A button that calls your function
slider(text, bind:, min:, max:, step:)A slider bound to a var
toggle(text, bind:)A switch bound to a var

at: takes topLeft, topRight, bottomLeft or bottomRight.

↑ Back to top

The built-in indicators

45 built in

Forty-five indicators, each one an expression. No handles, no buffer copying, no release call. Every one returns a series, or a struct of series where it naturally has more than one line.

Trend

Call Returns
ma(src, len, type)series
sma(src, len)series
ema(src, len)series
smma(src, len)series
lwma(src, len)series
dema(src, len)series
tema(src, len)series
ama(src, len, fast, slow)series
frama(src, len)series
vidya(src, cmo, ema)series
sar(step, max)series
ichimoku(t, k, s){tenkan, kijun, senkouA, senkouB, chikou}
alligator(...){jaw, teeth, lips}
envelopes(src, len, type, dev){upper, lower}

Oscillators

Call Returns
rsi(src, len)series
macd(src, fast, slow, signal){line, signal, histogram}
osma(src, fast, slow, signal)series
stoch(k, d, slowing){k, d}
cci(src, len)series
williamsR(len)series
momentum(src, len)series
roc(src, len)series
demarker(len)series
rvi(len){main, signal}
trix(src, len)series
ao() ac()series
bearsPower(len) bullsPower(len)series
gator(...){upper, lower}
adx(len) adxWilder(len){main, plusDi, minusDi}
chaikin(fast, slow, type)series
force(len, type)series

Volatility and volume

Call Returns
atr(len)series
trueRange()series
stddev(src, len)series
bands(src, len, dev){upper, basis, lower}
volumes()series
obv() ad()series
mfi(len) bwmfi()series
fractals(){up, down}

Your own, or someone else's

let band = custom("myind.eti", 20, 2.0).buffer(0)

↑ Back to top

Series helpers

The small functions a strategy reaches for constantly, so you never write the loop yourself.

Call What it gives you
crossesOver(a, b)True on the candle where a moves above b
crossesUnder(a, b)True on the candle where a moves below b
rising(s, n) falling(s, n)True when the series rose or fell for n candles
changed(s)True when the value differs from the previous candle
barsSince(cond)How many candles since the condition was last true
valueWhen(cond, s, n)The value of s the nth time the condition was true
highest(s, n) lowest(s, n)Highest and lowest over n candles
highestBar(s, n) lowestBar(s, n)How many candles back that extreme was
sum(s, n) avg(s, n) cum(s)Running totals and averages
stdev(s, n) correlation(a, b, n)Dispersion and correlation
percentRank(s, n)Where this reading sits in the last n, 0 to 100
linreg(s, n)Linear regression value
pivotHigh(n, m) pivotLow(n, m)Swing points with n candles either side
// entered more than 20 candles ago and still going
if barsSince(crossesOver(fast, slow))[0] > 20 { ... }

// the close on the candle the last cross happened
let entryPrice = valueWhen(crossesOver(fast, slow), close, 0)

// today's range as a percentage of the 20-day average range
let ratio = (high[0] - low[0]) / avg(high - low, 20)[0]

↑ Back to top

Maths

abs sign min max clamp round(x, digits) floor ceil trunc sqrt cbrt pow(x, y) exp ln log10 log2 sin cos tan asin acos atan atan2 sinh cosh tanh hypot mod isNaN isFinite

Random, and why it is repeatable

rand()            // 0 to 1
randInt(1, 6)
seed(42)

The generator is seeded per bot instance and the seed is recorded, so a backtest replays exactly the same sequence. A strategy that uses randomness is still reproducible.

Statistics over an array

sum(values)   mean(values)   median(values)   stdev(values)
variance(values)   percentile(values, 90)
normalize(x, lo, hi)   lerp(a, b, t)

Constants: PI E INF EPSILON INT_MAX INT_MIN.

The natural logarithm is ln, not log, because log.info(...) writes to the journal. The compiler refuses to build if any two built-in names ever collide.

↑ Back to top

Text

text(x) turns anything into text. After that:

text.len        text.upper      text.lower
text.trim       text.trimLeft   text.trimRight
text.find(sub)  text.contains(sub)
text.startsWith(sub)   text.endsWith(sub)
text.replace(a, b)     text.replaceAll(a, b)
text.split(sep)        text.join(array, sep)
text.slice(a, b)       text.charAt(i)      text.code(i)
text.fromCode(n)       text.repeat(n)      text.reverse
text.pad(n, ch)        text.padLeft(n, ch)
text.format(fmt, args...)
text.compare(a, b)
text.parseNum   text.parseInt   text.parseTime
text.parseJson  text.toJson(value)

Formatting for a human

These are the helpers the eTrader apps themselves use, so your output matches the rest of the platform.

Call Gives
money(1234.5)"1,234.50 USD", in the account currency
pips(43.2)A pip count, formatted
pct(1.75)A percentage
priceText(1.084523)The price at the instrument's digits
lotsText(0.1)A volume at the instrument's step
log.info("Equity " + money(account.equity)
       + ", open " + text(positions.count())
       + " for " + money(positions.profit()))

JSON

let r    = http.json("https://api.example.com/signal")
let bias = r["bias"]                       // "long"
let body = text.toJson({ "symbol": symbol.name, "action": "buy" })

↑ Back to top

Time and dates

time is the series of candle opening times, so time[1] means the previous candle's time, which is what a trader expects. The clock therefore lives under clock.
now              // this moment
clock.local      clock.gmt      clock.server
clock.gmtOffset  clock.dst

Taking a time apart

let t = now
t.year   t.month   t.day
t.hour   t.minute  t.second
t.dayOfWeek        t.dayOfYear

t.format("yyyy-MM-dd HH:mm")

Arithmetic

now + 4h
now - 30m
clock.startOfDay(now)
clock.startOfWeek(now)
clock.startOfMonth(now)
clock.isWeekend(now)
clock.inSession(08:00, 17:00)
clock.of(2026, 9, 11, 14, 30, 0)
clock.parse("2026-09-11", "yyyy-MM-dd")

A trading window

input time from = 08:00
input time to   = 17:00

fn onBar() {
  guard !clock.isWeekend(now) else { return }
  guard clock.inSession(from, to) else { return }
  guard now - (lastEntry ?? now - 1d) > 4h else { return }
  ...
}

↑ Back to top

Arrays and maps

Arrays

var levels: array<price> = []

levels.push(1.0850)
levels.push(1.0900)

levels.len          levels.pop()       levels.shift()
levels.unshift(x)   levels.insert(i, x)
levels.remove(i)    levels.clear()     levels.resize(n)
levels.fill(x)      levels.copy()      levels.slice(a, b)
levels.reverse()    levels.sort()      levels.sortBy(fn)
levels.indexOf(x)   levels.contains(x)
levels.min()        levels.max()       levels.minIndex()
levels.maxIndex()   levels.sum()
levels.map(fn)      levels.filter(fn)  levels.reduce(fn, init)
levels.find(fn)     levels.any(fn)     levels.all(fn)
levels.join(", ")   levels.bsearch(x)
let wins = history.positions(now - 30d, now)
             .filter((t) => t.profit > 0)

let total = history.positions(now - 30d, now)
              .map((t) => t.profit)
              .reduce((a, b) => a + b, 0)

log.info(text(wins.len) + " winners, " + money(total) + " net")

Maps

var lastEntryBySymbol: map<text, time> = {}

lastEntryBySymbol.set("EURUSD", now)

if lastEntryBySymbol.has("EURUSD") {
  let t = lastEntryBySymbol.get("EURUSD")
}

lastEntryBySymbol.len       lastEntryBySymbol.delete("EURUSD")
lastEntryBySymbol.clear()   lastEntryBySymbol.keys()
lastEntryBySymbol.values()  lastEntryBySymbol.entries()

for sym, t in lastEntryBySymbol {
  log.debug(sym + " last entered " + t.format("HH:mm"))
}

↑ Back to top

Remembering things

A var lives as long as the process. A bot in the cloud can be restarted after a deploy or moved to another machine, and a var starts again at its default when that happens. Anything that must genuinely survive belongs in store.

store.set("tradesToday", 3)
store.set("lastSignal", now)

let n = store.get("tradesToday") ?? 0

store.delete("lastSignal")
store.keys()

store is private to one bot instance and survives restarts, redeploys and machine moves. globals works the same way but is shared across the whole account, so two bots can coordinate.

globals.set("riskOff", true)

// in another bot
guard !(globals.get("riskOff") ?? false) else { return }

Files packed into the bundle

input file symbolList

fn onInit() -> status {
  let contents = resource.read(symbolList)
  ...
}
There is no filesystem API. A cloud runtime that lets bots write files is a runtime that leaks between tenants, so store replaces it completely.

↑ Back to top

Logging, alerts and notifications

Call Where it goes
log.info(text)The bot's journal, which the trader can read
log.warn(text) log.error(text)The journal, flagged
log.debug(text)The journal, only when debug logging is on
alert(text)A pop-up in the terminal
notify(title, body)A push notification on the trader's phone
email(subject, body)The trader's email
webhook(url, payload)An address you declared in permissions
chart.comment(text)The corner of the chart
sound(name)A sound in the terminal
fn onFilled(d: Deal) {
  notify("Golden Cross filled",
    d.symbol + " " + lotsText(d.volume) + " at " + priceText(d.price))

  log.info("Ticket " + text(d.ticket) + " on " + d.symbol)
}
Everything a bot logs is stamped with the bot instance id and kept in that bot's own journal, so a trader running six bots can always see which one said what.

↑ Back to top

Calling a web service

A bot may reach the network, but only addresses it declared up front. The trader is shown that list before they install anything.

permissions {
  network "licence.mysite.com", "api.anthropic.com"
  maxOrdersPerMinute 4
}
let r = http.get("https://api.example.com/signal", timeout: 5s)

if r.ok {
  let data = r.json()
  log.info("Bias is " + data["bias"])
}

http.post("https://api.example.com/report",
  json: { "equity": account.equity, "open": positions.count() })

let quick = http.json("https://api.example.com/signal")   // get and parse

http.get http.post http.put http.patch http.delete http.json, each taking headers: and timeout:. A response carries ok, status, body, headers and json().

What is blocked, and why

  • Any host not in the manifest is refused outright.
  • Private ranges, link-local and loopback addresses are blocked, and a redirect is re-checked against the same rules.
  • A per-bot rate limit applies.
  • Indicators cannot call out at all. They run on the trader's own machine, on a chart.
  • Raw sockets are not offered. A cloud runtime sharing a machine with a live trading engine does not hand those out.
Always wrap a network call in try. A service that is up today will be down one morning, and a bot that stops trading because a web request threw is a bot that failed for the wrong reason.

↑ Back to top

Asking an AI

ai.ask(model, prompt, system:, key:, maxTokens:, temperature:) -> text
ai.json(model, prompt, schema:, key:) -> any
ai.embed(model, text, key:) -> array<num>
ai.models() -> array<text>

A filter on top of a signal

input bool   useAi   = false { label: "Ask an AI before entering" }
input model  aiModel = "claude-opus-5"
input secret aiKey   { label: "AI API key" }

fn aiAgrees() -> bool {
  let answer = ai.ask(aiModel, key: aiKey, maxTokens: 8,
    system: "Answer with exactly YES or NO.",
    prompt: "EURUSD just crossed its fast EMA above the slow EMA. RSI is "
          + text(round(rsi(close, 14)[0], 1))
          + ". Is this a reasonable long entry? YES or NO.")

  return text.upper(text.trim(answer)).startsWith("YES")
}

fn onBar() {
  guard crossesOver(fast, slow) else { return }
  guard !useAi || aiAgrees()    else { return }
  trade.buy(volume)
}

The model comes from an input model, so the trader picks it from a live list. The key comes from an input secret and is decrypted only inside the worker running the bot. If the trader leaves the key blank, the platform's own endpoint is used against a per-account quota.

An AI call takes time and costs money on every candle it runs. Put it behind the cheap checks, as in the example above, so it only runs when a signal has already fired.

↑ Back to top

Selling what you write

A compiled .etb is bytecode, not source. If you sell bots, you can also bind each copy to one account.

permissions { network "licence.mysite.com" }

input secret licenceKey { label: "Licence key", required: true }

fn onInit() -> status {
  let lic = licence.check(licenceKey, at: "https://licence.mysite.com/verify")

  if !lic.valid {
    return status.failed("Licence rejected: " + lic.reason)
  }

  log.info("Licensed to " + lic.holder + ", expires " + text(lic.expires))
  licence.offlineGrace(7d)
  return status.ok
}

The result carries valid, reason, holder, expires, plan and meta.

What the platform does for you

  • The request is signed with the bundle's creator key and a per-install fingerprint, so your endpoint can bind a key to one account and spot a replay.
  • licence.fingerprint gives you a stable id for the install.
  • licence.offlineGrace(7d) keeps a paying customer trading through an outage on your own server.
Failing the check in onInit is the right place. The bot never starts, so it cannot place a single order on an unlicensed copy.

↑ Back to top

Libraries and imports

Write a function once and use it in every bot.

// risk.tidelib
library "Risk" { version "1.0" }

pub fn positionSize(percent: num, stopPips: int) -> lots {
  let riskMoney = account.equity * percent / 100
  let perPip    = symbol.tickValue * (symbol.pip / symbol.tickSize)
  return lots(riskMoney / (stopPips * perPip))
}

pub fn openRiskPercent() -> num {
  var total = 0.0
  for pos in positions.mine() {
    total += abs(pos.openPrice - pos.sl) * pos.volume
  }
  return total / account.equity * 100
}
// in your bot
import "risk.tidelib" as risk
import { positionSize } from "sizing.tidelib"

let v = risk.positionSize(percent: 1.0, stopPips: 200)
guard risk.openRiskPercent() < 5 else { return }

Only pub declarations are visible outside the library. Everything else stays private to the file.

A library is compiled into the bundle that imports it. There is no runtime linking and no dynamic loading, so an .etb you hand to somebody always contains everything it needs.

↑ Back to top

Permissions and limits

Every bundle declares what it wants, and the trader sees that list in plain words before installing: this bot wants to contact licence.mysite.com and api.anthropic.com, and may place up to 4 orders a minute.

permissions {
  network            "licence.mysite.com", "api.anthropic.com"
  maxOrdersPerMinute 4
}

What the runtime enforces

Limit In the cloud On a chart
Steps per event2,000,000500,000
Memory32 MB16 MB
History depth100,000 candles20,000 candles
Web requests per minute300, indicators cannot call out
Orders per minuteAs declared, capped at 60Not applicable
Time per event5 s100 ms

Going over a limit stops that one event and writes a line to the journal. It does not stop the bot unless it keeps happening.

What a bot can never do

  • See another bot, or its positions, or its storage.
  • Read or write the filesystem.
  • Reach any network address not in its own manifest.
  • Run native code, load a DLL, or open a raw socket.

Why the same run repeats

Execution is deterministic. The same bytecode, the same inputs and the same candles produce the same trades every time. rand() is seeded per instance and the seed is recorded, so a backtest reproduces a live run exactly rather than approximately.

↑ Back to top

Finding out what your bot did

The journal

Every bot has its own journal. Everything it logs, every order it sent and every error it hit is there, stamped with the file and line it came from. A failure in the cloud three weeks from now still reports ema-cross.tide:42:11, because source positions survive every stage of compilation.

Logging that is worth reading

// not this
log.info("here")

// this
log.info("Signal on " + symbol.name
       + " fast=" + text(round(fast[0], 5))
       + " slow=" + text(round(slow[0], 5))
       + " spread=" + text(symbol.spread)
       + " equity=" + money(account.equity))

Checking the budget

fn onBar() {
  heavyCalculation()
  log.debug("Used " + text(runtime.steps) + " steps in "
          + text(runtime.elapsed) + "ms")
}

Knowing where you are running

if bot.isTesting {
  log.debug("Backtest run, skipping the notification")
} else {
  notify("Filled", symbol.name)
}

bot.id        bot.name      bot.version    bot.magic
bot.isTesting bot.isOptimising
lastError     clearError()
tester.stat("profitFactor")
A runtime error is caught, journalled and survived. Three errors in a row on the same line stops the bot and tells the trader, because a robot that silently does nothing while its owner believes it is working is the worst outcome of all.

↑ Back to top

Where your code runs

Bots run in eTrader's cloud

You upload an .etb, fill in its inputs and switch it on. It keeps trading with your computer off. There is no VPS to rent and no terminal to leave running.

Bots run in a separate worker pool inside the eTrader backend, never in the process that carries the price feed and the trading engine. A runaway loop in one trader's bot cannot cost anybody else a tick. A worker that goes over its budget is killed and its bots are moved; the bot that caused it is journalled, and after three strikes it is paused and the trader is told.

Indicators run on your own machine

An .eti draws on a chart, so it runs where the chart is: in the eTrader desktop terminal for macOS.

Where each piece lives

Piece What it is Runs
eTrader CodeThe application you write Tide in, with an editor, a strategy tester, an optimiser and a debuggermacOS
An .etb botA compiled robotIn eTrader's cloud, with your app closed
An .eti indicatorA compiled chart indicatorOn the chart, in the desktop terminal for macOS and in eTrader Web
eTrader WebThe browser terminal at terminal.etraderweb.comTakes both file types. Its twelve built-in indicators are still there, and your own .eti indicators sit alongside them
One engine, four places. The Tide engine is a single core compiled for each host: it runs your bots in the cloud, your indicators on the desktop chart, your indicators in the browser (as WebAssembly), and your backtests in the tester. That is why a strategy behaves the same in all of them, and it is why there is no second implementation of the language anywhere.

Installing one

Bots and indicators are managed from the Files menu, which holds both: Indicators for your .eti files and Bots for your .etb files and the bots configured from them. Drag a file in, review the permissions it asks for, set its inputs and switch it on. Uploading a newer version of something you already have offers to replace it, keeping your settings and your running instances.

An indicator you add appears in the chart's own Indicators list under My Indicators, beside the built-in ones, with the same switch, the same eye and the same settings sheet. Its Inputs tab is generated from the inputs you declared; its Style tab lets whoever is using it recolour, rethicken, restyle or hide any plot you drew, without touching your source.

↑ Back to top

Reserved words

bot indicator library script permissions input group plot buffer panel
let var const fn return if else while for in match once every atNewBar guard
try catch import as pub struct enum true false none now and or not
break continue

Type names are reserved too: int num bool text time duration price lots color series array map secret model symbol timeframe source select file status void.

↑ Back to top

The grammar

The readable summary. The parser carries the normative version.

program      = programDecl , { permissions | importDecl | inputDecl | declaration } ;
programDecl  = ("bot"|"indicator"|"library"|"script") , string , [ metaBlock ] ;
metaBlock    = "{" , { ident , metaValue } , "}" ;
permissions  = "permissions" , "{" , { permEntry } , "}" ;
inputDecl    = "input" , ( "group" , string , [ objectLit ]
                         | type , ident , [ "=" , expr ] , [ objectLit ] ) ;
declaration  = varDecl | fnDecl | structDecl | enumDecl
             | plotDecl | bufferDecl | panelDecl ;
varDecl      = ("let"|"var"|"const") , ident , [ ":" , type ] , "=" , expr ;
fnDecl       = [ "pub" ] , "fn" , ident , "(" , [ params ] , ")" ,
               [ "->" , type ] , body ;
body         = block | "=>" , expr ;
type         = ident , [ "<" , type , { "," , type } , ">" ] , [ "?" ] ;
expr         = assignment ;
assignment   = ternary , [ assignOp , assignment ] ;
ternary      = pipe , [ "?" , expr , ":" , expr ] ;
pipe         = orExpr , { "|>" , call } ;
orExpr       = andExpr , { "||" , andExpr } ;
andExpr      = equality , { "&&" , equality } ;
equality     = comparison , { ("=="|"!=") , comparison } ;
comparison   = range , { ("<"|"<="|">"|">=") , range } ;
range        = additive , [ (".."|"..=") , additive ] ;
additive     = multiplicative , { ("+"|"-"|"??") , multiplicative } ;
multiplicative = power , { ("*"|"/"|"%") , power } ;
power        = unary , [ "**" , power ] ;
unary        = [ "!" | "-" ] , postfix ;
postfix      = primary , { "." ident | "[" expr "]" | "(" [ args ] ")" } ;
primary      = literal | ident | "(" expr ")" | arrayLit | mapLit
             | structLit | ifExpr | matchExpr ;
args         = arg , { "," , arg } ;
arg          = [ ident , ":" ] , expr ;

How a file becomes a bundle

source.tide
   |  lexer         tokens, each carrying its position in the file
   v
  AST              recursive descent, with Pratt parsing for expressions
   |  resolver      scopes, imports, name binding
   v
 typed AST         type checking, series lifting, input schema
   |  lowering      desugars every, once, atNewBar, guard, match, pipes
   v
   IR              constant folding, dead code removal
   |  codegen
   v
 bytecode          a stack machine, around 130 opcodes, plus a line table
   |  package
   v
.etb / .eti        manifest, inputs, permissions, code, resources, signature

Every stage keeps the source position, which is why an error in the cloud weeks later still names the file, the line and the column.

↑ Back to top

What Tide will not do

Some things are missing on purpose. Each one has a reason and, where a strategy genuinely needs the capability, a replacement.

Not available Why Use instead
Raw socketsNot next to a live trading enginehttp.* to declared hosts
File and folder accessA shared cloud runtime would leak between tenantsstore and globals
Loading a native libraryArbitrary native code in the cloudNothing. This one stays closed
GPU and graphics APIsThere is no GPU in the runtimeNothing
A SQL databaseA foot-gun inside a trading strategystore
Pointers, manual memoryThe virtual machine manages memoryNothing to manage
An economic calendarNo calendar feed is wired in yetPlanned
ONNX and Python bridgesCovered by a simpler routeai.*

Nothing in this table blocks a strategy. Where a real one needs the capability, Tide gets its own version of it rather than a port of somebody else's.

↑ Back to top

Status and getting in touch

Tide is being built now. This page describes the language as designed and as it is being implemented, so parts of it will change before the first release. Nothing here is available to download today.

If you write trading robots and want to be told when Tide opens, write to office@sghk.org and say so.

eTrader is a trading-technology platform, licensed by SGHK Softwares Limited (Hong Kong). It is software: not a broker, not an exchange and not a financial adviser, and it never holds client money. Trading accounts and order execution are provided by the independent brokers you connect to. Trading leveraged products carries a high risk of losing money rapidly. A trading robot does not reduce that risk and can lose money faster than you would by hand. Nothing on this page is investment advice.

↑ Back to top