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); 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():
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:
string("note", { max_bytes: 64 });
let label: string = "";Three steps
- Declare the slot at the top of the file, and what reads it (a text mark, a label, a table cell).
- Build the line in
onBar():sb_clear()first, then thesb_*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. - Send it with
str_<slot>_sb(). The sender hands the chart the line and returns the slot's index (a label handle'stext(...)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
}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_ | fixed point with + above zero, - below, no sign at zero |
sb_ | 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_ | the two largest non-zero units of d, h, m, s; zero is 0s |
sb_spaces(n: i32) | n spaces |
sb_ | nothing: answers whether the line has outgrown the buffer since sb_clear() (sending it would be refused) |
str_ | 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_ | 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.
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).
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 Text | 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( | 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:
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:
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.
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).
| Method | Signature | Description |
|---|---|---|
split | s. | splits into an array of substrings (string[]) |
concat | s.concat(other) | joins two strings; + does the same |
substring | s. | 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. | the first occurrence replaced (replaceAll for every one) |
indexOf | s. | the index of the first occurrence, -1 if absent |
startsWith, endsWith | s. | prefix and suffix tests |
includes | s. | 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. | 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();
}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)issb_f64(x, decimals)when you know the decimals andsb_auto(x)when you do not;str.tostring(x, "#.##")issb_f64(x, 2), which always writes both decimals (12.5reads12.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)issb_time(t, "yyyy-MM-dd HH:mm", offsetSec), with the zone as an offset in seconds.