Strings and text

View as MarkdownOpen the editor

Strings in a wrun indicator live in string slots: byte-capped channels written once per bar from onBar() and read by a text mark, a label, a table cell or a card.

Typed words enter once, through a text setting (pt_<name>() in onStart(), Setting kinds); per-bar text leaves through string slots and never becomes an output. On the way out, the generated sb_* builder turns the numbers the indicator computes into the words the chart shows, without allocating: a price with the decimals its tick size calls for, a percent with its sign, a volume as 1.2M, a span as 2h 15m, the bar's clock in a pattern you choose. Inside the module, AssemblyScript's String has the JavaScript-style methods for one-time work. This page is the slot, the builder's three steps and every call, the capacity rule, a second builder, the String methods and the Pine mappings.

Where strings go

Declare a slot at the top of the file, and what reads it, then send text to it from onBar():

wrun
string("note", { max_bytes: 64 });
render.text("note_mark", { y: "average", text: "note" });

The build generates three senders per slot: str_note(s) encodes and sends a whole string, str_note_sb() sends the shared line buffer, and str_note_tb(tb) sends a TextBuilder of your own. An indicator must not allocate per bar, so the ./sdk/fmt module ships a TextBuilder: a byte buffer allocated once that spells text and numbers into itself, and the generated ./gen/strings module builds its sb_* calls on one of them. The senders belong in onBar(). A slot not written that bar is absent, which is distinct from a written empty string, and the difference is what gates render.text: an absent slot draws nothing.

The string name

The declaration is called string, the same word as the type, and the two share a file without a clash: the declaration is a top-level statement that only writes the sheet, so let label: string = ""; compiles beside it:

wrun
string("note", { max_bytes: 64 });
let label: string = "";

Three steps

  1. Declare the slot at the top of the file, and what reads it (a text mark, a label, a table cell).
  2. Build the line in onBar(): sb_clear() first, then the sb_* calls in reading order. Nothing here allocates, so the same line can be rebuilt on every bar of the history and on every live tick.
  3. Send it with str_<slot>_sb(). The sender hands the chart the line and returns the slot's index (a label handle's text(...) takes that index).
output("average", line, overlay, { color: "#38bdf8", width: 2, description: "20-bar average" });
string("readout", { max_bytes: 48 }); // step 1: the slot
render.text("readout_mark", { y: "average", text: "readout", size: 10 }); // and what reads it

let sma = new Sma(20);

function onStart(): void {
  sma = new Sma(20);
}

function onBar(): void {
  const close = bar.close();
  const average = sma.update(close);
  if (isNaN(average)) return;
  out_average(average);
  sb_clear(); // step 2: build the line
  sb_price(close, 0.01); // 1234.57 when close is 1234.5678 and the tick is 0.01
  sb_text(" is ");
  sb_signed(((close - average) / average) * 100.0, 1); // +1.3 or -0.8
  sb_text("% from the average");
  str_readout_sb(); // step 3: send it
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Every call

The generated ./gen/strings module wraps one shared TextBuilder sized to the largest max_bytes you declared. Every sb_* call appends to it; every str_<slot>_* call sends it.

CallAppends
sb_clear()nothing: starts a new line (call it first)
sb_text(s: string)the string's UTF-8, one code point at a time
sb_char(code: i32)one code point (a code outside 0..0x10FFFF or a surrogate becomes U+FFFD)
sb_int(value: i64)an integer, - first when negative
sb_f64(value: f64, decimals: i32)fixed point with decimals (0..9) fraction digits, rounded half up; NaN, Inf, -Inf spelled out
sb_auto(value: f64)five significant digits with the trailing fraction zeros trimmed; the digits left of the point are never rounded away
sb_price(value: f64, tick: f64 = 0)fixed point with the decimals the tick implies (0.5 is 1, 0.01 is 2, 0.00001 is 5, at most 9); tick 0 or NaN means unknown and formats like sb_auto
sb_pct(value: f64, decimals: i32 = 1)the value, already in percent units, in fixed point then %
sb_signed(value: f64, decimals: i32)fixed point with + above zero, - below, no sign at zero
sb_compact(value: f64, decimals: i32 = 1)the value scaled to K, M, B or T at 1e3, 1e6, 1e9, 1e12, trailing fraction zeros trimmed, sign kept
sb_time(t: f64, pattern: string, offsetSec: i32 = 0)the clock time of t (epoch seconds) shifted by offsetSec, spelled by the pattern's tokens yyyy MM dd HH mm ss MMM EEE; other characters copied; NaN is NaN
sb_duration(seconds: f64)the two largest non-zero units of d, h, m, s; zero is 0s
sb_spaces(n: i32)n spaces
sb_overflowed(): boolnothing: answers whether the line has outgrown the buffer since sb_clear() (sending it would be refused)
str_<slot>_sb(): i32nothing: sends the shared line to the slot, returns its index; a line over the slot's max_bytes is refused by name
str_<slot>(s: string): i32nothing: clears, appends s, sends the same way
str_<slot>_tb(tb: TextBuilder): i32nothing: sends another builder's line to the slot, returns its index; a builder that overflowed stops the run by name

A non-finite number spells its word (NaN, Inf, -Inf) and nothing else: no sign, no %, no unit. Every formatting call below shows its output for the value it is given.

output("last_close", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" });
string("numbers", { max_bytes: 64 });
string("sizes", { max_bytes: 64 });
string("clock", { max_bytes: 64 });
string("words", { max_bytes: 64 });
render.table("catalog", { rows: 4, cols: 1, cells: ["numbers", "sizes", "clock", "words"], position: "top_left" });

let firstBarTime: f64 = NaN;

function onBar(): void {
  const close = bar.close();
  const volume = bar.volume();
  const barTime = bar.time();
  if (isNaN(firstBarTime)) firstBarTime = barTime;
  if (isNaN(close)) return;
  out_last_close(close);

  sb_clear();
  sb_f64(1234.5678, 2); // 1234.57
  sb_spaces(1); // one space
  sb_auto(1234.5678); // 1234.6
  sb_spaces(1);
  sb_auto(0.00012345); // 0.00012345
  sb_spaces(1);
  sb_price(1234.5678, 0.5); // 1234.6 (a 0.5 tick: one decimal)
  sb_spaces(1);
  sb_pct(12.345, 1); // 12.3%
  sb_spaces(1);
  sb_signed(3.5, 1); // +3.5
  sb_spaces(1);
  sb_int(-42); // -42
  str_numbers_sb(); // 1234.57 1234.6 0.00012345 1234.6 12.3% +3.5 -42

  sb_clear();
  sb_compact(1500.0, 1); // 1.5K
  sb_text(" ");
  sb_compact(2000000.0, 1); // 2M
  sb_text(" ");
  sb_compact(-1234567.0, 2); // -1.23M
  sb_text(" ");
  sb_compact(volume, 1); // the bar's volume: 850.3M, 1.2B, ...
  str_sizes_sb();

  sb_clear();
  sb_time(1700000000.0, "yyyy-MM-dd HH:mm:ss", 0); // 2023-11-14 22:13:20
  sb_text(" ");
  sb_time(1700000000.0, "EEE dd MMM yyyy", -18000); // Tue 14 Nov 2023 (five hours west of UTC)
  sb_text(" ");
  sb_duration(7500.0); // 2h 5m
  sb_text(" ");
  sb_duration(barTime - firstBarTime); // the history's span so far: 3d 4h, 12h 30m, ...
  str_clock_sb();

  sb_clear();
  sb_text("héllo"); // héllo (UTF-8 as is)
  sb_char(0x2192); // one arrow character
  sb_text("wörld"); // wörld
  if (sb_overflowed()) sb_clear(); // false here: 15 bytes fit; a line that outgrew the buffer would be refused
  str_words_sb();
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

sb_time takes the time zone as an offset in seconds (-18000 is five hours west of UTC); a run of any other letters in the pattern, and every other character, is copied as is. sb_duration floors to whole seconds.

Capacity and overflow

Strings are never truncated. A slot refuses a line longer than its own max_bytes by name: the run stops and the Console says string slot 0 write of 40 bytes exceeds the slot's max_bytes 32. That holds for a line built with the sb_* calls and for a string handed whole to str_<slot>(s). The shared builder holds as many bytes as the largest max_bytes you declared, and its sender passes the bytes the line asked for, so a line that outgrew the buffer is over every slot's cap and is refused the same way.

Inside a builder, an append that does not fit is dropped whole (a text append stops at the first code point that does not fit; a number, a time or a duration is never split) and every later append is dropped too, so the bytes it holds stay valid. That is bookkeeping, not delivery: the line is still refused when it is sent. sb_overflowed() answers true from the first dropped append until the next sb_clear(), so when a line can run long, check it before sending and build a shorter line instead.

A TextBuilder you construct holds the capacity you give it. It answers the same question with overflowed(), and needed() tells the bytes the whole line asked for, which is the capacity and the max_bytes to declare. Sending one that overflowed stops the run by name: string slot 'mid_text': the TextBuilder overflowed before str_mid_text_tb(); size the builder to the text.

The caps: per slot max_bytes is at most 4096; 64 slots per indicator; 64 KiB of string bytes per row; 2 MiB per run on the chart, strings and frames together. Each is a named refusal, never a truncation (Limits).

A second builder

The TextBuilder class is the same code the sb_* calls use. Construct one in onStart() (it allocates its buffer there, once). Every method returns the builder, so calls chain, and str_<slot>_tb(tb) sends it to a slot.

MethodSignatureDefinition
constructornew TextBuilder(capacity: i32)a builder holding at most capacity bytes; allocate at module start or in onStart()
clearclear(): TextBuilderstarts a new line; length(), needed() and overflowed() return to 0, 0 and false
texttext(s: string): TextBuilderappends the string's UTF-8 one code point at a time (a lone surrogate becomes U+FFFD)
charchar(code: i32): TextBuilderappends one code point
intint(v: i64): TextBuilderappends an integer
f64f64(x: f64, decimals: i32): TextBuilderappends fixed point with decimals (0..9) fraction digits, rounded half up, every finite double exact
autoauto(x: f64): TextBuilderappends five significant digits, trailing fraction zeros trimmed, the digits left of the point exact at every magnitude
priceprice(x: f64, tick: f64 = 0): TextBuilderappends fixed point with the tick's decimal places (at most 9); tick 0 or NaN formats like auto
pctpct(x: f64, decimals: i32 = 1): TextBuilderappends fixed point then %
signedsigned(x: f64, decimals: i32): TextBuilderappends fixed point with + above zero, - below, no sign at zero
compactcompact(x: f64, decimals: i32 = 1): TextBuilderappends K M B T scaling at 1e3, 1e6, 1e9, 1e12, trailing fraction zeros trimmed, sign kept
timetime(t: f64, pattern: string, offsetSec: i32 = 0): TextBuilderappends the clock time of t shifted by offsetSec, tokens yyyy MM dd HH mm ss MMM EEE
durationduration(seconds: f64): TextBuilderappends the two largest non-zero units of d h m s, 0s for zero
spacesspaces(n: i32): TextBuilderappends n spaces
lengthlength(): i32the bytes stored, never above the capacity
neededneeded(): i32the bytes every append since clear() asked for, stored or dropped
ptrptr(): i32the address of the stored bytes (what a sender hands the chart)
overflowedoverflowed(): booltrue when an append was dropped since clear(); sending the builder then stops the run by name

Every method on one builder, each with its output:

output("close_line", line, overlay, { color: "#94a3b8", description: "The close" });
output("line_bytes", line, lower, { description: "Bytes the tour line holds" }); // a count, so its own pane, not the price scale
string("tour", { max_bytes: 96 });
render.table("tour_table", { rows: 1, cols: 1, cells: ["tour"], position: "bottom_left" });

let tb = new TextBuilder(1);

function onStart(): void {
  tb = new TextBuilder(96); // capacity 96: allocated here, once
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  out_close_line(close);
  tb.clear() // length() 0, needed() 0, overflowed() false
    .text("px ") // px
    .price(1234.5678, 0.01) // 1234.57
    .char(0x20) // one space
    .f64(0.125, 2) // 0.13
    .spaces(1) // one space
    .auto(0.00012345) // 0.00012345
    .spaces(1)
    .pct(12.345, 1) // 12.3%
    .spaces(1)
    .signed(-3.5, 1) // -3.5
    .spaces(1)
    .int(42) // 42
    .spaces(1)
    .compact(2500000.0, 1) // 2.5M
    .spaces(1)
    .time(1700000000.0, "HH:mm", 0) // 22:13
    .spaces(1)
    .duration(3661.0); // 1h 1m
  // The line: "px 1234.57 0.13 0.00012345 12.3% -3.5 42 2.5M 22:13 1h 1m"
  out_line_bytes(f64(tb.length())); // 57, and needed() is 57: nothing was dropped
  str_tour_tb(tb); // sends ptr() and length() to the slot; overflowed() is false
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

When a line should survive the shared builder's next sb_clear(), when two lines are built side by side, or when you want the overflow check before sending, a builder of your own is the tool:

input("high", ohlcv.high);
output("mid", line, overlay, { color: "#f59e0b", description: "Bar midpoint" });
string("range", { max_bytes: 32 });
string("mid_text", { max_bytes: 24 });
render.text("range_mark", { y: "mid", text: "range", size: 10 });
render.table("mid_table", { rows: 1, cols: 1, cells: ["mid_text"], position: "top_right" });

let tb = new TextBuilder(1);

function onStart(): void {
  tb = new TextBuilder(24); // its own buffer, allocated once, here
}

function onBar(): void {
  const high = bar.high();
  const low = bar.low();
  const mid = (high + low) / 2.0;
  if (isNaN(mid)) return;
  out_mid(mid);
  // The shared builder, sent to one slot: "1230.00 .. 1240.50".
  sb_clear();
  sb_price(low, 0.01);
  sb_text(" .. ");
  sb_price(high, 0.01);
  str_range_sb();
  // A second builder keeps its own line: build, check, then send it to
  // another slot. "mid 1235.25 (10.5 wide)" is 23 bytes and fits; a wider
  // market with more digits would not, and the shorter line goes instead.
  tb.clear().text("mid ").price(mid, 0.01).text(" (").auto(high - low).text(" wide)");
  if (tb.overflowed()) tb.clear().text("mid ").price(mid, 0.01);
  str_mid_text_tb(tb);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The check before str_mid_text_tb(tb) is what keeps a long line from ending the run: a builder that overflowed is never sent short. The builder never sends anything itself: only the generated str_<slot>_* calls reach the chart, so a slot is always named by its declaration.

Chart names in text

{{symbol}}, {{exchange}} and {{timeframe}} inside a string output, a label, a table cell or a card are replaced by the chart when it draws them: the symbol the chart header shows, its exchange name, and the short label the interval button shows. The indicator never sees the names: it writes the placeholder as plain text and the chart fills it in at display time, so one module reads right on every chart it is added to.

wrun
sb_clear();
sb_text("{{symbol}} {{timeframe}} on {{exchange}}: ");
sb_signed(change, 2);
sb_text("%");
str_readout_sb();

Any other {{...}} text is shown as written. Alert messages keep their own placeholders (Alerts). Protected indicators that run in the cloud show the placeholders as written for now.

String methods

AssemblyScript's String has the JavaScript methods you expect. They work on string values in the module, in onStart() or per bar, and the result goes out through str_<slot>(s).

MethodSignatureDescription
splits.split(separator)splits into an array of substrings (string[])
concats.concat(other)joins two strings; + does the same
substrings.substring(start, end?)the characters between two indexes
toUpperCases.toUpperCase()uppercase copy
toLowerCases.toLowerCase()lowercase copy
trims.trim()whitespace removed from both ends (trimStart, trimEnd too)
replaces.replace(search, replaceWith)the first occurrence replaced (replaceAll for every one)
indexOfs.indexOf(search)the index of the first occurrence, -1 if absent
startsWith, endsWiths.startsWith(prefix)prefix and suffix tests
includess.includes(search)containment test
lengths.lengththe number of UTF-16 units; a property, not a call
charCodeAts.charCodeAt(i)the code unit at i
padStart, padEnds.padStart(width, fill)padding to a width

Numbers do not stringify themselves cheaply: sb_f64 and sb_int are the way to put a number in a line, and the builder is where per-bar text belongs.

Allocation

Every String method that returns a new string allocates it, and the module's runtime never frees: the sandbox compiles with a bump allocator and a 4 MiB memory ceiling, and a module that grows memory after onStart() is refused. Per-bar String work therefore accumulates for the whole history. The sb_* builder is allocation-free by design (one shared buffer sized to the largest slot at module start), so the rule is: String methods for one-time work in onStart() or at module start (a label, a market name, a template), sb_* for everything that happens per bar.

The String methods in one module

A label assembled once in onStart() with the String methods, and per-bar text built with the sb_* builder into a mark and a live tag.

param("period", 20, { min: 1, max: 200 });
output("average", line, overlay, { color: "#38bdf8", width: 2, description: "Simple average" });
output("bar_time", none, overlay, { description: "Bar open in epoch seconds" });
output("stretched", none, overlay, { description: "1 while the close is over two percent from the average" });
string("readout", { max_bytes: 48 });
string("tag", { max_bytes: 48 });
render.text("stretch_mark", { y: "average", text: "readout", size: 10 });
render.label("average_tag", { x: "bar_time", y: "average", text: "tag", size: 11 });

let sma = new Sma(20);
let label: string = "";
let period: i32 = 20;
let barIndex: i32 = 0;

function onStart(): void {
  period = i32(p_period());
  sma = new Sma(period);
  // One-time string work: every method from the table, on a market label.
  const raw = "  btc-usdt:perp  ";
  const parts = raw.trim().split(":");
  let name = parts[0].toUpperCase().replace("-", "/");
  if (name.startsWith("BTC") && name.endsWith("USDT") && name.indexOf("/") == 3 && name.includes("USD")) {
    name = name.concat(" ").concat(parts[1].toLowerCase());
  }
  label = name.substring(0, name.length).padEnd(12, ".");
}

function onBar(): void {
  const close = bar.close();
  const barTime = bar.time();
  const value = sma.update(close);
  barIndex += 1;
  if (isNaN(value)) return;
  const stretch = ((close - value) / value) * 100.0;
  const stretched = Math.abs(stretch) >= 2.0;
  out_average(value);
  out_bar_time(barTime);
  out_stretched(stretched ? 1.0 : 0.0);
  if (stretched) {
    // Per-bar text: the builder, no allocation.
    sb_clear();
    sb_f64(stretch, 1);
    sb_text("% at bar ");
    sb_int(barIndex);
    str_readout_sb();
  }
  sb_clear();
  sb_text(label);
  sb_text(" SMA ");
  sb_f64(value, 2);
  str_tag_sb();
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

label is built once in onStart() and reused on every bar. The readout is written only on stretched bars, so the mark appears only there.

From Pine

  • str.tostring(x) is sb_f64(x, decimals) when you know the decimals and sb_auto(x) when you do not; str.tostring(x, "#.##") is sb_f64(x, 2), which always writes both decimals (12.5 reads 12.50, where Pine drops the trailing zero).
  • str.format("{0} / {1}", a, b) is the chained calls: sb_f64(a, 2), sb_text(" / "), sb_f64(b, 2).
  • str.format_time(t, "yyyy-MM-dd HH:mm", tz) is sb_time(t, "yyyy-MM-dd HH:mm", offsetSec), with the zone as an offset in seconds.