---
title: "Strings and text"
description: "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…"
order: 51
section: "functions"
---

<!-- source: docs/indicators/functions/text-formatting.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Strings and text

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](../settings/kinds.md#text-settings)); 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()`:

```typescript
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:

```typescript
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).

```typescript sample=fn-text-formatting-three-steps
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
}
```

## 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.

| Call | Appends |
| --- | --- |
| `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(): bool` | nothing: answers whether the line has outgrown the buffer since `sb_clear()` (sending it would be refused) |
| `str_<slot>_sb(): i32` | nothing: 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): i32` | nothing: clears, appends `s`, sends the same way |
| `str_<slot>_tb(tb: TextBuilder): i32` | nothing: 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.

```typescript sample=fn-text-formatting-every-call
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();
}
```

`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](../reference/limits.md)).

## 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.

| Method | Signature | Definition |
| --- | --- | --- |
| `constructor` | `new TextBuilder(capacity: i32)` | a builder holding at most `capacity` bytes; allocate at module start or in `onStart()` |
| `clear` | `clear(): TextBuilder` | starts a new line; `length()`, `needed()` and `overflowed()` return to 0, 0 and `false` |
| `text` | `text(s: string): TextBuilder` | appends the string's UTF-8 one code point at a time (a lone surrogate becomes U+FFFD) |
| `char` | `char(code: i32): TextBuilder` | appends one code point |
| `int` | `int(v: i64): TextBuilder` | appends an integer |
| `f64` | `f64(x: f64, decimals: i32): TextBuilder` | appends fixed point with `decimals` (0..9) fraction digits, rounded half up, every finite double exact |
| `auto` | `auto(x: f64): TextBuilder` | appends five significant digits, trailing fraction zeros trimmed, the digits left of the point exact at every magnitude |
| `price` | `price(x: f64, tick: f64 = 0): TextBuilder` | appends fixed point with the tick's decimal places (at most 9); tick 0 or `NaN` formats like `auto` |
| `pct` | `pct(x: f64, decimals: i32 = 1): TextBuilder` | appends fixed point then `%` |
| `signed` | `signed(x: f64, decimals: i32): TextBuilder` | appends fixed point with `+` above zero, `-` below, no sign at zero |
| `compact` | `compact(x: f64, decimals: i32 = 1): TextBuilder` | appends `K` `M` `B` `T` scaling at 1e3, 1e6, 1e9, 1e12, trailing fraction zeros trimmed, sign kept |
| `time` | `time(t: f64, pattern: string, offsetSec: i32 = 0): TextBuilder` | appends the clock time of `t` shifted by `offsetSec`, tokens `yyyy` `MM` `dd` `HH` `mm` `ss` `MMM` `EEE` |
| `duration` | `duration(seconds: f64): TextBuilder` | appends the two largest non-zero units of `d` `h` `m` `s`, `0s` for zero |
| `spaces` | `spaces(n: i32): TextBuilder` | appends `n` spaces |
| `length` | `length(): i32` | the bytes stored, never above the capacity |
| `needed` | `needed(): i32` | the bytes every append since `clear()` asked for, stored or dropped |
| `ptr` | `ptr(): i32` | the address of the stored bytes (what a sender hands the chart) |
| `overflowed` | `overflowed(): bool` | `true` 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:

```typescript sample=fn-text-formatting-builder-tour
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
}
```

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:

```typescript sample=fn-text-formatting-second-builder
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);
}
```

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.

```typescript
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](alerts.md)). 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)`.

| Method | Signature | Description |
| --- | --- | --- |
| `split` | `s.split(separator)` | splits into an array of substrings (`string[]`) |
| `concat` | `s.concat(other)` | joins two strings; `+` does the same |
| `substring` | `s.substring(start, end?)` | the characters between two indexes |
| `toUpperCase` | `s.toUpperCase()` | uppercase copy |
| `toLowerCase` | `s.toLowerCase()` | lowercase copy |
| `trim` | `s.trim()` | whitespace removed from both ends (`trimStart`, `trimEnd` too) |
| `replace` | `s.replace(search, replaceWith)` | the first occurrence replaced (`replaceAll` for every one) |
| `indexOf` | `s.indexOf(search)` | the index of the first occurrence, `-1` if absent |
| `startsWith`, `endsWith` | `s.startsWith(prefix)` | prefix and suffix tests |
| `includes` | `s.includes(search)` | containment test |
| `length` | `s.length` | the number of UTF-16 units; a property, not a call |
| `charCodeAt` | `s.charCodeAt(i)` | the code unit at `i` |
| `padStart`, `padEnd` | `s.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.

```typescript sample=fn-strings
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();
}
```

`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.
